Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/remote-proxy-update-bindings-await-reload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

Fix race in `RemoteProxySession.updateBindings` so it waits for the remote worker to finish reloading with the new bindings before resolving

Previously, `updateBindings` resolved as soon as the config update event was dispatched, long before the remote worker had been re-uploaded and the local proxy worker had unpaused. Callers that issued requests immediately afterwards could see flaky failures — typically "WebSocket connection failed" for JSRPC bindings such as service bindings or dispatch namespaces — because the local proxy worker was still in its paused state during the reload window. `updateBindings` now waits for the next `reloadComplete` event and for the local proxy worker's runtime-message queue to drain before returning, so callers can safely issue requests after `await session.updateBindings(...)`. If the reload fails, the rejection from `updateBindings` carries the underlying error.
7 changes: 7 additions & 0 deletions .changeset/remote-tail-suppress-abort-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

Fix unhandled `AbortError` from `wrangler dev`'s remote tail WebSocket when the bundle rebuilds or the dev session shuts down

The remote-runtime tail-logs WebSocket (`#activeTail` in `RemoteRuntimeController`) was constructed with the same `AbortSignal` that `onBundleStart` aborts to cancel in-flight preview-session operations. The abort destroyed the WebSocket's underlying upgrade request with `AbortError`, which had no `error` listener attached and propagated as an unhandled exception. We now attach an `error` listener at WebSocket construction that ignores errors (logging at debug level), matching the safeguards already present on the `terminate` paths in `#previewToken` and `teardown()`.
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import events from "node:events";
import path from "node:path";
import chalk from "chalk";
import { DeferredPromise } from "miniflare";
Expand Down Expand Up @@ -97,7 +98,35 @@ export async function startRemoteProxySession(
{ ...binding, raw: true },
])
);

// `worker.patchConfig` returns as soon as the config update is dispatched
// — long before the remote worker has actually been re-uploaded with the
// new bindings and the local proxy worker has unpaused. If we returned
// here, callers issuing requests immediately afterwards would race the
// reload window, often surfacing as "WebSocket connection failed" for
// JSRPC bindings.
//
// Subscribe BEFORE patchConfig so we don't miss either event.
// `events.once()` resolves on `reloadComplete` and rejects if `error`
// is emitted first (with the event payload as the rejection value).
const reloadComplete = events.once(worker.raw, "reloadComplete");
await worker.patchConfig({ bindings: rawNewBindings });
try {
await reloadComplete;
} catch (errOrEvent) {
throw errOrEvent instanceof Error
? errOrEvent
: new Error(
`RemoteProxySession.updateBindings failed during reload: ${
(errOrEvent as { reason?: string })?.reason ?? "unknown"
}`,
{ cause: errOrEvent }
);
}
// The "play" message that resumes the local proxy worker is enqueued on
// this mutex during onReloadComplete. Wait for it to drain so the proxy
// actually unpauses before we return — matches what `worker.fetch` does.
await worker.raw.proxy.runtimeMessageMutex.drained();
};

return {
Expand Down
5 changes: 5 additions & 0 deletions packages/wrangler/src/api/startDevWorker/DevEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export class DevEnv extends EventEmitter implements ControllerBus {
* - RuntimeController emits devRegistryUpdate → ConfigController
* - ProxyController emits previewTokenExpired → RuntimeControllers
* - Any controller emits error → DevEnv error handler
*
* `reloadComplete` is also re-emitted as an external EventEmitter event
* (`devEnv.on("reloadComplete", ...)`) so callers like
* `RemoteProxySession.updateBindings` can wait for the reload to finish.
*/
dispatch(event: ControllerEvent): void {
switch (event.type) {
Expand Down Expand Up @@ -117,6 +121,7 @@ export class DevEnv extends EventEmitter implements ControllerBus {

case "reloadComplete":
this.proxy.onReloadComplete(event);
this.emit("reloadComplete", event);
break;

case "devRegistryUpdate":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,18 @@ export class RemoteRuntimeController extends RuntimeController {
);

this.#activeTail.on("message", realishPrintLogs);
// Best-effort log streaming: ignore errors instead of letting them
// propagate as unhandled exceptions. The signal we pass to the `ws`
// constructor is shared with `onBundleStart`'s abort, which destroys
// the underlying upgrade request with `AbortError` every time a new
// bundle starts. The existing `terminate` paths in `#previewToken`
// and `teardown()` re-install no-op listeners before shutting the
// tail down — this listener covers the window between WebSocket
// construction and the next terminate, plus any transient network
// errors during normal operation.
this.#activeTail.on("error", (err) => {
logger.debug("Active tail WebSocket error (ignored):", err);
});
}
return workerPreviewToken;
} catch (err: unknown) {
Expand Down
Loading