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. 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/remoteBindings/start-remote-proxy-session.ts b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts index fc91ef1341..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"; @@ -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 { 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": 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) {