From 93c315593ff4914be3211081d6cf2fff932e6adf Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Thu, 7 May 2026 20:30:30 +0100 Subject: [PATCH 1/5] [wrangler] Re-emit reloadComplete event externally on DevEnv DevEnv extends EventEmitter and routes events between controllers via its internal `dispatch()` method. Re-emit `reloadComplete` events on the EventEmitter so external callers (in the next commit, `RemoteProxySession.updateBindings`) can wait for a remote upload to finish. Other event types stay internal for now. --- packages/wrangler/src/api/startDevWorker/DevEnv.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/wrangler/src/api/startDevWorker/DevEnv.ts b/packages/wrangler/src/api/startDevWorker/DevEnv.ts index 8d2e18c814..8f5bd60aeb 100644 --- a/packages/wrangler/src/api/startDevWorker/DevEnv.ts +++ b/packages/wrangler/src/api/startDevWorker/DevEnv.ts @@ -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) { @@ -117,6 +121,7 @@ export class DevEnv extends EventEmitter implements ControllerBus { case "reloadComplete": this.proxy.onReloadComplete(event); + this.emit("reloadComplete", event); break; case "devRegistryUpdate": From 5f7075780998a915acb3b9c4aa5fb1f6d9639ba7 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Thu, 7 May 2026 20:30:42 +0100 Subject: [PATCH 2/5] [wrangler] Make RemoteProxySession.updateBindings wait for the reload to complete Previously `updateBindings` resolved as soon as `worker.patchConfig` dispatched the configUpdate event, long before the remote worker had been re-uploaded with the new bindings and the local proxy worker had unpaused. Callers issuing requests immediately after `updateBindings` could see flaky failures (typically "WebSocket connection failed" for JSRPC bindings like service bindings or dispatch namespaces) because the local proxy worker was still in its paused state during the reload window. `updateBindings` now subscribes to the `reloadComplete` event before calling `patchConfig`, then awaits either that event (success) or the DevEnv `error` event (non-recoverable failure). After reload it also drains the local proxy worker's runtime-message mutex so the "play" message that resumes the proxy has been delivered, mirroring what `worker.fetch` already does. --- .../start-remote-proxy-session.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts index fc91ef1341..2d3547c62f 100644 --- a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts +++ b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts @@ -97,7 +97,45 @@ 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. The cycle + // ends with either: + // - `reloadComplete` (success), or + // - an `error` event re-emitted by DevEnv (non-recoverable failure). + const reloadComplete = new Promise((resolve, reject) => { + const onReload = () => { + worker.raw.off("error", onError); + resolve(); + }; + const onError = (errOrEvent: unknown) => { + worker.raw.off("reloadComplete", onReload); + reject( + errOrEvent instanceof Error + ? errOrEvent + : new Error( + `RemoteProxySession.updateBindings failed during reload: ${ + (errOrEvent as { reason?: string })?.reason ?? "unknown" + }`, + { cause: errOrEvent } + ) + ); + }; + worker.raw.once("reloadComplete", onReload); + worker.raw.once("error", onError); + }); await worker.patchConfig({ bindings: rawNewBindings }); + await reloadComplete; + // 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 { From bd678a2ce7432fa679b70e5356ec4cfb968ecf04 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Thu, 7 May 2026 20:30:48 +0100 Subject: [PATCH 3/5] Add changeset for RemoteProxySession.updateBindings reload wait --- .changeset/remote-proxy-update-bindings-await-reload.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/remote-proxy-update-bindings-await-reload.md diff --git a/.changeset/remote-proxy-update-bindings-await-reload.md b/.changeset/remote-proxy-update-bindings-await-reload.md new file mode 100644 index 0000000000..cb1f76bcd5 --- /dev/null +++ b/.changeset/remote-proxy-update-bindings-await-reload.md @@ -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. From c7ce076f6fbed8806258c353db61fa48bf1ba340 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Fri, 8 May 2026 13:28:27 +0100 Subject: [PATCH 4/5] [wrangler] Suppress AbortError from remote tail WebSocket on bundle restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remote-runtime tail-logs WebSocket (`#activeTail` in `RemoteRuntimeController`) is constructed with `signal: this.#abortController.signal`. The `ws` package wires that signal to the underlying HTTP upgrade request via Node's `addAbortSignal()` helper, so when `onBundleStart` aborts the controller to cancel in-flight preview-session operations, the WebSocket's request is destroyed with `AbortError` and emits an `error` event. Only a `message` listener was attached at construction, so the `error` had no handler and propagated as an unhandled exception — visible in e2e runs as a trailing "Unhandled Errors / Uncaught Exception" in the test report. Attach an `error` listener at WebSocket construction that ignores errors (debug-logged), matching the safeguards already present on the `terminate` paths in `#previewToken` and `teardown()`. This covers the window between WebSocket construction and the next `terminate`, plus any transient network errors during normal operation. --- .changeset/remote-tail-suppress-abort-error.md | 7 +++++++ .../api/startDevWorker/RemoteRuntimeController.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 .changeset/remote-tail-suppress-abort-error.md diff --git a/.changeset/remote-tail-suppress-abort-error.md b/.changeset/remote-tail-suppress-abort-error.md new file mode 100644 index 0000000000..05c7adaf98 --- /dev/null +++ b/.changeset/remote-tail-suppress-abort-error.md @@ -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()`. diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index 32afcf750d..dc88158837 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -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) { From f1586acef1aefc0a33c203c1b849c05f1cbdcb51 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Fri, 8 May 2026 15:35:25 +0100 Subject: [PATCH 5/5] [wrangler] Simplify RemoteProxySession.updateBindings reload wait using events.once() Address PR feedback to use `node:events`'s `events.once()` helper, which resolves on the named event and rejects on `error`, instead of hand-rolling a Promise with cross-cleanup listeners. Same behavior, fewer lines, matches the existing convention used elsewhere in wrangler (`dev.ts`, `pages/dev.ts`, `api/dev.ts`, `ProxyController.ts`, `check/commands.ts`). --- .../start-remote-proxy-session.ts | 43 ++++++++----------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts index 2d3547c62f..1d14f2346c 100644 --- a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts +++ b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts @@ -1,3 +1,4 @@ +import events from "node:events"; import path from "node:path"; import chalk from "chalk"; import { DeferredPromise } from "miniflare"; @@ -105,33 +106,23 @@ export async function startRemoteProxySession( // reload window, often surfacing as "WebSocket connection failed" for // JSRPC bindings. // - // Subscribe BEFORE patchConfig so we don't miss either event. The cycle - // ends with either: - // - `reloadComplete` (success), or - // - an `error` event re-emitted by DevEnv (non-recoverable failure). - const reloadComplete = new Promise((resolve, reject) => { - const onReload = () => { - worker.raw.off("error", onError); - resolve(); - }; - const onError = (errOrEvent: unknown) => { - worker.raw.off("reloadComplete", onReload); - reject( - errOrEvent instanceof Error - ? errOrEvent - : new Error( - `RemoteProxySession.updateBindings failed during reload: ${ - (errOrEvent as { reason?: string })?.reason ?? "unknown" - }`, - { cause: errOrEvent } - ) - ); - }; - worker.raw.once("reloadComplete", onReload); - worker.raw.once("error", onError); - }); + // 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 }); - await reloadComplete; + 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.