From caca08d20f6ae5a67cc20f602892101bfdedba65 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Thu, 24 Sep 2026 18:24:12 +0100 Subject: [PATCH 01/12] Fail early when `createTestHarness()` cannot start its proxy under Bun --- .changeset/fuzzy-bears-listen.md | 7 +++ .../src/__tests__/api/test-harness.test.ts | 43 +++++++++++++++++ .../src/__tests__/utils/is-bun.test.ts | 18 +++++++ .../src/api/startDevWorker/ProxyController.ts | 14 ++++-- .../wrangler/src/api/test-harness-runtime.ts | 47 +++++++++++++++++++ packages/wrangler/src/api/test-harness.ts | 9 ++++ packages/wrangler/src/utils/is-bun.ts | 8 ++++ .../src/utils/logPossibleBugMessage.ts | 3 +- .../templates/startDevWorker/ProxyWorker.ts | 5 ++ 9 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 .changeset/fuzzy-bears-listen.md create mode 100644 packages/wrangler/src/__tests__/api/test-harness.test.ts create mode 100644 packages/wrangler/src/__tests__/utils/is-bun.test.ts create mode 100644 packages/wrangler/src/api/test-harness-runtime.ts create mode 100644 packages/wrangler/src/utils/is-bun.ts diff --git a/.changeset/fuzzy-bears-listen.md b/.changeset/fuzzy-bears-listen.md new file mode 100644 index 00000000000..da412f9b2a1 --- /dev/null +++ b/.changeset/fuzzy-bears-listen.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Fail early when `createTestHarness()` cannot start its proxy under Bun + +When Bun does not deliver the proxy control request required by Miniflare, `createTestHarness()` now throws an actionable startup error. This prevents the process from remaining idle while every request to the test harness hangs indefinitely. diff --git a/packages/wrangler/src/__tests__/api/test-harness.test.ts b/packages/wrangler/src/__tests__/api/test-harness.test.ts new file mode 100644 index 00000000000..3118d796345 --- /dev/null +++ b/packages/wrangler/src/__tests__/api/test-harness.test.ts @@ -0,0 +1,43 @@ +import { describe, it, vi } from "vitest"; +import { waitForBunProxyMessages } from "../../api/test-harness-runtime"; + +describe("waitForBunProxyMessages", () => { + it("allows Bun when proxy messages are delivered", async ({ expect }) => { + await expect( + waitForBunProxyMessages(() => Promise.resolve(true), 1) + ).resolves.toBeUndefined(); + }); + + it("throws a UserError when Bun fails to deliver proxy messages", async ({ + expect, + }) => { + await expect( + waitForBunProxyMessages(() => Promise.resolve(false), 1) + ).rejects.toMatchObject({ + message: + "`createTestHarness()` could not start because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Run your tests using Node.js instead.", + telemetryMessage: "test harness bun proxy request failed", + }); + }); + + it("throws a UserError when Bun does not deliver proxy messages", async ({ + expect, + }) => { + vi.useFakeTimers(); + + try { + const rejection = expect( + waitForBunProxyMessages(() => new Promise(() => {}), 100) + ).rejects.toMatchObject({ + message: + "`createTestHarness()` could not start because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Run your tests using Node.js instead.", + telemetryMessage: "test harness bun proxy request failed", + }); + + await vi.advanceTimersByTimeAsync(100); + await rejection; + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/wrangler/src/__tests__/utils/is-bun.test.ts b/packages/wrangler/src/__tests__/utils/is-bun.test.ts new file mode 100644 index 00000000000..6eea4df2ff5 --- /dev/null +++ b/packages/wrangler/src/__tests__/utils/is-bun.test.ts @@ -0,0 +1,18 @@ +import { describe, it } from "vitest"; +import { isBun } from "../../utils/is-bun"; + +describe("isBun", () => { + it("detects whether Bun is the current runtime", ({ expect }) => { + const originalBunVersion = process.versions.bun; + + try { + process.versions.bun = undefined; + expect(isBun()).toBe(false); + + process.versions.bun = "1.4.2"; + expect(isBun()).toBe(true); + } finally { + process.versions.bun = originalBunVersion; + } + }); +}); diff --git a/packages/wrangler/src/api/startDevWorker/ProxyController.ts b/packages/wrangler/src/api/startDevWorker/ProxyController.ts index d507f29bcbb..1e033409deb 100644 --- a/packages/wrangler/src/api/startDevWorker/ProxyController.ts +++ b/packages/wrangler/src/api/startDevWorker/ProxyController.ts @@ -328,18 +328,19 @@ export class ProxyController extends Controller { } runtimeMessageMutex = new Mutex(); + latestReloadCompleteMessage = Promise.resolve(true); async sendMessageToProxyWorker( message: ProxyWorkerIncomingRequestBody, retries = 3 - ): Promise { + ): Promise { if (this._torndown) { - return; + return false; } // Don't do any async work here. Enqueue the message with the mutex immediately. try { - await this.runtimeMessageMutex.runWith(async () => { + const response = await this.runtimeMessageMutex.runWith(async () => { const { proxyWorker } = await this.ready.promise; const ready = await proxyWorker.ready.catch(() => undefined); @@ -355,9 +356,11 @@ export class ProxyController extends Controller { } ); }); + + return response?.status === 204; } catch (cause) { if (this._torndown) { - return; + return false; } const error = castErrorCause(cause); @@ -370,6 +373,7 @@ export class ProxyController extends Controller { `Failed to send message to ProxyWorker: ${JSON.stringify(message)}`, error ); + return false; } } async sendMessageToInspectorProxyWorker( @@ -456,7 +460,7 @@ export class ProxyController extends Controller { this.latestConfig = data.config; this.latestBundle = data.bundle; - void this.sendMessageToProxyWorker({ + this.latestReloadCompleteMessage = this.sendMessageToProxyWorker({ type: "play", proxyData: data.proxyData, }); diff --git a/packages/wrangler/src/api/test-harness-runtime.ts b/packages/wrangler/src/api/test-harness-runtime.ts new file mode 100644 index 00000000000..a0cfd145678 --- /dev/null +++ b/packages/wrangler/src/api/test-harness-runtime.ts @@ -0,0 +1,47 @@ +import { UserError } from "@cloudflare/workers-utils"; + +// A working dispatcher delivers the ProxyWorker control request in milliseconds. +// Five seconds leaves ample startup headroom while turning Bun's ignored +// `dispatcher` option into an actionable error instead of an indefinite hang. +const BUN_PROXY_MESSAGE_TIMEOUT_MS = 5_000; + +function createBunProxyMessageError(): UserError { + return new UserError( + "`createTestHarness()` could not start because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Run your tests using Node.js instead.", + { telemetryMessage: "test harness bun proxy request failed" } + ); +} + +/** + * Waits for Bun to deliver the control message that unlocks the test harness + * proxy. Current Bun releases are affected by an upstream dispatcher issue and + * do not deliver this message. A possible future compatible release can resolve + * normally; until then, the timeout prevents requests from hanging indefinitely. + * + * @param waitForProxyMessages Callback that reports whether the latest proxy + * control message was delivered. + * @param timeoutMs Maximum time to wait when running under Bun. + * @returns A promise that resolves after the proxy control message is delivered. + * @throws {UserError} If Bun does not deliver the proxy control message. + */ +export async function waitForBunProxyMessages( + waitForProxyMessages: () => Promise, + timeoutMs = BUN_PROXY_MESSAGE_TIMEOUT_MS +): Promise { + let timeoutId: ReturnType | undefined; + try { + const delivered = await Promise.race([ + waitForProxyMessages(), + new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => { + reject(createBunProxyMessageError()); + }, timeoutMs); + }), + ]); + if (!delivered) { + throw createBunProxyMessageError(); + } + } finally { + clearTimeout(timeoutId); + } +} diff --git a/packages/wrangler/src/api/test-harness.ts b/packages/wrangler/src/api/test-harness.ts index 16fcf4f3d55..d89b9e27b34 100644 --- a/packages/wrangler/src/api/test-harness.ts +++ b/packages/wrangler/src/api/test-harness.ts @@ -28,9 +28,11 @@ import { validateNodeCompatMode } from "../deployment-bundle/node-compat"; import { getDurableObjectClassNameToUseSQLiteMap } from "../dev/class-names-sqlite"; import { runWithLogLevel } from "../logger"; import { requireApiToken, requireAuth } from "../user"; +import { isBun } from "../utils/is-bun"; import { DevEnv } from "./startDevWorker/DevEnv"; import { MultiworkerRuntimeController } from "./startDevWorker/MultiworkerRuntimeController"; import { NoOpProxyController } from "./startDevWorker/NoOpProxyController"; +import { waitForBunProxyMessages } from "./test-harness-runtime"; import type { CfAccount } from "../dev/create-worker-preview"; import type { ErrorEvent } from "./startDevWorker/events"; import type { WranglerStartDevWorkerInput } from "./startDevWorker/types"; @@ -448,6 +450,8 @@ type DebugLog = { * const response = await server.fetch("/api/users"); * await server.close(); * ``` + * @throws {UserError} If Bun cannot deliver the proxy control request required + * to start the server. */ export function createTestHarness(options?: TestHarnessOptions): TestHarness { let initialOptions = options; @@ -680,6 +684,11 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { waitForReloadComplete(session), updateConfig(session, inputs), ]); + if (isBun()) { + await waitForBunProxyMessages( + () => session.primaryDevEnv.proxy.latestReloadCompleteMessage + ); + } debugLog("startup - completed"); return session; } catch (error) { diff --git a/packages/wrangler/src/utils/is-bun.ts b/packages/wrangler/src/utils/is-bun.ts new file mode 100644 index 00000000000..c1c08bc6834 --- /dev/null +++ b/packages/wrangler/src/utils/is-bun.ts @@ -0,0 +1,8 @@ +/** + * Returns whether Wrangler is running in the Bun runtime. + * + * @returns Whether Wrangler is running in the Bun runtime. + */ +export function isBun(): boolean { + return !!process.versions.bun; +} diff --git a/packages/wrangler/src/utils/logPossibleBugMessage.ts b/packages/wrangler/src/utils/logPossibleBugMessage.ts index 718ba7b0d84..801bbb94ebe 100644 --- a/packages/wrangler/src/utils/logPossibleBugMessage.ts +++ b/packages/wrangler/src/utils/logPossibleBugMessage.ts @@ -1,12 +1,13 @@ import { logger } from "../logger"; import { updateCheck } from "../update-check"; import { fgGreenColor, resetColor } from "./constants"; +import { isBun } from "./is-bun"; /** * Write a message to the log that tells the user what they might do after we have reported an unexpected error. */ export async function logPossibleBugMessage() { - if (process.versions.bun) { + if (isBun()) { logger.warn( `Wrangler does not support the Bun runtime. Please try this command again using Node.js via \`npm\` or \`pnpm\`. Alternatively, make sure you're not passing the \`--bun\` flag when running \`bun run wrangler ...\`` ); diff --git a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts index 87ca82c8941..5c9f55a210a 100644 --- a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts +++ b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts @@ -96,6 +96,11 @@ export class ProxyWorker implements DurableObject { .forEach((ws) => ws.send("reload")); break; + + default: + // Bun currently drops `cf.hostMetadata`, so acknowledge only control + // requests whose payload actually reached the ProxyWorker. + return new Response(null, { status: 400 }); } return new Response(null, { status: 204 }); From 850adad6188929c849c3e2a04277f9c0cadc36f5 Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:59:33 -0400 Subject: [PATCH 02/12] [wrangler] Keep getWorker() working under Bun and fail only the proxy doors The guard in #15845 ran inside listen(), so under Bun it also refused server.getWorker().fetch(), which never goes through the ProxyWorker and worked before. Move the check to server.fetch(), and have the ProxyWorker answer with a 503 once a control request arrives without its payload, so requests to the listen() URL fail loudly instead of queueing forever. --- .changeset/fuzzy-bears-listen.md | 4 +- .../startDevWorker/ProxyController.test.ts | 48 +++++++++++++++++++ .../src/__tests__/api/test-harness.test.ts | 4 +- .../wrangler/src/api/test-harness-runtime.ts | 4 +- packages/wrangler/src/api/test-harness.ts | 19 +++++--- .../templates/startDevWorker/ProxyWorker.ts | 44 ++++++++++++++++- 6 files changed, 109 insertions(+), 14 deletions(-) diff --git a/.changeset/fuzzy-bears-listen.md b/.changeset/fuzzy-bears-listen.md index da412f9b2a1..be878172579 100644 --- a/.changeset/fuzzy-bears-listen.md +++ b/.changeset/fuzzy-bears-listen.md @@ -2,6 +2,6 @@ "wrangler": patch --- -Fail early when `createTestHarness()` cannot start its proxy under Bun +Fail fast when `createTestHarness()` requests cannot reach the proxy under Bun -When Bun does not deliver the proxy control request required by Miniflare, `createTestHarness()` now throws an actionable startup error. This prevents the process from remaining idle while every request to the test harness hangs indefinitely. +Bun's `fetch()` ignores the undici `dispatcher` that Miniflare uses to deliver the ProxyWorker's control requests, so under Bun `server.fetch()` and requests to the URL returned by `listen()` used to hang forever with no error. `server.fetch()` now throws an actionable `UserError`, and the ProxyWorker answers requests to the `listen()` URL with a 503 that names the cause. `listen()` and `server.getWorker().fetch()` keep working under Bun, since `getWorker()` dispatches to the Worker directly without going through the proxy. diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts index f5a08b65136..179bb7602dd 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts @@ -4,6 +4,7 @@ import { ProxyController } from "../../../api/startDevWorker/ProxyController"; import { FakeBus } from "../../helpers/fake-bus"; import { mockConsoleMethods } from "../../helpers/mock-console"; import type { SerializedError } from "../../../api/startDevWorker/events"; +import type { StartDevWorkerOptions } from "../../../api/startDevWorker/types"; describe("ProxyController", () => { mockConsoleMethods(); @@ -62,4 +63,51 @@ describe("ProxyController", () => { expect(event.stack).toContain("Error: boom"); expect(event.exceptionDetails?.exceptionId).toBe(1); }); + + test("ProxyWorker answers with a 503 once a control request arrives without its payload", async ({ + expect, + onTestFinished, + }) => { + // Regression test for https://github.com/cloudflare/workers-sdk/issues/15717: + // Bun's `fetch()` ignores the undici dispatcher that attaches the + // `cf.hostMetadata` payload, so control requests arrive authorised but + // empty, no `play` ever lands, and queued requests used to hang forever. + const bus = new FakeBus(); + const controller = new ProxyController(bus); + onTestFinished(() => controller.teardown()); + + controller.onConfigUpdate({ + type: "configUpdate", + config: { + dev: { inspector: false, server: { hostname: "127.0.0.1", port: 0 } }, + } as StartDevWorkerOptions, + }); + const { proxyWorker } = await controller.ready.promise; + + // The same control request delivered with its payload is acknowledged, + // so the 400 below comes from the missing payload alone. + await expect( + controller.sendMessageToProxyWorker({ type: "pause" }) + ).resolves.toBe(true); + + // Whether this request reaches the ProxyWorker before or after the empty + // control request, it must be answered rather than left in the queue. + const sentBefore = proxyWorker.dispatchFetch("http://example.com/before"); + + const control = await proxyWorker.dispatchFetch( + "http://dummy/cdn-cgi/ProxyWorker/play", + { headers: { Authorization: controller.secret } } + ); + expect(control.status).toBe(400); + + for (const response of [ + await sentBefore, + await proxyWorker.dispatchFetch("http://example.com/after"), + ]) { + expect(response.status).toBe(503); + expect(await response.text()).toContain( + "https://github.com/oven-sh/bun/issues/39247" + ); + } + }); }); diff --git a/packages/wrangler/src/__tests__/api/test-harness.test.ts b/packages/wrangler/src/__tests__/api/test-harness.test.ts index 3118d796345..541eddb9a57 100644 --- a/packages/wrangler/src/__tests__/api/test-harness.test.ts +++ b/packages/wrangler/src/__tests__/api/test-harness.test.ts @@ -15,7 +15,7 @@ describe("waitForBunProxyMessages", () => { waitForBunProxyMessages(() => Promise.resolve(false), 1) ).rejects.toMatchObject({ message: - "`createTestHarness()` could not start because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Run your tests using Node.js instead.", + "`server.fetch()` cannot reach the Worker because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Use `server.getWorker().fetch()`, which dispatches to the Worker directly, or run your tests using Node.js.", telemetryMessage: "test harness bun proxy request failed", }); }); @@ -30,7 +30,7 @@ describe("waitForBunProxyMessages", () => { waitForBunProxyMessages(() => new Promise(() => {}), 100) ).rejects.toMatchObject({ message: - "`createTestHarness()` could not start because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Run your tests using Node.js instead.", + "`server.fetch()` cannot reach the Worker because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Use `server.getWorker().fetch()`, which dispatches to the Worker directly, or run your tests using Node.js.", telemetryMessage: "test harness bun proxy request failed", }); diff --git a/packages/wrangler/src/api/test-harness-runtime.ts b/packages/wrangler/src/api/test-harness-runtime.ts index a0cfd145678..b9897eb4dec 100644 --- a/packages/wrangler/src/api/test-harness-runtime.ts +++ b/packages/wrangler/src/api/test-harness-runtime.ts @@ -7,14 +7,14 @@ const BUN_PROXY_MESSAGE_TIMEOUT_MS = 5_000; function createBunProxyMessageError(): UserError { return new UserError( - "`createTestHarness()` could not start because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Run your tests using Node.js instead.", + "`server.fetch()` cannot reach the Worker because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Use `server.getWorker().fetch()`, which dispatches to the Worker directly, or run your tests using Node.js.", { telemetryMessage: "test harness bun proxy request failed" } ); } /** * Waits for Bun to deliver the control message that unlocks the test harness - * proxy. Current Bun releases are affected by an upstream dispatcher issue and + * proxy, before `server.fetch()` sends a request through it. Current Bun releases are affected by an upstream dispatcher issue and * do not deliver this message. A possible future compatible release can resolve * normally; until then, the timeout prevents requests from hanging indefinitely. * diff --git a/packages/wrangler/src/api/test-harness.ts b/packages/wrangler/src/api/test-harness.ts index d89b9e27b34..31956e1ea7b 100644 --- a/packages/wrangler/src/api/test-harness.ts +++ b/packages/wrangler/src/api/test-harness.ts @@ -318,6 +318,11 @@ export type TestHarness = { * await server.fetch("http://example.com/api/data"); * // Dispatches a request to the API Worker with URL "http://example.com/api/data" * ``` + * + * Under Bun, this method and the URL returned by `listen()` cannot reach the + * Worker, because Bun's `fetch()` ignores the undici `dispatcher` that the + * proxy relies on. This method throws a `UserError` and the URL answers with a + * 503. `server.getWorker().fetch()` dispatches directly and works under Bun. */ fetch: DispatchFetch; /** @@ -450,8 +455,6 @@ type DebugLog = { * const response = await server.fetch("/api/users"); * await server.close(); * ``` - * @throws {UserError} If Bun cannot deliver the proxy control request required - * to start the server. */ export function createTestHarness(options?: TestHarnessOptions): TestHarness { let initialOptions = options; @@ -684,11 +687,6 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { waitForReloadComplete(session), updateConfig(session, inputs), ]); - if (isBun()) { - await waitForBunProxyMessages( - () => session.primaryDevEnv.proxy.latestReloadCompleteMessage - ); - } debugLog("startup - completed"); return session; } catch (error) { @@ -1027,6 +1025,13 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { }, async fetch(input, init) { const session = await resolveSession(); + // Only this door and the `listen()` URL go through the ProxyWorker. + // `getWorker()` dispatches directly, so it keeps working under Bun. + if (isBun()) { + await waitForBunProxyMessages( + () => session.primaryDevEnv.proxy.latestReloadCompleteMessage + ); + } const miniflare = session.primaryDevEnv.proxy.proxyWorker; assert( miniflare, diff --git a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts index 5c9f55a210a..9468fdb13f8 100644 --- a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts +++ b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts @@ -45,6 +45,14 @@ export class ProxyWorker implements DurableObject { requestQueue = new Map>(); requestRetryQueue = new Map>(); + /** + * Set when a ProxyController request arrives without its `cf.hostMetadata` + * payload. Bun's `fetch()` ignores the undici `dispatcher` that Miniflare + * uses to attach that payload, so under Bun no `play` message can ever + * reach this ProxyWorker, and a queued request would wait forever. + */ + controlPayloadMissing = false; + fetch(request: Request) { if (isRequestForLiveReloadWebsocket(request)) { // requests for live-reload websocket @@ -58,6 +66,10 @@ export class ProxyWorker implements DurableObject { return this.processProxyControllerRequest(request); } + if (this.controlPayloadMissing) { + return controlPayloadMissingResponse(); + } + // regular requests to be proxied const deferred = createDeferred(); @@ -85,10 +97,12 @@ export class ProxyWorker implements DurableObject { const event = request.cf?.hostMetadata; switch (event?.type) { case "pause": + this.controlPayloadMissing = false; this.proxyData = undefined; break; case "play": + this.controlPayloadMissing = false; this.proxyData = event.proxyData; this.processQueue(); this.state @@ -99,7 +113,10 @@ export class ProxyWorker implements DurableObject { default: // Bun currently drops `cf.hostMetadata`, so acknowledge only control - // requests whose payload actually reached the ProxyWorker. + // requests whose payload actually reached the ProxyWorker, and fail + // requests instead of queueing them for a `play` that cannot arrive. + this.controlPayloadMissing = true; + this.failQueuedRequests(); return new Response(null, { status: 400 }); } @@ -116,6 +133,18 @@ export class ProxyWorker implements DurableObject { yield* this.requestQueue; } + /** + * Answers every queued request with the same 503 that new requests get once + * `controlPayloadMissing` is set. + */ + failQueuedRequests() { + for (const [request, deferredResponse] of this.getOrderedQueue()) { + this.requestRetryQueue.delete(request); + this.requestQueue.delete(request); + deferredResponse.resolve(controlPayloadMissingResponse()); + } + } + processQueue() { const { proxyData } = this; // store proxyData at the moment this function was called if (proxyData === undefined) return; @@ -330,6 +359,19 @@ export class ProxyWorker implements DurableObject { } } +/** + * Builds the response for requests that the ProxyWorker cannot forward because + * its control requests arrive without their payload. + * + * @returns A 503 response naming the cause and the ways around it. + */ +function controlPayloadMissingResponse(): Response { + return new Response( + "The Wrangler ProxyWorker received a control request without its payload, so it cannot forward requests to your Worker. This happens when the JavaScript runtime's `fetch()` ignores undici's `dispatcher` option, which Bun does today (https://github.com/oven-sh/bun/issues/39247). Run under Node.js, or, with `createTestHarness()`, use `server.getWorker().fetch()`, which does not go through this proxy.", + { status: 503 } + ); +} + function isRequestFromProxyController(req: Request, env: Env): boolean { return req.headers.get("Authorization") === env.PROXY_CONTROLLER_AUTH_SECRET; } From 29d0fa7bd9d70f6e64caf8af4489944c190b18a6 Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:19:04 -0400 Subject: [PATCH 03/12] [wrangler] Fail late retries under a missing control payload, and cover the harness under simulated Bun A request already forwarded to the UserWorker sits in neither queue when an empty control request drains them, and its retry is requeued asynchronously once the old connection fails. Route both requeue sites through requeueForRetry(), which answers with the same 503 while controlPayloadMissing is set instead of waiting for a play that cannot come. Add a createTestHarness() test that simulates Bun under Node (isBun() true, proxy control requests stripped of cf) and asserts listen() and getWorker().fetch() work while server.fetch() throws and the listen() URL answers 503. --- .../startDevWorker/ProxyController.test.ts | 73 ++++++++++++- .../src/__tests__/api/test-harness.test.ts | 100 +++++++++++++++++- .../templates/startDevWorker/ProxyWorker.ts | 24 ++++- 3 files changed, 190 insertions(+), 7 deletions(-) diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts index 179bb7602dd..4988e8438e4 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts @@ -1,10 +1,12 @@ -import { describe, test } from "vitest"; +import { createServer } from "node:net"; +import { describe, test, vi } from "vitest"; import { serialiseError } from "../../../api/startDevWorker/events"; import { ProxyController } from "../../../api/startDevWorker/ProxyController"; import { FakeBus } from "../../helpers/fake-bus"; import { mockConsoleMethods } from "../../helpers/mock-console"; import type { SerializedError } from "../../../api/startDevWorker/events"; import type { StartDevWorkerOptions } from "../../../api/startDevWorker/types"; +import type { AddressInfo, Socket } from "node:net"; describe("ProxyController", () => { mockConsoleMethods(); @@ -110,4 +112,73 @@ describe("ProxyController", () => { ); } }); + + test("ProxyWorker answers with a 503 when a request in flight is retried after a control request arrives without its payload", async ({ + expect, + onTestFinished, + }) => { + // A request that is already forwarded is in neither queue when the empty + // control request drains them, and its retry is requeued asynchronously + // once the connection to the old UserWorker fails. With no `play` to + // come, that retry must get the same 503 rather than wait forever. + const sockets: Socket[] = []; + const userWorker = createServer((socket) => sockets.push(socket)); + await new Promise((resolve) => + userWorker.listen(0, "127.0.0.1", resolve) + ); + onTestFinished( + () => new Promise((resolve) => userWorker.close(() => resolve())) + ); + const { port } = userWorker.address() as AddressInfo; + + const bus = new FakeBus(); + const controller = new ProxyController(bus); + onTestFinished(() => controller.teardown()); + + controller.onConfigUpdate({ + type: "configUpdate", + config: { + dev: { inspector: false, server: { hostname: "127.0.0.1", port: 0 } }, + } as StartDevWorkerOptions, + }); + const { proxyWorker } = await controller.ready.promise; + + await expect( + controller.sendMessageToProxyWorker({ + type: "play", + proxyData: { + userWorkerUrl: { + protocol: "http:", + hostname: "127.0.0.1", + port: String(port), + }, + headers: {}, + }, + }) + ).resolves.toBe(true); + + const inFlight = proxyWorker.dispatchFetch("http://example.com/in-flight"); + await vi.waitFor(() => expect(sockets.length).toBeGreaterThan(0)); + + // A reload pauses the proxy, so the failed request is requeued rather + // than reported, and then the next control request arrives empty. + await expect( + controller.sendMessageToProxyWorker({ type: "pause" }) + ).resolves.toBe(true); + const control = await proxyWorker.dispatchFetch( + "http://dummy/cdn-cgi/ProxyWorker/play", + { headers: { Authorization: controller.secret } } + ); + expect(control.status).toBe(400); + + for (const socket of sockets) { + socket.destroy(); + } + + const response = await inFlight; + expect(response.status).toBe(503); + expect(await response.text()).toContain( + "https://github.com/oven-sh/bun/issues/39247" + ); + }); }); diff --git a/packages/wrangler/src/__tests__/api/test-harness.test.ts b/packages/wrangler/src/__tests__/api/test-harness.test.ts index 541eddb9a57..de62634e9e8 100644 --- a/packages/wrangler/src/__tests__/api/test-harness.test.ts +++ b/packages/wrangler/src/__tests__/api/test-harness.test.ts @@ -1,5 +1,99 @@ +import { writeFileSync } from "node:fs"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { fetch } from "undici"; import { describe, it, vi } from "vitest"; +import { ProxyController } from "../../api/startDevWorker/ProxyController"; +import { createTestHarness } from "../../api/test-harness"; import { waitForBunProxyMessages } from "../../api/test-harness-runtime"; +import { mockConsoleMethods } from "../helpers/mock-console"; +import type { Miniflare } from "miniflare"; + +const BUN_PROXY_ERROR = + "`server.fetch()` cannot reach the Worker because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Use `server.getWorker().fetch()`, which dispatches to the Worker directly, or run your tests using Node.js."; + +const { isBunMock } = vi.hoisted(() => ({ isBunMock: vi.fn(() => false) })); +vi.mock("../../utils/is-bun", () => ({ isBun: isBunMock })); + +describe("createTestHarness under Bun", () => { + mockConsoleMethods(); + runInTempDir(); + + it("keeps listen() and getWorker() working and fails both proxy doors loudly", async ({ + expect, + onTestFinished, + }) => { + // Simulates Bun under Node with the two things Bun changes: `isBun()` + // reports true, and Miniflare's control requests to the ProxyWorker lose + // the `cf` payload, because Bun's `fetch()` ignores the undici dispatcher + // that attaches it (https://github.com/cloudflare/workers-sdk/issues/15717). + isBunMock.mockReturnValue(true); + onTestFinished(() => { + isBunMock.mockReturnValue(false); + }); + // `dispatchFetch` is an instance field, so wrap it on the ProxyWorker's + // Miniflare right after the ProxyController creates it. + const proxyControllerPrototype = ProxyController.prototype as unknown as { + createProxyWorker(this: ProxyController): void; + }; + const createProxyWorker = proxyControllerPrototype.createProxyWorker; + const createProxyWorkerSpy = vi + .spyOn(proxyControllerPrototype, "createProxyWorker") + .mockImplementation(function (this: ProxyController) { + createProxyWorker.call(this); + const { proxyWorker } = this; + if (proxyWorker === undefined || droppedCf.has(proxyWorker)) { + return; + } + droppedCf.add(proxyWorker); + const dispatchFetch = proxyWorker.dispatchFetch; + proxyWorker.dispatchFetch = (input, init) => { + if (String(input).includes("/cdn-cgi/ProxyWorker/") && init) { + const withoutCf = { ...init }; + delete withoutCf.cf; + return dispatchFetch(input, withoutCf); + } + return dispatchFetch(input, init); + }; + }); + onTestFinished(() => createProxyWorkerSpy.mockRestore()); + const droppedCf = new WeakSet(); + + writeFileSync( + "worker.js", + `export default { fetch: () => new Response("ok") };` + ); + writeFileSync( + "wrangler.jsonc", + JSON.stringify({ + name: "harness-under-bun", + main: "worker.js", + compatibility_date: "2026-05-20", + }) + ); + + const server = createTestHarness({ + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(() => server.close()); + + const { url } = await server.listen(); + + const direct = await server.getWorker().fetch("/"); + expect(direct.status).toBe(200); + expect(await direct.text()).toBe("ok"); + + await expect(server.fetch("/")).rejects.toMatchObject({ + message: BUN_PROXY_ERROR, + telemetryMessage: "test harness bun proxy request failed", + }); + + const viaUrl = await fetch(url); + expect(viaUrl.status).toBe(503); + expect(await viaUrl.text()).toContain( + "https://github.com/oven-sh/bun/issues/39247" + ); + }); +}); describe("waitForBunProxyMessages", () => { it("allows Bun when proxy messages are delivered", async ({ expect }) => { @@ -14,8 +108,7 @@ describe("waitForBunProxyMessages", () => { await expect( waitForBunProxyMessages(() => Promise.resolve(false), 1) ).rejects.toMatchObject({ - message: - "`server.fetch()` cannot reach the Worker because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Use `server.getWorker().fetch()`, which dispatches to the Worker directly, or run your tests using Node.js.", + message: BUN_PROXY_ERROR, telemetryMessage: "test harness bun proxy request failed", }); }); @@ -29,8 +122,7 @@ describe("waitForBunProxyMessages", () => { const rejection = expect( waitForBunProxyMessages(() => new Promise(() => {}), 100) ).rejects.toMatchObject({ - message: - "`server.fetch()` cannot reach the Worker because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Use `server.getWorker().fetch()`, which dispatches to the Worker directly, or run your tests using Node.js.", + message: BUN_PROXY_ERROR, telemetryMessage: "test harness bun proxy request failed", }); diff --git a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts index 9468fdb13f8..c78afee8e4b 100644 --- a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts +++ b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts @@ -145,6 +145,26 @@ export class ProxyWorker implements DurableObject { } } + /** + * Puts a request back on the retry queue, unless a control request has + * already arrived without its payload. Retries settle asynchronously, so one + * can land after `failQueuedRequests()` has drained the queues, and no `play` + * would ever release it. + * + * @param request The request to retry. + * @param deferredResponse The response promise its caller is waiting on. + */ + requeueForRetry( + request: Request, + deferredResponse: DeferredPromise + ) { + if (this.controlPayloadMissing) { + deferredResponse.resolve(controlPayloadMissingResponse()); + return; + } + this.requestRetryQueue.set(request, deferredResponse); + } + processQueue() { const { proxyData } = this; // store proxyData at the moment this function was called if (proxyData === undefined) return; @@ -276,7 +296,7 @@ export class ProxyWorker implements DurableObject { // the pre-reload Worker or send a stale preview token. // Requeue instead and let the current proxyData rebuild it. if (this.proxyData !== proxyData) { - this.requestRetryQueue.set(request, deferredResponse); + this.requeueForRetry(request, deferredResponse); this.processQueue(); return; } @@ -327,7 +347,7 @@ export class ProxyWorker implements DurableObject { // if the request can be retried (subset of idempotent requests which have no body), requeue it else if (request.method === "GET" || request.method === "HEAD") { - this.requestRetryQueue.set(request, deferredResponse); + this.requeueForRetry(request, deferredResponse); // we would only end up here if the downstream UserWorker is chang*ing* // i.e. we are in a `pause`d state and expecting a `play` message soon // this request will be processed (retried) when the `play` message arrives From 471282581c77991c6e1f52f5c90ff6bf9dc5692d Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:43:31 -0400 Subject: [PATCH 04/12] [wrangler] Refuse every harness dispatch under Bun, since #15906 routes getWorker() through the dispatcher too --- .changeset/fuzzy-bears-listen.md | 4 +-- .../src/__tests__/api/test-harness.test.ts | 25 +++++++++++++----- .../wrangler/src/api/test-harness-runtime.ts | 10 ++++--- packages/wrangler/src/api/test-harness.ts | 26 +++++++++++-------- .../templates/startDevWorker/ProxyWorker.ts | 2 +- 5 files changed, 42 insertions(+), 25 deletions(-) diff --git a/.changeset/fuzzy-bears-listen.md b/.changeset/fuzzy-bears-listen.md index be878172579..e55931ac9ac 100644 --- a/.changeset/fuzzy-bears-listen.md +++ b/.changeset/fuzzy-bears-listen.md @@ -2,6 +2,6 @@ "wrangler": patch --- -Fail fast when `createTestHarness()` requests cannot reach the proxy under Bun +Fail fast when `createTestHarness()` cannot dispatch requests under Bun -Bun's `fetch()` ignores the undici `dispatcher` that Miniflare uses to deliver the ProxyWorker's control requests, so under Bun `server.fetch()` and requests to the URL returned by `listen()` used to hang forever with no error. `server.fetch()` now throws an actionable `UserError`, and the ProxyWorker answers requests to the `listen()` URL with a 503 that names the cause. `listen()` and `server.getWorker().fetch()` keep working under Bun, since `getWorker()` dispatches to the Worker directly without going through the proxy. +Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes requests through. Under Bun, `server.fetch()` and requests to the URL returned by `listen()` used to hang forever with no error, and `server.getWorker().fetch()` could send a request to the network instead of the Worker. `listen()` now resolves, `server.fetch()` and `server.getWorker()`'s `fetch()`, `email()` and `scheduled()` throw an actionable `UserError` before sending anything, and the ProxyWorker answers requests to the `listen()` URL with a 503 that names the cause. `server.getWorker().getEnv()` and `getExport()` keep working under Bun. diff --git a/packages/wrangler/src/__tests__/api/test-harness.test.ts b/packages/wrangler/src/__tests__/api/test-harness.test.ts index de62634e9e8..720a16f218a 100644 --- a/packages/wrangler/src/__tests__/api/test-harness.test.ts +++ b/packages/wrangler/src/__tests__/api/test-harness.test.ts @@ -9,7 +9,7 @@ import { mockConsoleMethods } from "../helpers/mock-console"; import type { Miniflare } from "miniflare"; const BUN_PROXY_ERROR = - "`server.fetch()` cannot reach the Worker because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Use `server.getWorker().fetch()`, which dispatches to the Worker directly, or run your tests using Node.js."; + "`createTestHarness()` cannot dispatch requests to your Worker under Bun, because Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes them through (https://github.com/oven-sh/bun/issues/39247). Without it, a request can reach the network instead of your Worker. `server.getWorker().getEnv()` and `getExport()` still work under Bun; run `fetch()`, `email()` and `scheduled()` dispatches using Node.js."; const { isBunMock } = vi.hoisted(() => ({ isBunMock: vi.fn(() => false) })); vi.mock("../../utils/is-bun", () => ({ isBun: isBunMock })); @@ -18,7 +18,7 @@ describe("createTestHarness under Bun", () => { mockConsoleMethods(); runInTempDir(); - it("keeps listen() and getWorker() working and fails both proxy doors loudly", async ({ + it("keeps listen() and getEnv() working and fails every dispatch door loudly", async ({ expect, onTestFinished, }) => { @@ -68,6 +68,7 @@ describe("createTestHarness under Bun", () => { name: "harness-under-bun", main: "worker.js", compatibility_date: "2026-05-20", + vars: { PROBE: "yes" }, }) ); @@ -78,14 +79,24 @@ describe("createTestHarness under Bun", () => { const { url } = await server.listen(); - const direct = await server.getWorker().fetch("/"); - expect(direct.status).toBe(200); - expect(await direct.text()).toBe("ok"); + // `getEnv()` reads bindings without Miniflare's `dispatchFetch()`. + const env = await server.getWorker<{ PROBE: string }>().getEnv(); + expect(env.PROBE).toBe("yes"); - await expect(server.fetch("/")).rejects.toMatchObject({ + // Every door below goes through `dispatchFetch()`, whose undici + // dispatcher Bun ignores, so each must refuse before sending anything. + const refused = { message: BUN_PROXY_ERROR, telemetryMessage: "test harness bun proxy request failed", - }); + }; + await expect(server.fetch("/")).rejects.toMatchObject(refused); + await expect(server.getWorker().fetch("/")).rejects.toMatchObject(refused); + await expect( + server.getWorker().fetch("http://example.com/") + ).rejects.toMatchObject(refused); + await expect( + server.getWorker().scheduled({ cron: "* * * * *" }) + ).rejects.toMatchObject(refused); const viaUrl = await fetch(url); expect(viaUrl.status).toBe(503); diff --git a/packages/wrangler/src/api/test-harness-runtime.ts b/packages/wrangler/src/api/test-harness-runtime.ts index b9897eb4dec..303cb20a449 100644 --- a/packages/wrangler/src/api/test-harness-runtime.ts +++ b/packages/wrangler/src/api/test-harness-runtime.ts @@ -7,16 +7,18 @@ const BUN_PROXY_MESSAGE_TIMEOUT_MS = 5_000; function createBunProxyMessageError(): UserError { return new UserError( - "`server.fetch()` cannot reach the Worker because Bun does not support the custom `fetch()` dispatcher required to deliver Miniflare's proxy control request. Use `server.getWorker().fetch()`, which dispatches to the Worker directly, or run your tests using Node.js.", + "`createTestHarness()` cannot dispatch requests to your Worker under Bun, because Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes them through (https://github.com/oven-sh/bun/issues/39247). Without it, a request can reach the network instead of your Worker. `server.getWorker().getEnv()` and `getExport()` still work under Bun; run `fetch()`, `email()` and `scheduled()` dispatches using Node.js.", { telemetryMessage: "test harness bun proxy request failed" } ); } /** * Waits for Bun to deliver the control message that unlocks the test harness - * proxy, before `server.fetch()` sends a request through it. Current Bun releases are affected by an upstream dispatcher issue and - * do not deliver this message. A possible future compatible release can resolve - * normally; until then, the timeout prevents requests from hanging indefinitely. + * proxy, before the harness dispatches a request through Miniflare. The message + * travels through the same undici `dispatcher` as every dispatch, so its + * delivery doubles as the capability check. Current Bun releases ignore that + * option and do not deliver it. A future compatible release resolves normally; + * until then, this throws before any request leaves the process. * * @param waitForProxyMessages Callback that reports whether the latest proxy * control message was delivered. diff --git a/packages/wrangler/src/api/test-harness.ts b/packages/wrangler/src/api/test-harness.ts index 31956e1ea7b..49d4cb249ac 100644 --- a/packages/wrangler/src/api/test-harness.ts +++ b/packages/wrangler/src/api/test-harness.ts @@ -319,10 +319,11 @@ export type TestHarness = { * // Dispatches a request to the API Worker with URL "http://example.com/api/data" * ``` * - * Under Bun, this method and the URL returned by `listen()` cannot reach the - * Worker, because Bun's `fetch()` ignores the undici `dispatcher` that the - * proxy relies on. This method throws a `UserError` and the URL answers with a - * 503. `server.getWorker().fetch()` dispatches directly and works under Bun. + * Under Bun, this method, `server.getWorker()`'s `fetch()`, `email()` and + * `scheduled()`, and the URL returned by `listen()` cannot reach the Worker, + * because Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes + * requests through. The methods throw a `UserError` and the URL answers with a + * 503. `server.getWorker().getEnv()` and `getExport()` keep working under Bun. */ fetch: DispatchFetch; /** @@ -976,6 +977,16 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { worker?: string, event = "fetch" ) { + // Miniflare's `dispatchFetch()` routes every request, `getWorker()` ones + // included, through an undici dispatcher that Bun's `fetch()` ignores, so + // a request can leave for the network. Check before sending anything. + if (isBun()) { + const session = await resolveSession(); + await waitForBunProxyMessages( + () => session.primaryDevEnv.proxy.latestReloadCompleteMessage + ); + } + let resolvedInput = input; if (typeof input === "string" && !URL.canParse(input)) { @@ -1025,13 +1036,6 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { }, async fetch(input, init) { const session = await resolveSession(); - // Only this door and the `listen()` URL go through the ProxyWorker. - // `getWorker()` dispatches directly, so it keeps working under Bun. - if (isBun()) { - await waitForBunProxyMessages( - () => session.primaryDevEnv.proxy.latestReloadCompleteMessage - ); - } const miniflare = session.primaryDevEnv.proxy.proxyWorker; assert( miniflare, diff --git a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts index c78afee8e4b..38228c84f6d 100644 --- a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts +++ b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts @@ -387,7 +387,7 @@ export class ProxyWorker implements DurableObject { */ function controlPayloadMissingResponse(): Response { return new Response( - "The Wrangler ProxyWorker received a control request without its payload, so it cannot forward requests to your Worker. This happens when the JavaScript runtime's `fetch()` ignores undici's `dispatcher` option, which Bun does today (https://github.com/oven-sh/bun/issues/39247). Run under Node.js, or, with `createTestHarness()`, use `server.getWorker().fetch()`, which does not go through this proxy.", + "The Wrangler ProxyWorker received a control request without its payload, so it cannot forward requests to your Worker. This happens when the JavaScript runtime's `fetch()` ignores undici's `dispatcher` option, which Bun does today (https://github.com/oven-sh/bun/issues/39247). Run under Node.js.", { status: 503 } ); } From e7ff0cac9fe1cf5c0074ab3b00a961d08f4264f0 Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:39:03 -0400 Subject: [PATCH 05/12] Retrigger CI after Windows timeouts in @fixture/entrypoints-rpc and workflows-shared Both are HeadersTimeoutError / waitUntil timeouts on the Windows runners. The same fixture ran 17/17 on Windows at this branch's previous head, and an unrelated PR failed it with the same HeadersTimeoutError. From ab5abe1551b0d859a7ca00ada835693961becf18 Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:22:17 -0700 Subject: [PATCH 06/12] [remote-bindings] Fail remote proxy startup under Bun instead of hanging every binding request startRemoteProxySession reported ready once the remote Worker reloaded, but the `play` message that hands the ProxyWorker that session was sent in the background. Under Bun it never arrives: Miniflare's dispatchFetch() routes it through an undici dispatcher that Bun's fetch() ignores, so it goes to the literal http://dummy/ URL and fails with ENOTFOUND. The error was emitted after startup stopped listening, and the ProxyWorker queued every remote binding request forever, as reported for getPlatformProxy({ remoteBindings: true }) in #15717. ProxyController now records whether `play` was delivered, and startup waits for it. Under Bun a failed delivery throws a UserError naming oven-sh/bun#39247; elsewhere it goes through the existing startup error path. --- .changeset/remote-bindings-bun-play.md | 7 + .../src/start-remote-proxy-session.test.ts | 122 ++++++++++++++++-- .../src/start-remote-proxy-session.ts | 93 ++++++++++--- .../startDevWorker/ProxyController.test.ts | 66 ++++++++++ .../src/startDevWorker/ProxyController.ts | 29 ++++- packages/remote-bindings/src/utils/is-bun.ts | 8 ++ 6 files changed, 288 insertions(+), 37 deletions(-) create mode 100644 .changeset/remote-bindings-bun-play.md create mode 100644 packages/remote-bindings/src/startDevWorker/ProxyController.test.ts create mode 100644 packages/remote-bindings/src/utils/is-bun.ts diff --git a/.changeset/remote-bindings-bun-play.md b/.changeset/remote-bindings-bun-play.md new file mode 100644 index 00000000000..043ffb83f0d --- /dev/null +++ b/.changeset/remote-bindings-bun-play.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Fail fast when remote bindings cannot work under Bun + +Under Bun, `getPlatformProxy()` with remote bindings, and other remote dev sessions, started normally, but every request to a remote binding then waited forever with no error. The local proxy learns where to forward those requests from a control message that Miniflare sends through an undici `dispatcher`, which Bun's `fetch()` ignores. Starting a remote proxy session now waits for that message to be delivered and, under Bun, throws an actionable `UserError` naming the cause instead of returning a session that hangs. diff --git a/packages/remote-bindings/src/start-remote-proxy-session.test.ts b/packages/remote-bindings/src/start-remote-proxy-session.test.ts index bf954d21727..b44039b75af 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.test.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.test.ts @@ -1,32 +1,62 @@ import { APIError, UserError } from "@cloudflare/workers-utils"; -import { describe, it, vi } from "vitest"; +import { beforeEach, describe, it, vi } from "vitest"; import { startRemoteProxySession } from "./start-remote-proxy-session"; import { RemoteSessionAuthenticationError } from "./utils/remote"; import type { RemoteBindingsLogger } from "./logger"; import type { ErrorEvent } from "./startDevWorker/events"; -// The error that the mocked `DevEnv` emits asynchronously from `start()`. -const mockDevEnv = vi.hoisted(() => ({ error: undefined as unknown })); +// How the mocked `DevEnv` behaves. With `ready` false (the default), it never +// becomes ready and emits `error` just after `start()`. With `ready` true, the +// local server and proxy come up, and the `play` message is delivered unless +// `playError` is set, in which case it fails the way ProxyController reports a +// failed send: an error event, then `latestReloadCompleteMessage` resolving to false. +const mockDevEnv = vi.hoisted(() => ({ + error: undefined as unknown, + ready: false, + playError: undefined as unknown, +})); -// Replace `DevEnv` with a stub that never becomes ready and instead emits a -// configurable error event just after `start()`, so we can exercise the async -// error-handling path in `startRemoteProxySession` without any real Miniflare. +const { isBunMock } = vi.hoisted(() => ({ isBunMock: vi.fn(() => false) })); +vi.mock("./utils/is-bun", () => ({ isBun: isBunMock })); + +const PROXY_URL = "http://127.0.0.1:8787/"; + +// Replace `DevEnv` with a stub, so we can exercise `startRemoteProxySession`'s +// startup paths without any real Miniflare. vi.mock("./startDevWorker/DevEnv", async () => { const { EventEmitter } = await import("node:events"); class DevEnv extends EventEmitter { - proxy = { - // Never resolves — the startup race is always settled by the error event. - localServerReady: { promise: new Promise(() => {}) }, - ready: { promise: new Promise(() => {}) }, - runtimeMessageMutex: { drained: async () => {} }, - }; + proxy = mockDevEnv.ready + ? { + localServerReady: { promise: Promise.resolve() }, + ready: { promise: Promise.resolve({ url: new URL(PROXY_URL) }) }, + latestReloadCompleteMessage: + mockDevEnv.playError === undefined + ? Promise.resolve(true) + : new Promise((resolve) => + queueMicrotask(() => { + this.emit("error", mockDevEnv.playError); + resolve(false); + }) + ), + runtimeMessageMutex: { drained: async () => {} }, + } + : { + // Never resolves — the startup race is always settled by the error event. + localServerReady: { promise: new Promise(() => {}) }, + ready: { promise: new Promise(() => {}) }, + latestReloadCompleteMessage: new Promise(() => {}), + runtimeMessageMutex: { drained: async () => {} }, + }; constructor() { super(); // Avoid Node's throw-on-unhandled-"error" behaviour. this.on("error", () => {}); } start() { - queueMicrotask(() => this.emit("error", mockDevEnv.error)); + if (!mockDevEnv.ready) { + queueMicrotask(() => this.emit("error", mockDevEnv.error)); + } } update() {} async teardown() {} @@ -34,6 +64,13 @@ vi.mock("./startDevWorker/DevEnv", async () => { return { DevEnv }; }); +beforeEach(() => { + mockDevEnv.error = undefined; + mockDevEnv.ready = false; + mockDevEnv.playError = undefined; + isBunMock.mockReturnValue(false); +}); + function createTestLogger(): RemoteBindingsLogger { return { loggerLevel: "none", @@ -140,3 +177,62 @@ describe("startRemoteProxySession error handling", () => { ); }); }); + +describe("startRemoteProxySession waits for the play message", () => { + // The ProxyWorker queues every binding request until `play` hands it the + // remote session. Under Bun that message never arrives, because Bun's + // `fetch()` ignores the undici `dispatcher` Miniflare routes it through + // (https://github.com/oven-sh/bun/issues/39247), so a session reported as + // ready would leave every remote binding call waiting forever. + const sendFailure = () => + makeErrorEvent(new Error("getaddrinfo ENOTFOUND dummy")); + + it("resolves once play is delivered", async ({ expect }) => { + mockDevEnv.ready = true; + + const session = await startRemoteProxySession( + {}, + { cliDisplayName: "Test CLI", logger: createTestLogger() } + ); + + expect(String(session.remoteProxyConnectionString)).toBe(PROXY_URL); + }); + + it("rejects instead of hanging when play is never delivered", async ({ + expect, + }) => { + mockDevEnv.ready = true; + mockDevEnv.playError = sendFailure(); + + const thrown = await startRemoteProxySession( + {}, + { cliDisplayName: "Test CLI", logger: createTestLogger() } + ).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(Error); + expect(thrown).not.toBeInstanceOf(UserError); + expect((thrown as Error).message).toContain( + "Failed to start the remote proxy session" + ); + expect((thrown as Error).message).toContain("ENOTFOUND dummy"); + }); + + it("names the Bun limitation when play is never delivered under Bun", async ({ + expect, + }) => { + isBunMock.mockReturnValue(true); + mockDevEnv.ready = true; + mockDevEnv.playError = sendFailure(); + + const thrown = await startRemoteProxySession( + {}, + { cliDisplayName: "Test CLI", logger: createTestLogger() } + ).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(UserError); + expect((thrown as Error).message).toContain( + "https://github.com/oven-sh/bun/issues/39247" + ); + expect((thrown as Error).cause).toBe(mockDevEnv.playError); + }); +}); diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index e9001a7887c..3a50b44a1a0 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -7,6 +7,7 @@ import remoteBindingsWorkerSource from "worker:remoteBindings/ProxyServerWorker" import { getRemoteBindingsAuthHook } from "./auth"; import { initLogger } from "./logger"; import { DevEnv } from "./startDevWorker/DevEnv"; +import { isBun } from "./utils/is-bun"; import { RemoteSessionAuthenticationError } from "./utils/remote"; import type { RemoteBindingsLogger } from "./logger"; import type { @@ -76,6 +77,53 @@ function formatRemoteProxySessionError(error: unknown): string | undefined { * verbatim from the chain — generic API errors (which also extend * {@link UserError} via {@link APIError}) fall through to the wrapping logic. */ +/** + * Turns an error raised while the remote proxy session starts into the error + * to throw. A UserError emitted directly (e.g. the Miniflare user-error case + * that DevEnv re-emits), and a RemoteSessionAuthenticationError found in the + * error event's cause chain, are surfaced verbatim so callers can still branch + * on `instanceof UserError` and the user sees a single actionable message. + * Everything else — including generic API errors — is wrapped. + * + * @param error The error the session emitted during startup. + * @returns The error to throw. + */ +function toStartupError(error: unknown): Error { + if (error instanceof UserError) { + return error; + } + const authError = findRemoteSessionAuthError(error); + if (authError) { + return authError; + } + const details = formatRemoteProxySessionError(error); + return new Error( + details + ? `Failed to start the remote proxy session. ${details}` + : "Failed to start the remote proxy session. There is likely additional logging output above.", + { cause: error } + ); +} + +/** + * The error for a remote proxy session that cannot work under Bun. Bun's + * `fetch()` ignores the undici `dispatcher` that Miniflare routes the session's + * control messages through, so the local proxy never learns where to forward + * binding requests. + * + * @param cause The failure that showed it, if one was reported. + * @returns A UserError explaining the limitation and the way around it. + */ +function createBunRemoteBindingsError(cause: unknown): UserError { + return new UserError( + "Remote bindings cannot work under Bun yet. Bun's `fetch()` ignores the undici `dispatcher` that Miniflare uses to reach the remote proxy session (https://github.com/oven-sh/bun/issues/39247), so every request to a remote binding would wait forever. Run this under Node.js, or turn remote bindings off.", + { + cause, + telemetryMessage: "remote proxy session bun control message failed", + } + ); +} + function findRemoteSessionAuthError( error: unknown ): RemoteSessionAuthenticationError | undefined { @@ -146,7 +194,12 @@ export async function startRemoteProxySession( } const maybeErrorPromise = new DeferredPromise<{ error: unknown }>(); + // Also kept synchronously: a failed `play` emits its error just before + // `latestReloadCompleteMessage` resolves to false, and the race below can + // settle on either. + let startupError: unknown; const onStartupError = (error: unknown) => { + startupError ??= error; maybeErrorPromise.resolve({ error }); }; devEnv.addListener("error", onStartupError); @@ -158,26 +211,28 @@ export async function startRemoteProxySession( ]); if (maybeError && maybeError.error) { - // A UserError emitted directly (e.g. the Miniflare user-error case that - // DevEnv re-emits), and a RemoteSessionAuthenticationError found in the - // error event's cause chain, are surfaced verbatim so callers can still - // branch on `instanceof UserError` and the user sees a single actionable - // message. Everything else — including generic API errors — falls through - // to the wrapping below. - if (maybeError.error instanceof UserError) { - throw maybeError.error; - } - const authError = findRemoteSessionAuthError(maybeError.error); - if (authError) { - throw authError; + throw toStartupError(maybeError.error); + } + + // The local server is ready once the remote Worker has reloaded, but the + // `play` message that hands the ProxyWorker that remote session is sent in + // the background. Wait for it: until it lands, every binding request waits + // in the ProxyWorker's queue, so a session whose `play` never arrives would + // hang its callers with no error. + const delivery = await Promise.race([ + maybeErrorPromise, + devEnv.proxy.latestReloadCompleteMessage, + ]); + if (delivery !== true) { + const cause = delivery ? delivery.error : startupError; + if (isBun()) { + throw createBunRemoteBindingsError(cause); } - const details = formatRemoteProxySessionError(maybeError.error); - throw new Error( - details - ? `Failed to start the remote proxy session. ${details}` - : "Failed to start the remote proxy session. There is likely additional logging output above.", - { cause: maybeError.error } - ); + throw cause !== undefined + ? toStartupError(cause) + : new Error( + "Failed to start the remote proxy session. Its local proxy never received the session. There is likely additional logging output above." + ); } remoteProxyConnectionString = (await devEnv.proxy.ready.promise) diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.test.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.test.ts new file mode 100644 index 00000000000..ff7143df91f --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.test.ts @@ -0,0 +1,66 @@ +import { Response } from "miniflare"; +import { describe, it, vi } from "vitest"; +import { ProxyController } from "./ProxyController"; +import type { ErrorEvent, ReadyEvent, ReloadCompleteEvent } from "./events"; + +// Only the Miniflare instance ProxyController talks to is faked, so its own +// send, retry and error reporting run for real. +function controllerWith(dispatchFetch: () => Promise) { + const onError = vi.fn<(event: ErrorEvent) => void>(); + const controller = new ProxyController(onError, () => {}); + const proxyWorker = { + ready: Promise.resolve(new URL("http://127.0.0.1:8787/")), + dispatchFetch: vi.fn(dispatchFetch), + }; + controller.ready.resolve({ + type: "ready", + proxyWorker, + url: new URL("http://127.0.0.1:8787/"), + } as unknown as ReadyEvent); + return { controller, onError, proxyWorker }; +} + +const reloadComplete = { + type: "reloadComplete", + config: {}, + bundle: {}, + proxyData: {}, +} as unknown as ReloadCompleteEvent; + +describe("ProxyController.latestReloadCompleteMessage", () => { + it("resolves true once the ProxyWorker receives play", async ({ expect }) => { + const { controller, onError, proxyWorker } = controllerWith(async () => { + return new Response(null, { status: 204 }); + }); + + controller.play(reloadComplete); + + expect(await controller.latestReloadCompleteMessage).toBe(true); + expect(proxyWorker.dispatchFetch).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + // Under Bun, Miniflare's `dispatchFetch()` sends the request to the literal + // `http://dummy/...` URL, because Bun's `fetch()` ignores the undici + // `dispatcher` that rewrites it, and the lookup fails. + it("resolves false and reports the error once every retry fails", async ({ + expect, + }) => { + const { controller, onError, proxyWorker } = controllerWith(async () => { + throw new Error("getaddrinfo ENOTFOUND dummy"); + }); + + controller.play(reloadComplete); + + expect(await controller.latestReloadCompleteMessage).toBe(false); + expect(proxyWorker.dispatchFetch).toHaveBeenCalledTimes(4); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + reason: expect.stringContaining( + "Failed to send message to ProxyWorker" + ), + }) + ); + }); +}); diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index fb2de74ca99..6120367aa32 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -30,6 +30,15 @@ export class ProxyController { public localServerReady = createDeferred(); + /** + * Whether the latest `play` message reached the ProxyWorker. `play` carries + * the remote session that binding requests are forwarded to, and until it + * arrives the ProxyWorker queues every request, so a `play` that is never + * delivered leaves callers waiting forever. Mirrors Wrangler's + * ProxyController. + */ + latestReloadCompleteMessage = Promise.resolve(true); + public proxyWorker?: Miniflare; protected latestConfig?: StartDevWorkerOptions; @@ -131,18 +140,25 @@ export class ProxyController { } runtimeMessageMutex = new Mutex(); + /** + * Sends a control message to the ProxyWorker, retrying on failure. + * + * @param message The control message to send. + * @param retries How many more attempts to make after a failed one. + * @returns Whether the ProxyWorker received the message. + */ async sendMessageToProxyWorker( message: ProxyWorkerIncomingRequestBody, retries = 3 - ): Promise { + ): Promise { if (this._torndown) { - return; + return false; } // Don't do any async work here. Enqueue the message with the mutex immediately. try { - await this.runtimeMessageMutex.runWith(async () => { + const response = await this.runtimeMessageMutex.runWith(async () => { const { proxyWorker } = await this.ready.promise; const ready = await proxyWorker.ready.catch(() => undefined); @@ -158,9 +174,11 @@ export class ProxyController { } ); }); + + return response?.status === 204; } catch (cause) { if (this._torndown) { - return; + return false; } const error = castErrorCause(cause); @@ -173,6 +191,7 @@ export class ProxyController { `Failed to send message to ProxyWorker: ${JSON.stringify(message)}`, error ); + return false; } } start(config: StartDevWorkerOptions) { @@ -189,7 +208,7 @@ export class ProxyController { this.latestConfig = data.config; this.latestBundle = data.bundle; - void this.sendMessageToProxyWorker({ + this.latestReloadCompleteMessage = this.sendMessageToProxyWorker({ type: "play", proxyData: data.proxyData, }); diff --git a/packages/remote-bindings/src/utils/is-bun.ts b/packages/remote-bindings/src/utils/is-bun.ts new file mode 100644 index 00000000000..793e309ffdc --- /dev/null +++ b/packages/remote-bindings/src/utils/is-bun.ts @@ -0,0 +1,8 @@ +/** + * Returns whether this process is running in the Bun runtime. + * + * @returns Whether this process is running in the Bun runtime. + */ +export function isBun(): boolean { + return !!process.versions.bun; +} From 63eb034efecf1e14b9c10ece2df5fc658b156b95 Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:01:30 -0700 Subject: [PATCH 07/12] [wrangler] Report the missing control payload to the ProxyWorker directly under Bun Since #15906, Miniflare rewrites dispatchFetch()'s URL onto the ProxyWorker's origin inside the undici dispatcher, which Bun's fetch() ignores. Under Bun a control request now goes to the literal http://dummy/ URL and fails, so it never reaches the ProxyWorker, and the 503 this PR gives requests to the listen() URL never fired: on Bun 1.4.4 those requests hung again (no answer after 15 s), while server.fetch() still threw its UserError. When every retry of a control request fails under Bun, ProxyController now sends one without a payload straight to the ProxyWorker's own URL, which Bun can reach. The ProxyWorker's existing handling for a missing payload then answers queued and later requests with the 503. Measured on Bun 1.4.4 with wrangler built from this branch: fetch(listenUrl) gets the 503 naming oven-sh/bun#39247; Node is unchanged. --- .../startDevWorker/ProxyController.test.ts | 60 +++++++++++++++++++ .../src/api/startDevWorker/ProxyController.ts | 30 ++++++++++ 2 files changed, 90 insertions(+) diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts index 4988e8438e4..a7adf7ee082 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts @@ -8,6 +8,9 @@ import type { SerializedError } from "../../../api/startDevWorker/events"; import type { StartDevWorkerOptions } from "../../../api/startDevWorker/types"; import type { AddressInfo, Socket } from "node:net"; +const { isBunMock } = vi.hoisted(() => ({ isBunMock: vi.fn(() => false) })); +vi.mock("../../../utils/is-bun", () => ({ isBun: isBunMock })); + describe("ProxyController", () => { mockConsoleMethods(); @@ -181,4 +184,61 @@ describe("ProxyController", () => { "https://github.com/oven-sh/bun/issues/39247" ); }); + + test("a request to the ProxyWorker URL gets a 503 when Bun cannot deliver control requests", async ({ + expect, + onTestFinished, + }) => { + // Since #15906, Miniflare rewrites `dispatchFetch()`'s URL onto the + // ProxyWorker's origin inside the undici dispatcher, which Bun's + // `fetch()` ignores. Under Bun a control request therefore goes to the + // literal `http://dummy/...` URL and fails, so no request reaches the + // ProxyWorker, while a plain `fetch()` to its real URL still works. + isBunMock.mockReturnValue(true); + onTestFinished(() => { + isBunMock.mockReturnValue(false); + }); + const bus = new FakeBus(); + const controller = new ProxyController(bus); + onTestFinished(() => controller.teardown()); + + controller.onConfigUpdate({ + type: "configUpdate", + config: { + dev: { inspector: false, server: { hostname: "127.0.0.1", port: 0 } }, + } as StartDevWorkerOptions, + }); + const { proxyWorker, url } = await controller.ready.promise; + const dispatchFetch = proxyWorker.dispatchFetch; + proxyWorker.dispatchFetch = (input, init) => { + if (String(input).startsWith("http://dummy/")) { + return Promise.reject( + new TypeError("fetch failed", { + cause: new Error("getaddrinfo ENOTFOUND dummy"), + }) + ); + } + return dispatchFetch(input, init); + }; + + await expect( + controller.sendMessageToProxyWorker({ + type: "play", + proxyData: { + userWorkerUrl: { + protocol: "http:", + hostname: "127.0.0.1", + port: "1", + }, + headers: {}, + }, + }) + ).resolves.toBe(false); + + const response = await fetch(url, { signal: AbortSignal.timeout(5_000) }); + expect(response.status).toBe(503); + expect(await response.text()).toContain( + "https://github.com/oven-sh/bun/issues/39247" + ); + }); }); diff --git a/packages/wrangler/src/api/startDevWorker/ProxyController.ts b/packages/wrangler/src/api/startDevWorker/ProxyController.ts index 1e033409deb..a009b32ac7d 100644 --- a/packages/wrangler/src/api/startDevWorker/ProxyController.ts +++ b/packages/wrangler/src/api/startDevWorker/ProxyController.ts @@ -26,6 +26,7 @@ import { import { validateHttpsOptions } from "../../https-options"; import { logger } from "../../logger"; import { getSourceMappedStack } from "../../sourcemap"; +import { isBun } from "../../utils/is-bun"; import { Controller } from "./BaseController"; import { castErrorCause } from "./events"; import { createDeferred } from "./utils"; @@ -373,9 +374,38 @@ export class ProxyController extends Controller { `Failed to send message to ProxyWorker: ${JSON.stringify(message)}`, error ); + if (isBun()) { + await this.reportMissingControlPayload(); + } return false; } } + /** + * Under Bun, `dispatchFetch()` cannot reach the ProxyWorker: the URL rewrite + * onto its origin lives in the undici `dispatcher` that Bun's `fetch()` + * ignores, so control requests go to `http://dummy/` and fail. Send one + * without a payload straight to the ProxyWorker's own URL, which Bun can + * reach, so the ProxyWorker answers queued and later requests with a 503 + * naming the cause instead of holding them for a `play` that cannot arrive. + */ + async reportMissingControlPayload() { + try { + const { url } = await this.ready.promise; + await globalThis.fetch(new URL("/cdn-cgi/ProxyWorker/play", url), { + method: "POST", + headers: { Authorization: this.secret }, + // Bun's option for accepting the local self-signed dev certificate. + ...(url.protocol === "https:" + ? { tls: { rejectUnauthorized: false } } + : {}), + } as RequestInit); + } catch (cause) { + logger.debug( + "Could not tell the ProxyWorker that its control payload is missing", + cause + ); + } + } async sendMessageToInspectorProxyWorker( message: InspectorProxyWorkerIncomingWebSocketMessage, retries = 3 From 5e8cb23fce6ce011fc431daf2a65c99a05a03d23 Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:19:40 -0700 Subject: [PATCH 08/12] [remote-bindings] Keep real startup errors under Bun, and confirm play after updateBindings DevEnv only logs a failed `play` send and never re-emits it, so an error event that arrives while startup waits for `play` is a separate failure (authentication, the remote preview). Startup now reports it as itself even under Bun, and keeps the Bun-specific error for a `play` that was not delivered. ProxyController records that send failure in `latestSendError`, since the event bus never carried it; the test mock emitted it as an error event, which the real DevEnv does not, so the mock now matches DevEnv. updateBindings now waits for the new `play` to be acknowledged, as startup does, instead of only for the message mutex to drain. The changeset lists the Vite and Vitest plugins, which bundle the private @cloudflare/remote-bindings package. --- .changeset/remote-bindings-bun-play.md | 4 +- .../src/start-remote-proxy-session.test.ts | 91 +++++++++++++++---- .../src/start-remote-proxy-session.ts | 59 +++++++----- .../src/startDevWorker/ProxyController.ts | 9 ++ 4 files changed, 125 insertions(+), 38 deletions(-) diff --git a/.changeset/remote-bindings-bun-play.md b/.changeset/remote-bindings-bun-play.md index 043ffb83f0d..78c8c3ac5d4 100644 --- a/.changeset/remote-bindings-bun-play.md +++ b/.changeset/remote-bindings-bun-play.md @@ -1,7 +1,9 @@ --- "wrangler": patch +"@cloudflare/vite-plugin": patch +"@cloudflare/vitest-plugin": patch --- Fail fast when remote bindings cannot work under Bun -Under Bun, `getPlatformProxy()` with remote bindings, and other remote dev sessions, started normally, but every request to a remote binding then waited forever with no error. The local proxy learns where to forward those requests from a control message that Miniflare sends through an undici `dispatcher`, which Bun's `fetch()` ignores. Starting a remote proxy session now waits for that message to be delivered and, under Bun, throws an actionable `UserError` naming the cause instead of returning a session that hangs. +Under Bun, remote bindings in `getPlatformProxy()`, the Vite plugin and the Vitest plugin started normally, but every request to a remote binding then waited forever with no error. The local proxy learns where to forward those requests from a control message that Miniflare sends through an undici `dispatcher`, which Bun's `fetch()` ignores. Starting a remote proxy session now waits for that message to be delivered and, under Bun, throws an actionable `UserError` naming the cause instead of returning a session that hangs. diff --git a/packages/remote-bindings/src/start-remote-proxy-session.test.ts b/packages/remote-bindings/src/start-remote-proxy-session.test.ts index b44039b75af..7ee68ad1ea6 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.test.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.test.ts @@ -8,12 +8,17 @@ import type { ErrorEvent } from "./startDevWorker/events"; // How the mocked `DevEnv` behaves. With `ready` false (the default), it never // becomes ready and emits `error` just after `start()`. With `ready` true, the // local server and proxy come up, and the `play` message is delivered unless -// `playError` is set, in which case it fails the way ProxyController reports a -// failed send: an error event, then `latestReloadCompleteMessage` resolving to false. +// `playError` is set, in which case it fails the way a real failed send looks +// from outside: `latestSendError` is set and `latestReloadCompleteMessage` +// resolves to false, with no error event (DevEnv only logs send failures). +// `lateError` instead leaves `play` pending and emits that error once the local +// server is ready. `updatePlayError` makes the `play` sent by `update()` fail. const mockDevEnv = vi.hoisted(() => ({ error: undefined as unknown, ready: false, - playError: undefined as unknown, + playError: undefined as Error | undefined, + lateError: undefined as unknown, + updatePlayError: undefined as Error | undefined, })); const { isBunMock } = vi.hoisted(() => ({ isBunMock: vi.fn(() => false) })); @@ -25,20 +30,26 @@ const PROXY_URL = "http://127.0.0.1:8787/"; // startup paths without any real Miniflare. vi.mock("./startDevWorker/DevEnv", async () => { const { EventEmitter } = await import("node:events"); + type MockProxy = { + latestReloadCompleteMessage: Promise; + latestSendError?: Error; + [key: string]: unknown; + }; + // A `play` that fails with `error`, recorded the way ProxyController records + // a send that ran out of retries. + const failedPlay = (proxy: MockProxy, error: Error) => + new Promise((resolve) => + queueMicrotask(() => { + proxy.latestSendError = error; + resolve(false); + }) + ); class DevEnv extends EventEmitter { - proxy = mockDevEnv.ready + proxy: MockProxy = mockDevEnv.ready ? { localServerReady: { promise: Promise.resolve() }, ready: { promise: Promise.resolve({ url: new URL(PROXY_URL) }) }, - latestReloadCompleteMessage: - mockDevEnv.playError === undefined - ? Promise.resolve(true) - : new Promise((resolve) => - queueMicrotask(() => { - this.emit("error", mockDevEnv.playError); - resolve(false); - }) - ), + latestReloadCompleteMessage: Promise.resolve(true), runtimeMessageMutex: { drained: async () => {} }, } : { @@ -52,13 +63,31 @@ vi.mock("./startDevWorker/DevEnv", async () => { super(); // Avoid Node's throw-on-unhandled-"error" behaviour. this.on("error", () => {}); + if (mockDevEnv.ready && mockDevEnv.playError !== undefined) { + this.proxy.latestReloadCompleteMessage = failedPlay( + this.proxy, + mockDevEnv.playError + ); + } else if (mockDevEnv.ready && mockDevEnv.lateError !== undefined) { + this.proxy.latestReloadCompleteMessage = new Promise(() => {}); + } } start() { if (!mockDevEnv.ready) { queueMicrotask(() => this.emit("error", mockDevEnv.error)); + } else if (mockDevEnv.lateError !== undefined) { + setTimeout(() => this.emit("error", mockDevEnv.lateError), 0); } } - update() {} + update() { + if (mockDevEnv.updatePlayError !== undefined) { + this.proxy.latestReloadCompleteMessage = failedPlay( + this.proxy, + mockDevEnv.updatePlayError + ); + } + queueMicrotask(() => this.emit("reloadComplete", {})); + } async teardown() {} } return { DevEnv }; @@ -68,6 +97,8 @@ beforeEach(() => { mockDevEnv.error = undefined; mockDevEnv.ready = false; mockDevEnv.playError = undefined; + mockDevEnv.lateError = undefined; + mockDevEnv.updatePlayError = undefined; isBunMock.mockReturnValue(false); }); @@ -184,8 +215,7 @@ describe("startRemoteProxySession waits for the play message", () => { // `fetch()` ignores the undici `dispatcher` Miniflare routes it through // (https://github.com/oven-sh/bun/issues/39247), so a session reported as // ready would leave every remote binding call waiting forever. - const sendFailure = () => - makeErrorEvent(new Error("getaddrinfo ENOTFOUND dummy")); + const sendFailure = () => new Error("getaddrinfo ENOTFOUND dummy"); it("resolves once play is delivered", async ({ expect }) => { mockDevEnv.ready = true; @@ -235,4 +265,33 @@ describe("startRemoteProxySession waits for the play message", () => { ); expect((thrown as Error).cause).toBe(mockDevEnv.playError); }); + + it("reports a separate startup error as itself while play is pending under Bun", async ({ + expect, + }) => { + isBunMock.mockReturnValue(true); + mockDevEnv.ready = true; + const authError = new RemoteSessionAuthenticationError("Not logged in"); + mockDevEnv.lateError = makeErrorEvent(authError); + + const thrown = await startRemoteProxySession( + {}, + { cliDisplayName: "Test CLI", logger: createTestLogger() } + ).catch((error: unknown) => error); + + expect(thrown).toBe(authError); + }); + + it("rejects updateBindings when the new play is never delivered", async ({ + expect, + }) => { + mockDevEnv.ready = true; + const session = await startRemoteProxySession( + {}, + { cliDisplayName: "Test CLI", logger: createTestLogger() } + ); + mockDevEnv.updatePlayError = sendFailure(); + + await expect(session.updateBindings({})).rejects.toThrow("ENOTFOUND dummy"); + }); }); diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 3a50b44a1a0..af216eed1ae 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -70,13 +70,6 @@ function formatRemoteProxySessionError(error: unknown): string | undefined { return getErrorMessage(error); } -/** - * Walk an error's cause chain (including through error events) for a - * {@link RemoteSessionAuthenticationError}, mirroring the original Wrangler - * `findRemoteSessionAuthError` walk. Only this specific user error is surfaced - * verbatim from the chain — generic API errors (which also extend - * {@link UserError} via {@link APIError}) fall through to the wrapping logic. - */ /** * Turns an error raised while the remote proxy session starts into the error * to throw. A UserError emitted directly (e.g. the Miniflare user-error case @@ -124,6 +117,32 @@ function createBunRemoteBindingsError(cause: unknown): UserError { ); } +/** + * The error for a `play` message that never reached the ProxyWorker. Until it + * arrives, the ProxyWorker queues every binding request, so callers would wait + * forever if the session carried on. + * + * @param cause The send failure ProxyController recorded, if any. + * @returns The Bun-specific UserError under Bun, otherwise a startup error. + */ +function createPlayNotDeliveredError(cause: unknown): Error { + if (isBun()) { + return createBunRemoteBindingsError(cause); + } + return cause !== undefined + ? toStartupError(cause) + : new Error( + "Failed to start the remote proxy session. Its local proxy never received the session. There is likely additional logging output above." + ); +} + +/** + * Walk an error's cause chain (including through error events) for a + * {@link RemoteSessionAuthenticationError}, mirroring the original Wrangler + * `findRemoteSessionAuthError` walk. Only this specific user error is surfaced + * verbatim from the chain — generic API errors (which also extend + * {@link UserError} via {@link APIError}) fall through to the wrapping logic. + */ function findRemoteSessionAuthError( error: unknown ): RemoteSessionAuthenticationError | undefined { @@ -194,12 +213,7 @@ export async function startRemoteProxySession( } const maybeErrorPromise = new DeferredPromise<{ error: unknown }>(); - // Also kept synchronously: a failed `play` emits its error just before - // `latestReloadCompleteMessage` resolves to false, and the race below can - // settle on either. - let startupError: unknown; const onStartupError = (error: unknown) => { - startupError ??= error; maybeErrorPromise.resolve({ error }); }; devEnv.addListener("error", onStartupError); @@ -223,16 +237,14 @@ export async function startRemoteProxySession( maybeErrorPromise, devEnv.proxy.latestReloadCompleteMessage, ]); + if (delivery === false) { + throw createPlayNotDeliveredError(devEnv.proxy.latestSendError); + } if (delivery !== true) { - const cause = delivery ? delivery.error : startupError; - if (isBun()) { - throw createBunRemoteBindingsError(cause); - } - throw cause !== undefined - ? toStartupError(cause) - : new Error( - "Failed to start the remote proxy session. Its local proxy never received the session. There is likely additional logging output above." - ); + // DevEnv only logs a failed `play` send and never re-emits it, so an + // error event here is a separate startup failure (authentication, the + // remote preview, ...). Report it as itself, even under Bun. + throw toStartupError(delivery.error); } remoteProxyConnectionString = (await devEnv.proxy.ready.promise) @@ -263,6 +275,11 @@ export async function startRemoteProxySession( { cause: errorOrEvent } ); } + // `reloadComplete` fires just after ProxyController starts sending the + // new `play`, so this is that message's delivery, as at startup. + if (!(await devEnv.proxy.latestReloadCompleteMessage)) { + throw createPlayNotDeliveredError(devEnv.proxy.latestSendError); + } await devEnv.proxy.runtimeMessageMutex.drained(); }; diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index 6120367aa32..b3c9f18570f 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -39,6 +39,14 @@ export class ProxyController { */ latestReloadCompleteMessage = Promise.resolve(true); + /** + * The error from the most recent control message that could not be sent + * after its retries. DevEnv only logs these send failures and never + * re-emits them, so this is how a caller waiting on + * {@link latestReloadCompleteMessage} learns why it resolved to false. + */ + latestSendError?: Error; + public proxyWorker?: Miniflare; protected latestConfig?: StartDevWorkerOptions; @@ -187,6 +195,7 @@ export class ProxyController { return this.sendMessageToProxyWorker(message, retries - 1); } + this.latestSendError = error; this.emitErrorEvent( `Failed to send message to ProxyWorker: ${JSON.stringify(message)}`, error From e0a5086f2f17eaf4371c89d4617eadfc90a90cc5 Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:19:40 -0700 Subject: [PATCH 09/12] [wrangler] Send the missing-payload control request with undici's fetch Wrangler's rule is undici's fetch, never the global one. Wrangler bundles undici, so its fetch and dispatcher work under Bun (only Miniflare's runtime `undici` import resolves to Bun's dispatcher-less shim). Measured on Bun 1.4.4 with undici 7.29.1 inlined by esbuild: an Agent with rejectUnauthorized false reaches a self-signed HTTPS server (204), and the same request without it fails with DEPTH_ZERO_SELF_SIGNED_CERT. --- .../src/api/startDevWorker/ProxyController.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/wrangler/src/api/startDevWorker/ProxyController.ts b/packages/wrangler/src/api/startDevWorker/ProxyController.ts index a009b32ac7d..da7579ae1bb 100644 --- a/packages/wrangler/src/api/startDevWorker/ProxyController.ts +++ b/packages/wrangler/src/api/startDevWorker/ProxyController.ts @@ -10,6 +10,7 @@ import { Mutex, Response, } from "miniflare"; +import { Agent, fetch } from "undici"; import inspectorProxyWorkerPath from "worker:startDevWorker/InspectorProxyWorker"; import proxyWorkerPath from "worker:startDevWorker/ProxyWorker"; import WebSocket from "ws"; @@ -387,23 +388,28 @@ export class ProxyController extends Controller { * without a payload straight to the ProxyWorker's own URL, which Bun can * reach, so the ProxyWorker answers queued and later requests with a 503 * naming the cause instead of holding them for a `play` that cannot arrive. + * + * Wrangler bundles undici, so this `fetch` and its `dispatcher` are undici's + * own even under Bun; only Miniflare's runtime `undici` import resolves to + * Bun's dispatcher-less shim. */ async reportMissingControlPayload() { + // The local dev certificate is self-signed. + const insecure = new Agent({ connect: { rejectUnauthorized: false } }); try { const { url } = await this.ready.promise; - await globalThis.fetch(new URL("/cdn-cgi/ProxyWorker/play", url), { + await fetch(new URL("/cdn-cgi/ProxyWorker/play", url), { method: "POST", headers: { Authorization: this.secret }, - // Bun's option for accepting the local self-signed dev certificate. - ...(url.protocol === "https:" - ? { tls: { rejectUnauthorized: false } } - : {}), - } as RequestInit); + ...(url.protocol === "https:" ? { dispatcher: insecure } : {}), + }); } catch (cause) { logger.debug( "Could not tell the ProxyWorker that its control payload is missing", cause ); + } finally { + await insecure.close(); } } async sendMessageToInspectorProxyWorker( From d3a474ec04f0edca5c6956b77fb8c4c84fec7f62 Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:26:38 -0700 Subject: [PATCH 10/12] [remote-bindings] Clear latestSendError on every send so it never describes an older message A non-204 answer, or a ProxyWorker that was not ready, returned false without touching latestSendError, so a failure left over from an earlier message was reported as the cause of a later rejected `play`. Each send now sets latestSendError to its own outcome: cleared on a 204, a status error on any other answer. --- .../startDevWorker/ProxyController.test.ts | 34 +++++++++++++++++++ .../src/startDevWorker/ProxyController.ts | 20 ++++++++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.test.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.test.ts index ff7143df91f..b97745e5ca4 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.test.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.test.ts @@ -63,4 +63,38 @@ describe("ProxyController.latestReloadCompleteMessage", () => { }) ); }); + + it("keeps latestSendError about the latest send, never an older one", async ({ + expect, + }) => { + const outcomes: Array<() => Promise> = [ + // Four attempts that all throw, so the first `play` runs out of retries. + ...Array.from({ length: 4 }, () => async () => { + throw new Error("getaddrinfo ENOTFOUND dummy"); + }), + async () => new Response(null, { status: 500 }), + async () => new Response(null, { status: 204 }), + ]; + const { controller } = controllerWith(() => { + const next = outcomes.shift(); + if (next === undefined) { + throw new Error("unexpected extra send"); + } + return next(); + }); + + controller.play(reloadComplete); + expect(await controller.latestReloadCompleteMessage).toBe(false); + expect(controller.latestSendError?.message).toContain("ENOTFOUND dummy"); + + controller.play(reloadComplete); + expect(await controller.latestReloadCompleteMessage).toBe(false); + expect(controller.latestSendError?.message).toBe( + "The ProxyWorker answered the play message with status 500" + ); + + controller.play(reloadComplete); + expect(await controller.latestReloadCompleteMessage).toBe(true); + expect(controller.latestSendError).toBeUndefined(); + }); }); diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index b3c9f18570f..14e47a4a0be 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -40,10 +40,11 @@ export class ProxyController { latestReloadCompleteMessage = Promise.resolve(true); /** - * The error from the most recent control message that could not be sent - * after its retries. DevEnv only logs these send failures and never - * re-emits them, so this is how a caller waiting on - * {@link latestReloadCompleteMessage} learns why it resolved to false. + * Why the most recent control message was not delivered, or undefined if + * it was. Every send sets or clears it, so it never describes an older + * message. DevEnv only logs send failures and never re-emits them, so this + * is how a caller waiting on {@link latestReloadCompleteMessage} learns why + * it resolved to false. */ latestSendError?: Error; @@ -183,7 +184,16 @@ export class ProxyController { ); }); - return response?.status === 204; + if (response?.status === 204) { + this.latestSendError = undefined; + return true; + } + this.latestSendError = new Error( + response + ? `The ProxyWorker answered the ${message.type} message with status ${response.status}` + : `The ProxyWorker was not ready to receive the ${message.type} message` + ); + return false; } catch (cause) { if (this._torndown) { return false; From 27c060e38d7a64a948d7942a2bc9ca2c43f84777 Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:43:51 -0700 Subject: [PATCH 11/12] Retrigger CI after a slow Windows runner timed out @fixture/start-worker-node Both files hit node --test's 15 s timeout on Test partition (Windows, fixtures, 1) while the dev server answered GET / 200 OK after 9117 ms. The fixture passed 5/5 on Windows at 63eb034 and e0a5086, and d3a474e only changes when remote-bindings sets latestSendError. From 617272b8a97a4e8e33a8df95668ff22ff4f677d3 Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:07:18 -0700 Subject: [PATCH 12/12] [wrangler] Replace Bun delivery tracking with flat isBun() checks Each Bun failure now gets one check at its entry point: the harness's dispatchFetch() helper throws a UserError, the ProxyWorker answers with a 503 when ProxyController passes it the Bun message as a binding, and startRemoteProxySession() throws a UserError. This drops the play-delivery tracking in both ProxyController copies, the ProxyWorker's missing-payload state, the undici fallback request and the harness timeout. --- .changeset/fuzzy-bears-listen.md | 6 +- .changeset/remote-bindings-bun-play.md | 9 - .../src/start-remote-proxy-session.test.ts | 207 ++++-------------- .../src/start-remote-proxy-session.ts | 122 +++-------- .../startDevWorker/ProxyController.test.ts | 100 --------- .../src/startDevWorker/ProxyController.ts | 48 +--- packages/remote-bindings/src/utils/is-bun.ts | 8 - .../startDevWorker/ProxyController.test.ts | 181 +-------------- .../src/__tests__/api/test-harness.test.ts | 98 +-------- .../src/api/startDevWorker/ProxyController.ts | 57 +---- .../wrangler/src/api/test-harness-runtime.ts | 49 ----- packages/wrangler/src/api/test-harness.ts | 11 +- packages/wrangler/src/utils/is-bun.ts | 7 + .../templates/startDevWorker/ProxyWorker.ts | 76 +------ 14 files changed, 120 insertions(+), 859 deletions(-) delete mode 100644 .changeset/remote-bindings-bun-play.md delete mode 100644 packages/remote-bindings/src/startDevWorker/ProxyController.test.ts delete mode 100644 packages/remote-bindings/src/utils/is-bun.ts delete mode 100644 packages/wrangler/src/api/test-harness-runtime.ts diff --git a/.changeset/fuzzy-bears-listen.md b/.changeset/fuzzy-bears-listen.md index e55931ac9ac..adedf4999e2 100644 --- a/.changeset/fuzzy-bears-listen.md +++ b/.changeset/fuzzy-bears-listen.md @@ -1,7 +1,9 @@ --- "wrangler": patch +"@cloudflare/vite-plugin": patch +"@cloudflare/vitest-plugin": patch --- -Fail fast when `createTestHarness()` cannot dispatch requests under Bun +Fail with an error instead of hanging when local dev requests can't work under Bun -Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes requests through. Under Bun, `server.fetch()` and requests to the URL returned by `listen()` used to hang forever with no error, and `server.getWorker().fetch()` could send a request to the network instead of the Worker. `listen()` now resolves, `server.fetch()` and `server.getWorker()`'s `fetch()`, `email()` and `scheduled()` throw an actionable `UserError` before sending anything, and the ProxyWorker answers requests to the `listen()` URL with a 503 that names the cause. `server.getWorker().getEnv()` and `getExport()` keep working under Bun. +Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes requests through (oven-sh/bun#39247). Under Bun, `createTestHarness()`'s `server.fetch()` and `server.getWorker()`'s `fetch()`, `email()` and `scheduled()` now throw a `UserError`, requests to the dev server URL get a 503 naming the cause, and starting remote bindings throws a `UserError`. Each of these used to hang with no error. `server.getWorker().getEnv()` and `getExport()` keep working under Bun. diff --git a/.changeset/remote-bindings-bun-play.md b/.changeset/remote-bindings-bun-play.md deleted file mode 100644 index 78c8c3ac5d4..00000000000 --- a/.changeset/remote-bindings-bun-play.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"wrangler": patch -"@cloudflare/vite-plugin": patch -"@cloudflare/vitest-plugin": patch ---- - -Fail fast when remote bindings cannot work under Bun - -Under Bun, remote bindings in `getPlatformProxy()`, the Vite plugin and the Vitest plugin started normally, but every request to a remote binding then waited forever with no error. The local proxy learns where to forward those requests from a control message that Miniflare sends through an undici `dispatcher`, which Bun's `fetch()` ignores. Starting a remote proxy session now waits for that message to be delivered and, under Bun, throws an actionable `UserError` naming the cause instead of returning a session that hangs. diff --git a/packages/remote-bindings/src/start-remote-proxy-session.test.ts b/packages/remote-bindings/src/start-remote-proxy-session.test.ts index 7ee68ad1ea6..d3d0cfa247e 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.test.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.test.ts @@ -1,107 +1,39 @@ import { APIError, UserError } from "@cloudflare/workers-utils"; -import { beforeEach, describe, it, vi } from "vitest"; +import { describe, it, vi } from "vitest"; import { startRemoteProxySession } from "./start-remote-proxy-session"; import { RemoteSessionAuthenticationError } from "./utils/remote"; import type { RemoteBindingsLogger } from "./logger"; import type { ErrorEvent } from "./startDevWorker/events"; -// How the mocked `DevEnv` behaves. With `ready` false (the default), it never -// becomes ready and emits `error` just after `start()`. With `ready` true, the -// local server and proxy come up, and the `play` message is delivered unless -// `playError` is set, in which case it fails the way a real failed send looks -// from outside: `latestSendError` is set and `latestReloadCompleteMessage` -// resolves to false, with no error event (DevEnv only logs send failures). -// `lateError` instead leaves `play` pending and emits that error once the local -// server is ready. `updatePlayError` makes the `play` sent by `update()` fail. -const mockDevEnv = vi.hoisted(() => ({ - error: undefined as unknown, - ready: false, - playError: undefined as Error | undefined, - lateError: undefined as unknown, - updatePlayError: undefined as Error | undefined, -})); +// The error that the mocked `DevEnv` emits asynchronously from `start()`. +const mockDevEnv = vi.hoisted(() => ({ error: undefined as unknown })); -const { isBunMock } = vi.hoisted(() => ({ isBunMock: vi.fn(() => false) })); -vi.mock("./utils/is-bun", () => ({ isBun: isBunMock })); - -const PROXY_URL = "http://127.0.0.1:8787/"; - -// Replace `DevEnv` with a stub, so we can exercise `startRemoteProxySession`'s -// startup paths without any real Miniflare. +// Replace `DevEnv` with a stub that never becomes ready and instead emits a +// configurable error event just after `start()`, so we can exercise the async +// error-handling path in `startRemoteProxySession` without any real Miniflare. vi.mock("./startDevWorker/DevEnv", async () => { const { EventEmitter } = await import("node:events"); - type MockProxy = { - latestReloadCompleteMessage: Promise; - latestSendError?: Error; - [key: string]: unknown; - }; - // A `play` that fails with `error`, recorded the way ProxyController records - // a send that ran out of retries. - const failedPlay = (proxy: MockProxy, error: Error) => - new Promise((resolve) => - queueMicrotask(() => { - proxy.latestSendError = error; - resolve(false); - }) - ); class DevEnv extends EventEmitter { - proxy: MockProxy = mockDevEnv.ready - ? { - localServerReady: { promise: Promise.resolve() }, - ready: { promise: Promise.resolve({ url: new URL(PROXY_URL) }) }, - latestReloadCompleteMessage: Promise.resolve(true), - runtimeMessageMutex: { drained: async () => {} }, - } - : { - // Never resolves — the startup race is always settled by the error event. - localServerReady: { promise: new Promise(() => {}) }, - ready: { promise: new Promise(() => {}) }, - latestReloadCompleteMessage: new Promise(() => {}), - runtimeMessageMutex: { drained: async () => {} }, - }; + proxy = { + // Never resolves — the startup race is always settled by the error event. + localServerReady: { promise: new Promise(() => {}) }, + ready: { promise: new Promise(() => {}) }, + runtimeMessageMutex: { drained: async () => {} }, + }; constructor() { super(); // Avoid Node's throw-on-unhandled-"error" behaviour. this.on("error", () => {}); - if (mockDevEnv.ready && mockDevEnv.playError !== undefined) { - this.proxy.latestReloadCompleteMessage = failedPlay( - this.proxy, - mockDevEnv.playError - ); - } else if (mockDevEnv.ready && mockDevEnv.lateError !== undefined) { - this.proxy.latestReloadCompleteMessage = new Promise(() => {}); - } } start() { - if (!mockDevEnv.ready) { - queueMicrotask(() => this.emit("error", mockDevEnv.error)); - } else if (mockDevEnv.lateError !== undefined) { - setTimeout(() => this.emit("error", mockDevEnv.lateError), 0); - } - } - update() { - if (mockDevEnv.updatePlayError !== undefined) { - this.proxy.latestReloadCompleteMessage = failedPlay( - this.proxy, - mockDevEnv.updatePlayError - ); - } - queueMicrotask(() => this.emit("reloadComplete", {})); + queueMicrotask(() => this.emit("error", mockDevEnv.error)); } + update() {} async teardown() {} } return { DevEnv }; }); -beforeEach(() => { - mockDevEnv.error = undefined; - mockDevEnv.ready = false; - mockDevEnv.playError = undefined; - mockDevEnv.lateError = undefined; - mockDevEnv.updatePlayError = undefined; - isBunMock.mockReturnValue(false); -}); - function createTestLogger(): RemoteBindingsLogger { return { loggerLevel: "none", @@ -125,6 +57,30 @@ function makeErrorEvent(cause: ErrorEvent["cause"]): ErrorEvent { } describe("startRemoteProxySession error handling", () => { + it("throws a UserError under Bun before starting the session", async ({ + expect, + onTestFinished, + }) => { + const originalBunVersion = process.versions.bun; + process.versions.bun = "1.4.4"; + onTestFinished(() => { + process.versions.bun = originalBunVersion; + }); + + const thrown = await startRemoteProxySession( + {}, + { cliDisplayName: "Test CLI", logger: createTestLogger() } + ).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(UserError); + expect(thrown).toMatchObject({ + message: expect.stringContaining( + "https://github.com/oven-sh/bun/issues/39247" + ), + telemetryMessage: "remote proxy session under bun", + }); + }); + // Regression: a `UserError` emitted asynchronously (e.g. the Miniflare // user-error case in `DevEnv.handleErrorEvent`) used to fall through to being // wrapped in a generic `Error`, which makes Wrangler treat it as an @@ -208,90 +164,3 @@ describe("startRemoteProxySession error handling", () => { ); }); }); - -describe("startRemoteProxySession waits for the play message", () => { - // The ProxyWorker queues every binding request until `play` hands it the - // remote session. Under Bun that message never arrives, because Bun's - // `fetch()` ignores the undici `dispatcher` Miniflare routes it through - // (https://github.com/oven-sh/bun/issues/39247), so a session reported as - // ready would leave every remote binding call waiting forever. - const sendFailure = () => new Error("getaddrinfo ENOTFOUND dummy"); - - it("resolves once play is delivered", async ({ expect }) => { - mockDevEnv.ready = true; - - const session = await startRemoteProxySession( - {}, - { cliDisplayName: "Test CLI", logger: createTestLogger() } - ); - - expect(String(session.remoteProxyConnectionString)).toBe(PROXY_URL); - }); - - it("rejects instead of hanging when play is never delivered", async ({ - expect, - }) => { - mockDevEnv.ready = true; - mockDevEnv.playError = sendFailure(); - - const thrown = await startRemoteProxySession( - {}, - { cliDisplayName: "Test CLI", logger: createTestLogger() } - ).catch((error: unknown) => error); - - expect(thrown).toBeInstanceOf(Error); - expect(thrown).not.toBeInstanceOf(UserError); - expect((thrown as Error).message).toContain( - "Failed to start the remote proxy session" - ); - expect((thrown as Error).message).toContain("ENOTFOUND dummy"); - }); - - it("names the Bun limitation when play is never delivered under Bun", async ({ - expect, - }) => { - isBunMock.mockReturnValue(true); - mockDevEnv.ready = true; - mockDevEnv.playError = sendFailure(); - - const thrown = await startRemoteProxySession( - {}, - { cliDisplayName: "Test CLI", logger: createTestLogger() } - ).catch((error: unknown) => error); - - expect(thrown).toBeInstanceOf(UserError); - expect((thrown as Error).message).toContain( - "https://github.com/oven-sh/bun/issues/39247" - ); - expect((thrown as Error).cause).toBe(mockDevEnv.playError); - }); - - it("reports a separate startup error as itself while play is pending under Bun", async ({ - expect, - }) => { - isBunMock.mockReturnValue(true); - mockDevEnv.ready = true; - const authError = new RemoteSessionAuthenticationError("Not logged in"); - mockDevEnv.lateError = makeErrorEvent(authError); - - const thrown = await startRemoteProxySession( - {}, - { cliDisplayName: "Test CLI", logger: createTestLogger() } - ).catch((error: unknown) => error); - - expect(thrown).toBe(authError); - }); - - it("rejects updateBindings when the new play is never delivered", async ({ - expect, - }) => { - mockDevEnv.ready = true; - const session = await startRemoteProxySession( - {}, - { cliDisplayName: "Test CLI", logger: createTestLogger() } - ); - mockDevEnv.updatePlayError = sendFailure(); - - await expect(session.updateBindings({})).rejects.toThrow("ENOTFOUND dummy"); - }); -}); diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index af216eed1ae..08e1fb869dd 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -7,7 +7,6 @@ import remoteBindingsWorkerSource from "worker:remoteBindings/ProxyServerWorker" import { getRemoteBindingsAuthHook } from "./auth"; import { initLogger } from "./logger"; import { DevEnv } from "./startDevWorker/DevEnv"; -import { isBun } from "./utils/is-bun"; import { RemoteSessionAuthenticationError } from "./utils/remote"; import type { RemoteBindingsLogger } from "./logger"; import type { @@ -70,72 +69,6 @@ function formatRemoteProxySessionError(error: unknown): string | undefined { return getErrorMessage(error); } -/** - * Turns an error raised while the remote proxy session starts into the error - * to throw. A UserError emitted directly (e.g. the Miniflare user-error case - * that DevEnv re-emits), and a RemoteSessionAuthenticationError found in the - * error event's cause chain, are surfaced verbatim so callers can still branch - * on `instanceof UserError` and the user sees a single actionable message. - * Everything else — including generic API errors — is wrapped. - * - * @param error The error the session emitted during startup. - * @returns The error to throw. - */ -function toStartupError(error: unknown): Error { - if (error instanceof UserError) { - return error; - } - const authError = findRemoteSessionAuthError(error); - if (authError) { - return authError; - } - const details = formatRemoteProxySessionError(error); - return new Error( - details - ? `Failed to start the remote proxy session. ${details}` - : "Failed to start the remote proxy session. There is likely additional logging output above.", - { cause: error } - ); -} - -/** - * The error for a remote proxy session that cannot work under Bun. Bun's - * `fetch()` ignores the undici `dispatcher` that Miniflare routes the session's - * control messages through, so the local proxy never learns where to forward - * binding requests. - * - * @param cause The failure that showed it, if one was reported. - * @returns A UserError explaining the limitation and the way around it. - */ -function createBunRemoteBindingsError(cause: unknown): UserError { - return new UserError( - "Remote bindings cannot work under Bun yet. Bun's `fetch()` ignores the undici `dispatcher` that Miniflare uses to reach the remote proxy session (https://github.com/oven-sh/bun/issues/39247), so every request to a remote binding would wait forever. Run this under Node.js, or turn remote bindings off.", - { - cause, - telemetryMessage: "remote proxy session bun control message failed", - } - ); -} - -/** - * The error for a `play` message that never reached the ProxyWorker. Until it - * arrives, the ProxyWorker queues every binding request, so callers would wait - * forever if the session carried on. - * - * @param cause The send failure ProxyController recorded, if any. - * @returns The Bun-specific UserError under Bun, otherwise a startup error. - */ -function createPlayNotDeliveredError(cause: unknown): Error { - if (isBun()) { - return createBunRemoteBindingsError(cause); - } - return cause !== undefined - ? toStartupError(cause) - : new Error( - "Failed to start the remote proxy session. Its local proxy never received the session. There is likely additional logging output above." - ); -} - /** * Walk an error's cause chain (including through error events) for a * {@link RemoteSessionAuthenticationError}, mirroring the original Wrangler @@ -171,6 +104,16 @@ export async function startRemoteProxySession( } } + // The session hands its local proxy the remote connection in a control + // message sent through an undici `dispatcher`, which Bun's `fetch()` ignores, + // so every remote binding request would wait forever. + if (process.versions.bun) { + throw new UserError( + "Remote bindings can't work under Bun, because Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes them through (https://github.com/oven-sh/bun/issues/39247). Run this using Node.js, or turn remote bindings off.", + { telemetryMessage: "remote proxy session under bun" } + ); + } + options.logger.log(chalk.dim("⎔ Establishing remote connection...")); initLogger(getInternalLogger(options.logger)); const rawBindings = toRawBindings(bindings); @@ -225,26 +168,26 @@ export async function startRemoteProxySession( ]); if (maybeError && maybeError.error) { - throw toStartupError(maybeError.error); - } - - // The local server is ready once the remote Worker has reloaded, but the - // `play` message that hands the ProxyWorker that remote session is sent in - // the background. Wait for it: until it lands, every binding request waits - // in the ProxyWorker's queue, so a session whose `play` never arrives would - // hang its callers with no error. - const delivery = await Promise.race([ - maybeErrorPromise, - devEnv.proxy.latestReloadCompleteMessage, - ]); - if (delivery === false) { - throw createPlayNotDeliveredError(devEnv.proxy.latestSendError); - } - if (delivery !== true) { - // DevEnv only logs a failed `play` send and never re-emits it, so an - // error event here is a separate startup failure (authentication, the - // remote preview, ...). Report it as itself, even under Bun. - throw toStartupError(delivery.error); + // A UserError emitted directly (e.g. the Miniflare user-error case that + // DevEnv re-emits), and a RemoteSessionAuthenticationError found in the + // error event's cause chain, are surfaced verbatim so callers can still + // branch on `instanceof UserError` and the user sees a single actionable + // message. Everything else — including generic API errors — falls through + // to the wrapping below. + if (maybeError.error instanceof UserError) { + throw maybeError.error; + } + const authError = findRemoteSessionAuthError(maybeError.error); + if (authError) { + throw authError; + } + const details = formatRemoteProxySessionError(maybeError.error); + throw new Error( + details + ? `Failed to start the remote proxy session. ${details}` + : "Failed to start the remote proxy session. There is likely additional logging output above.", + { cause: maybeError.error } + ); } remoteProxyConnectionString = (await devEnv.proxy.ready.promise) @@ -275,11 +218,6 @@ export async function startRemoteProxySession( { cause: errorOrEvent } ); } - // `reloadComplete` fires just after ProxyController starts sending the - // new `play`, so this is that message's delivery, as at startup. - if (!(await devEnv.proxy.latestReloadCompleteMessage)) { - throw createPlayNotDeliveredError(devEnv.proxy.latestSendError); - } await devEnv.proxy.runtimeMessageMutex.drained(); }; diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.test.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.test.ts deleted file mode 100644 index b97745e5ca4..00000000000 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Response } from "miniflare"; -import { describe, it, vi } from "vitest"; -import { ProxyController } from "./ProxyController"; -import type { ErrorEvent, ReadyEvent, ReloadCompleteEvent } from "./events"; - -// Only the Miniflare instance ProxyController talks to is faked, so its own -// send, retry and error reporting run for real. -function controllerWith(dispatchFetch: () => Promise) { - const onError = vi.fn<(event: ErrorEvent) => void>(); - const controller = new ProxyController(onError, () => {}); - const proxyWorker = { - ready: Promise.resolve(new URL("http://127.0.0.1:8787/")), - dispatchFetch: vi.fn(dispatchFetch), - }; - controller.ready.resolve({ - type: "ready", - proxyWorker, - url: new URL("http://127.0.0.1:8787/"), - } as unknown as ReadyEvent); - return { controller, onError, proxyWorker }; -} - -const reloadComplete = { - type: "reloadComplete", - config: {}, - bundle: {}, - proxyData: {}, -} as unknown as ReloadCompleteEvent; - -describe("ProxyController.latestReloadCompleteMessage", () => { - it("resolves true once the ProxyWorker receives play", async ({ expect }) => { - const { controller, onError, proxyWorker } = controllerWith(async () => { - return new Response(null, { status: 204 }); - }); - - controller.play(reloadComplete); - - expect(await controller.latestReloadCompleteMessage).toBe(true); - expect(proxyWorker.dispatchFetch).toHaveBeenCalledTimes(1); - expect(onError).not.toHaveBeenCalled(); - }); - - // Under Bun, Miniflare's `dispatchFetch()` sends the request to the literal - // `http://dummy/...` URL, because Bun's `fetch()` ignores the undici - // `dispatcher` that rewrites it, and the lookup fails. - it("resolves false and reports the error once every retry fails", async ({ - expect, - }) => { - const { controller, onError, proxyWorker } = controllerWith(async () => { - throw new Error("getaddrinfo ENOTFOUND dummy"); - }); - - controller.play(reloadComplete); - - expect(await controller.latestReloadCompleteMessage).toBe(false); - expect(proxyWorker.dispatchFetch).toHaveBeenCalledTimes(4); - expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ - reason: expect.stringContaining( - "Failed to send message to ProxyWorker" - ), - }) - ); - }); - - it("keeps latestSendError about the latest send, never an older one", async ({ - expect, - }) => { - const outcomes: Array<() => Promise> = [ - // Four attempts that all throw, so the first `play` runs out of retries. - ...Array.from({ length: 4 }, () => async () => { - throw new Error("getaddrinfo ENOTFOUND dummy"); - }), - async () => new Response(null, { status: 500 }), - async () => new Response(null, { status: 204 }), - ]; - const { controller } = controllerWith(() => { - const next = outcomes.shift(); - if (next === undefined) { - throw new Error("unexpected extra send"); - } - return next(); - }); - - controller.play(reloadComplete); - expect(await controller.latestReloadCompleteMessage).toBe(false); - expect(controller.latestSendError?.message).toContain("ENOTFOUND dummy"); - - controller.play(reloadComplete); - expect(await controller.latestReloadCompleteMessage).toBe(false); - expect(controller.latestSendError?.message).toBe( - "The ProxyWorker answered the play message with status 500" - ); - - controller.play(reloadComplete); - expect(await controller.latestReloadCompleteMessage).toBe(true); - expect(controller.latestSendError).toBeUndefined(); - }); -}); diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index 14e47a4a0be..fb2de74ca99 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -30,24 +30,6 @@ export class ProxyController { public localServerReady = createDeferred(); - /** - * Whether the latest `play` message reached the ProxyWorker. `play` carries - * the remote session that binding requests are forwarded to, and until it - * arrives the ProxyWorker queues every request, so a `play` that is never - * delivered leaves callers waiting forever. Mirrors Wrangler's - * ProxyController. - */ - latestReloadCompleteMessage = Promise.resolve(true); - - /** - * Why the most recent control message was not delivered, or undefined if - * it was. Every send sets or clears it, so it never describes an older - * message. DevEnv only logs send failures and never re-emits them, so this - * is how a caller waiting on {@link latestReloadCompleteMessage} learns why - * it resolved to false. - */ - latestSendError?: Error; - public proxyWorker?: Miniflare; protected latestConfig?: StartDevWorkerOptions; @@ -149,25 +131,18 @@ export class ProxyController { } runtimeMessageMutex = new Mutex(); - /** - * Sends a control message to the ProxyWorker, retrying on failure. - * - * @param message The control message to send. - * @param retries How many more attempts to make after a failed one. - * @returns Whether the ProxyWorker received the message. - */ async sendMessageToProxyWorker( message: ProxyWorkerIncomingRequestBody, retries = 3 - ): Promise { + ): Promise { if (this._torndown) { - return false; + return; } // Don't do any async work here. Enqueue the message with the mutex immediately. try { - const response = await this.runtimeMessageMutex.runWith(async () => { + await this.runtimeMessageMutex.runWith(async () => { const { proxyWorker } = await this.ready.promise; const ready = await proxyWorker.ready.catch(() => undefined); @@ -183,20 +158,9 @@ export class ProxyController { } ); }); - - if (response?.status === 204) { - this.latestSendError = undefined; - return true; - } - this.latestSendError = new Error( - response - ? `The ProxyWorker answered the ${message.type} message with status ${response.status}` - : `The ProxyWorker was not ready to receive the ${message.type} message` - ); - return false; } catch (cause) { if (this._torndown) { - return false; + return; } const error = castErrorCause(cause); @@ -205,12 +169,10 @@ export class ProxyController { return this.sendMessageToProxyWorker(message, retries - 1); } - this.latestSendError = error; this.emitErrorEvent( `Failed to send message to ProxyWorker: ${JSON.stringify(message)}`, error ); - return false; } } start(config: StartDevWorkerOptions) { @@ -227,7 +189,7 @@ export class ProxyController { this.latestConfig = data.config; this.latestBundle = data.bundle; - this.latestReloadCompleteMessage = this.sendMessageToProxyWorker({ + void this.sendMessageToProxyWorker({ type: "play", proxyData: data.proxyData, }); diff --git a/packages/remote-bindings/src/utils/is-bun.ts b/packages/remote-bindings/src/utils/is-bun.ts deleted file mode 100644 index 793e309ffdc..00000000000 --- a/packages/remote-bindings/src/utils/is-bun.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Returns whether this process is running in the Bun runtime. - * - * @returns Whether this process is running in the Bun runtime. - */ -export function isBun(): boolean { - return !!process.versions.bun; -} diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts index a7adf7ee082..f5a08b65136 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts @@ -1,15 +1,9 @@ -import { createServer } from "node:net"; -import { describe, test, vi } from "vitest"; +import { describe, test } from "vitest"; import { serialiseError } from "../../../api/startDevWorker/events"; import { ProxyController } from "../../../api/startDevWorker/ProxyController"; import { FakeBus } from "../../helpers/fake-bus"; import { mockConsoleMethods } from "../../helpers/mock-console"; import type { SerializedError } from "../../../api/startDevWorker/events"; -import type { StartDevWorkerOptions } from "../../../api/startDevWorker/types"; -import type { AddressInfo, Socket } from "node:net"; - -const { isBunMock } = vi.hoisted(() => ({ isBunMock: vi.fn(() => false) })); -vi.mock("../../../utils/is-bun", () => ({ isBun: isBunMock })); describe("ProxyController", () => { mockConsoleMethods(); @@ -68,177 +62,4 @@ describe("ProxyController", () => { expect(event.stack).toContain("Error: boom"); expect(event.exceptionDetails?.exceptionId).toBe(1); }); - - test("ProxyWorker answers with a 503 once a control request arrives without its payload", async ({ - expect, - onTestFinished, - }) => { - // Regression test for https://github.com/cloudflare/workers-sdk/issues/15717: - // Bun's `fetch()` ignores the undici dispatcher that attaches the - // `cf.hostMetadata` payload, so control requests arrive authorised but - // empty, no `play` ever lands, and queued requests used to hang forever. - const bus = new FakeBus(); - const controller = new ProxyController(bus); - onTestFinished(() => controller.teardown()); - - controller.onConfigUpdate({ - type: "configUpdate", - config: { - dev: { inspector: false, server: { hostname: "127.0.0.1", port: 0 } }, - } as StartDevWorkerOptions, - }); - const { proxyWorker } = await controller.ready.promise; - - // The same control request delivered with its payload is acknowledged, - // so the 400 below comes from the missing payload alone. - await expect( - controller.sendMessageToProxyWorker({ type: "pause" }) - ).resolves.toBe(true); - - // Whether this request reaches the ProxyWorker before or after the empty - // control request, it must be answered rather than left in the queue. - const sentBefore = proxyWorker.dispatchFetch("http://example.com/before"); - - const control = await proxyWorker.dispatchFetch( - "http://dummy/cdn-cgi/ProxyWorker/play", - { headers: { Authorization: controller.secret } } - ); - expect(control.status).toBe(400); - - for (const response of [ - await sentBefore, - await proxyWorker.dispatchFetch("http://example.com/after"), - ]) { - expect(response.status).toBe(503); - expect(await response.text()).toContain( - "https://github.com/oven-sh/bun/issues/39247" - ); - } - }); - - test("ProxyWorker answers with a 503 when a request in flight is retried after a control request arrives without its payload", async ({ - expect, - onTestFinished, - }) => { - // A request that is already forwarded is in neither queue when the empty - // control request drains them, and its retry is requeued asynchronously - // once the connection to the old UserWorker fails. With no `play` to - // come, that retry must get the same 503 rather than wait forever. - const sockets: Socket[] = []; - const userWorker = createServer((socket) => sockets.push(socket)); - await new Promise((resolve) => - userWorker.listen(0, "127.0.0.1", resolve) - ); - onTestFinished( - () => new Promise((resolve) => userWorker.close(() => resolve())) - ); - const { port } = userWorker.address() as AddressInfo; - - const bus = new FakeBus(); - const controller = new ProxyController(bus); - onTestFinished(() => controller.teardown()); - - controller.onConfigUpdate({ - type: "configUpdate", - config: { - dev: { inspector: false, server: { hostname: "127.0.0.1", port: 0 } }, - } as StartDevWorkerOptions, - }); - const { proxyWorker } = await controller.ready.promise; - - await expect( - controller.sendMessageToProxyWorker({ - type: "play", - proxyData: { - userWorkerUrl: { - protocol: "http:", - hostname: "127.0.0.1", - port: String(port), - }, - headers: {}, - }, - }) - ).resolves.toBe(true); - - const inFlight = proxyWorker.dispatchFetch("http://example.com/in-flight"); - await vi.waitFor(() => expect(sockets.length).toBeGreaterThan(0)); - - // A reload pauses the proxy, so the failed request is requeued rather - // than reported, and then the next control request arrives empty. - await expect( - controller.sendMessageToProxyWorker({ type: "pause" }) - ).resolves.toBe(true); - const control = await proxyWorker.dispatchFetch( - "http://dummy/cdn-cgi/ProxyWorker/play", - { headers: { Authorization: controller.secret } } - ); - expect(control.status).toBe(400); - - for (const socket of sockets) { - socket.destroy(); - } - - const response = await inFlight; - expect(response.status).toBe(503); - expect(await response.text()).toContain( - "https://github.com/oven-sh/bun/issues/39247" - ); - }); - - test("a request to the ProxyWorker URL gets a 503 when Bun cannot deliver control requests", async ({ - expect, - onTestFinished, - }) => { - // Since #15906, Miniflare rewrites `dispatchFetch()`'s URL onto the - // ProxyWorker's origin inside the undici dispatcher, which Bun's - // `fetch()` ignores. Under Bun a control request therefore goes to the - // literal `http://dummy/...` URL and fails, so no request reaches the - // ProxyWorker, while a plain `fetch()` to its real URL still works. - isBunMock.mockReturnValue(true); - onTestFinished(() => { - isBunMock.mockReturnValue(false); - }); - const bus = new FakeBus(); - const controller = new ProxyController(bus); - onTestFinished(() => controller.teardown()); - - controller.onConfigUpdate({ - type: "configUpdate", - config: { - dev: { inspector: false, server: { hostname: "127.0.0.1", port: 0 } }, - } as StartDevWorkerOptions, - }); - const { proxyWorker, url } = await controller.ready.promise; - const dispatchFetch = proxyWorker.dispatchFetch; - proxyWorker.dispatchFetch = (input, init) => { - if (String(input).startsWith("http://dummy/")) { - return Promise.reject( - new TypeError("fetch failed", { - cause: new Error("getaddrinfo ENOTFOUND dummy"), - }) - ); - } - return dispatchFetch(input, init); - }; - - await expect( - controller.sendMessageToProxyWorker({ - type: "play", - proxyData: { - userWorkerUrl: { - protocol: "http:", - hostname: "127.0.0.1", - port: "1", - }, - headers: {}, - }, - }) - ).resolves.toBe(false); - - const response = await fetch(url, { signal: AbortSignal.timeout(5_000) }); - expect(response.status).toBe(503); - expect(await response.text()).toContain( - "https://github.com/oven-sh/bun/issues/39247" - ); - }); }); diff --git a/packages/wrangler/src/__tests__/api/test-harness.test.ts b/packages/wrangler/src/__tests__/api/test-harness.test.ts index 720a16f218a..aaade3e667b 100644 --- a/packages/wrangler/src/__tests__/api/test-harness.test.ts +++ b/packages/wrangler/src/__tests__/api/test-harness.test.ts @@ -2,61 +2,28 @@ import { writeFileSync } from "node:fs"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { fetch } from "undici"; import { describe, it, vi } from "vitest"; -import { ProxyController } from "../../api/startDevWorker/ProxyController"; import { createTestHarness } from "../../api/test-harness"; -import { waitForBunProxyMessages } from "../../api/test-harness-runtime"; +import { BUN_UNSUPPORTED_MESSAGE } from "../../utils/is-bun"; import { mockConsoleMethods } from "../helpers/mock-console"; -import type { Miniflare } from "miniflare"; - -const BUN_PROXY_ERROR = - "`createTestHarness()` cannot dispatch requests to your Worker under Bun, because Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes them through (https://github.com/oven-sh/bun/issues/39247). Without it, a request can reach the network instead of your Worker. `server.getWorker().getEnv()` and `getExport()` still work under Bun; run `fetch()`, `email()` and `scheduled()` dispatches using Node.js."; const { isBunMock } = vi.hoisted(() => ({ isBunMock: vi.fn(() => false) })); -vi.mock("../../utils/is-bun", () => ({ isBun: isBunMock })); +vi.mock("../../utils/is-bun", async (importOriginal) => ({ + ...(await importOriginal()), + isBun: isBunMock, +})); describe("createTestHarness under Bun", () => { mockConsoleMethods(); runInTempDir(); - it("keeps listen() and getEnv() working and fails every dispatch door loudly", async ({ + it("keeps getEnv() working and fails every dispatch with the Bun error", async ({ expect, onTestFinished, }) => { - // Simulates Bun under Node with the two things Bun changes: `isBun()` - // reports true, and Miniflare's control requests to the ProxyWorker lose - // the `cf` payload, because Bun's `fetch()` ignores the undici dispatcher - // that attaches it (https://github.com/cloudflare/workers-sdk/issues/15717). isBunMock.mockReturnValue(true); onTestFinished(() => { isBunMock.mockReturnValue(false); }); - // `dispatchFetch` is an instance field, so wrap it on the ProxyWorker's - // Miniflare right after the ProxyController creates it. - const proxyControllerPrototype = ProxyController.prototype as unknown as { - createProxyWorker(this: ProxyController): void; - }; - const createProxyWorker = proxyControllerPrototype.createProxyWorker; - const createProxyWorkerSpy = vi - .spyOn(proxyControllerPrototype, "createProxyWorker") - .mockImplementation(function (this: ProxyController) { - createProxyWorker.call(this); - const { proxyWorker } = this; - if (proxyWorker === undefined || droppedCf.has(proxyWorker)) { - return; - } - droppedCf.add(proxyWorker); - const dispatchFetch = proxyWorker.dispatchFetch; - proxyWorker.dispatchFetch = (input, init) => { - if (String(input).includes("/cdn-cgi/ProxyWorker/") && init) { - const withoutCf = { ...init }; - delete withoutCf.cf; - return dispatchFetch(input, withoutCf); - } - return dispatchFetch(input, init); - }; - }); - onTestFinished(() => createProxyWorkerSpy.mockRestore()); - const droppedCf = new WeakSet(); writeFileSync( "worker.js", @@ -79,68 +46,21 @@ describe("createTestHarness under Bun", () => { const { url } = await server.listen(); - // `getEnv()` reads bindings without Miniflare's `dispatchFetch()`. const env = await server.getWorker<{ PROBE: string }>().getEnv(); expect(env.PROBE).toBe("yes"); - // Every door below goes through `dispatchFetch()`, whose undici - // dispatcher Bun ignores, so each must refuse before sending anything. const refused = { - message: BUN_PROXY_ERROR, - telemetryMessage: "test harness bun proxy request failed", + message: expect.stringContaining(BUN_UNSUPPORTED_MESSAGE), + telemetryMessage: "test harness dispatch under bun", }; await expect(server.fetch("/")).rejects.toMatchObject(refused); await expect(server.getWorker().fetch("/")).rejects.toMatchObject(refused); - await expect( - server.getWorker().fetch("http://example.com/") - ).rejects.toMatchObject(refused); await expect( server.getWorker().scheduled({ cron: "* * * * *" }) ).rejects.toMatchObject(refused); const viaUrl = await fetch(url); expect(viaUrl.status).toBe(503); - expect(await viaUrl.text()).toContain( - "https://github.com/oven-sh/bun/issues/39247" - ); - }); -}); - -describe("waitForBunProxyMessages", () => { - it("allows Bun when proxy messages are delivered", async ({ expect }) => { - await expect( - waitForBunProxyMessages(() => Promise.resolve(true), 1) - ).resolves.toBeUndefined(); - }); - - it("throws a UserError when Bun fails to deliver proxy messages", async ({ - expect, - }) => { - await expect( - waitForBunProxyMessages(() => Promise.resolve(false), 1) - ).rejects.toMatchObject({ - message: BUN_PROXY_ERROR, - telemetryMessage: "test harness bun proxy request failed", - }); - }); - - it("throws a UserError when Bun does not deliver proxy messages", async ({ - expect, - }) => { - vi.useFakeTimers(); - - try { - const rejection = expect( - waitForBunProxyMessages(() => new Promise(() => {}), 100) - ).rejects.toMatchObject({ - message: BUN_PROXY_ERROR, - telemetryMessage: "test harness bun proxy request failed", - }); - - await vi.advanceTimersByTimeAsync(100); - await rejection; - } finally { - vi.useRealTimers(); - } + expect(await viaUrl.text()).toBe(BUN_UNSUPPORTED_MESSAGE); }); }); diff --git a/packages/wrangler/src/api/startDevWorker/ProxyController.ts b/packages/wrangler/src/api/startDevWorker/ProxyController.ts index da7579ae1bb..7b01a94ef06 100644 --- a/packages/wrangler/src/api/startDevWorker/ProxyController.ts +++ b/packages/wrangler/src/api/startDevWorker/ProxyController.ts @@ -10,7 +10,6 @@ import { Mutex, Response, } from "miniflare"; -import { Agent, fetch } from "undici"; import inspectorProxyWorkerPath from "worker:startDevWorker/InspectorProxyWorker"; import proxyWorkerPath from "worker:startDevWorker/ProxyWorker"; import WebSocket from "ws"; @@ -27,7 +26,7 @@ import { import { validateHttpsOptions } from "../../https-options"; import { logger } from "../../logger"; import { getSourceMappedStack } from "../../sourcemap"; -import { isBun } from "../../utils/is-bun"; +import { BUN_UNSUPPORTED_MESSAGE, isBun } from "../../utils/is-bun"; import { Controller } from "./BaseController"; import { castErrorCause } from "./events"; import { createDeferred } from "./utils"; @@ -118,6 +117,12 @@ export class ProxyController extends Controller { }, bindings: { PROXY_CONTROLLER_AUTH_SECRET: this.secret, + // Bun's `fetch()` ignores the undici `dispatcher` that carries + // control messages here, so the ProxyWorker would queue every + // request forever waiting for a `play` message. + ...(isBun() && { + UNSUPPORTED_RUNTIME_MESSAGE: BUN_UNSUPPORTED_MESSAGE, + }), }, // no need to use file-system, so don't @@ -330,19 +335,18 @@ export class ProxyController extends Controller { } runtimeMessageMutex = new Mutex(); - latestReloadCompleteMessage = Promise.resolve(true); async sendMessageToProxyWorker( message: ProxyWorkerIncomingRequestBody, retries = 3 - ): Promise { + ): Promise { if (this._torndown) { - return false; + return; } // Don't do any async work here. Enqueue the message with the mutex immediately. try { - const response = await this.runtimeMessageMutex.runWith(async () => { + await this.runtimeMessageMutex.runWith(async () => { const { proxyWorker } = await this.ready.promise; const ready = await proxyWorker.ready.catch(() => undefined); @@ -358,11 +362,9 @@ export class ProxyController extends Controller { } ); }); - - return response?.status === 204; } catch (cause) { if (this._torndown) { - return false; + return; } const error = castErrorCause(cause); @@ -375,41 +377,6 @@ export class ProxyController extends Controller { `Failed to send message to ProxyWorker: ${JSON.stringify(message)}`, error ); - if (isBun()) { - await this.reportMissingControlPayload(); - } - return false; - } - } - /** - * Under Bun, `dispatchFetch()` cannot reach the ProxyWorker: the URL rewrite - * onto its origin lives in the undici `dispatcher` that Bun's `fetch()` - * ignores, so control requests go to `http://dummy/` and fail. Send one - * without a payload straight to the ProxyWorker's own URL, which Bun can - * reach, so the ProxyWorker answers queued and later requests with a 503 - * naming the cause instead of holding them for a `play` that cannot arrive. - * - * Wrangler bundles undici, so this `fetch` and its `dispatcher` are undici's - * own even under Bun; only Miniflare's runtime `undici` import resolves to - * Bun's dispatcher-less shim. - */ - async reportMissingControlPayload() { - // The local dev certificate is self-signed. - const insecure = new Agent({ connect: { rejectUnauthorized: false } }); - try { - const { url } = await this.ready.promise; - await fetch(new URL("/cdn-cgi/ProxyWorker/play", url), { - method: "POST", - headers: { Authorization: this.secret }, - ...(url.protocol === "https:" ? { dispatcher: insecure } : {}), - }); - } catch (cause) { - logger.debug( - "Could not tell the ProxyWorker that its control payload is missing", - cause - ); - } finally { - await insecure.close(); } } async sendMessageToInspectorProxyWorker( @@ -496,7 +463,7 @@ export class ProxyController extends Controller { this.latestConfig = data.config; this.latestBundle = data.bundle; - this.latestReloadCompleteMessage = this.sendMessageToProxyWorker({ + void this.sendMessageToProxyWorker({ type: "play", proxyData: data.proxyData, }); diff --git a/packages/wrangler/src/api/test-harness-runtime.ts b/packages/wrangler/src/api/test-harness-runtime.ts deleted file mode 100644 index 303cb20a449..00000000000 --- a/packages/wrangler/src/api/test-harness-runtime.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { UserError } from "@cloudflare/workers-utils"; - -// A working dispatcher delivers the ProxyWorker control request in milliseconds. -// Five seconds leaves ample startup headroom while turning Bun's ignored -// `dispatcher` option into an actionable error instead of an indefinite hang. -const BUN_PROXY_MESSAGE_TIMEOUT_MS = 5_000; - -function createBunProxyMessageError(): UserError { - return new UserError( - "`createTestHarness()` cannot dispatch requests to your Worker under Bun, because Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes them through (https://github.com/oven-sh/bun/issues/39247). Without it, a request can reach the network instead of your Worker. `server.getWorker().getEnv()` and `getExport()` still work under Bun; run `fetch()`, `email()` and `scheduled()` dispatches using Node.js.", - { telemetryMessage: "test harness bun proxy request failed" } - ); -} - -/** - * Waits for Bun to deliver the control message that unlocks the test harness - * proxy, before the harness dispatches a request through Miniflare. The message - * travels through the same undici `dispatcher` as every dispatch, so its - * delivery doubles as the capability check. Current Bun releases ignore that - * option and do not deliver it. A future compatible release resolves normally; - * until then, this throws before any request leaves the process. - * - * @param waitForProxyMessages Callback that reports whether the latest proxy - * control message was delivered. - * @param timeoutMs Maximum time to wait when running under Bun. - * @returns A promise that resolves after the proxy control message is delivered. - * @throws {UserError} If Bun does not deliver the proxy control message. - */ -export async function waitForBunProxyMessages( - waitForProxyMessages: () => Promise, - timeoutMs = BUN_PROXY_MESSAGE_TIMEOUT_MS -): Promise { - let timeoutId: ReturnType | undefined; - try { - const delivered = await Promise.race([ - waitForProxyMessages(), - new Promise((_resolve, reject) => { - timeoutId = setTimeout(() => { - reject(createBunProxyMessageError()); - }, timeoutMs); - }), - ]); - if (!delivered) { - throw createBunProxyMessageError(); - } - } finally { - clearTimeout(timeoutId); - } -} diff --git a/packages/wrangler/src/api/test-harness.ts b/packages/wrangler/src/api/test-harness.ts index 49d4cb249ac..876dd840a55 100644 --- a/packages/wrangler/src/api/test-harness.ts +++ b/packages/wrangler/src/api/test-harness.ts @@ -28,11 +28,10 @@ import { validateNodeCompatMode } from "../deployment-bundle/node-compat"; import { getDurableObjectClassNameToUseSQLiteMap } from "../dev/class-names-sqlite"; import { runWithLogLevel } from "../logger"; import { requireApiToken, requireAuth } from "../user"; -import { isBun } from "../utils/is-bun"; +import { BUN_UNSUPPORTED_MESSAGE, isBun } from "../utils/is-bun"; import { DevEnv } from "./startDevWorker/DevEnv"; import { MultiworkerRuntimeController } from "./startDevWorker/MultiworkerRuntimeController"; import { NoOpProxyController } from "./startDevWorker/NoOpProxyController"; -import { waitForBunProxyMessages } from "./test-harness-runtime"; import type { CfAccount } from "../dev/create-worker-preview"; import type { ErrorEvent } from "./startDevWorker/events"; import type { WranglerStartDevWorkerInput } from "./startDevWorker/types"; @@ -979,11 +978,11 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { ) { // Miniflare's `dispatchFetch()` routes every request, `getWorker()` ones // included, through an undici dispatcher that Bun's `fetch()` ignores, so - // a request can leave for the network. Check before sending anything. + // a request can leave for the network. Refuse before sending anything. if (isBun()) { - const session = await resolveSession(); - await waitForBunProxyMessages( - () => session.primaryDevEnv.proxy.latestReloadCompleteMessage + throw new UserError( + `${BUN_UNSUPPORTED_MESSAGE} \`server.getWorker().getEnv()\` and \`getExport()\` still work under Bun.`, + { telemetryMessage: "test harness dispatch under bun" } ); } diff --git a/packages/wrangler/src/utils/is-bun.ts b/packages/wrangler/src/utils/is-bun.ts index c1c08bc6834..a06d90b5eee 100644 --- a/packages/wrangler/src/utils/is-bun.ts +++ b/packages/wrangler/src/utils/is-bun.ts @@ -6,3 +6,10 @@ export function isBun(): boolean { return !!process.versions.bun; } + +/** + * Why requests can't reach the Worker under Bun: Bun's `fetch()` ignores the + * undici `dispatcher` that Miniflare routes requests and control messages through. + */ +export const BUN_UNSUPPORTED_MESSAGE = + "Wrangler can't forward requests to your Worker under Bun, because Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes them through (https://github.com/oven-sh/bun/issues/39247). Run this using Node.js."; diff --git a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts index 38228c84f6d..0943e7014e9 100644 --- a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts +++ b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts @@ -15,6 +15,9 @@ interface Env { PROXY_CONTROLLER: Fetcher; PROXY_CONTROLLER_AUTH_SECRET: string; DURABLE_OBJECT: DurableObjectNamespace; + // Set when the ProxyController can never reach this Worker, so requests + // fail with this message instead of queueing for a `play` that won't arrive. + UNSUPPORTED_RUNTIME_MESSAGE?: string; } // request.cf.hostMetadata is verbose to type using the workers-types Request -- this allows us to have Request correctly typed in this scope @@ -45,14 +48,6 @@ export class ProxyWorker implements DurableObject { requestQueue = new Map>(); requestRetryQueue = new Map>(); - /** - * Set when a ProxyController request arrives without its `cf.hostMetadata` - * payload. Bun's `fetch()` ignores the undici `dispatcher` that Miniflare - * uses to attach that payload, so under Bun no `play` message can ever - * reach this ProxyWorker, and a queued request would wait forever. - */ - controlPayloadMissing = false; - fetch(request: Request) { if (isRequestForLiveReloadWebsocket(request)) { // requests for live-reload websocket @@ -66,8 +61,10 @@ export class ProxyWorker implements DurableObject { return this.processProxyControllerRequest(request); } - if (this.controlPayloadMissing) { - return controlPayloadMissingResponse(); + if (this.env.UNSUPPORTED_RUNTIME_MESSAGE) { + return new Response(this.env.UNSUPPORTED_RUNTIME_MESSAGE, { + status: 503, + }); } // regular requests to be proxied @@ -97,12 +94,10 @@ export class ProxyWorker implements DurableObject { const event = request.cf?.hostMetadata; switch (event?.type) { case "pause": - this.controlPayloadMissing = false; this.proxyData = undefined; break; case "play": - this.controlPayloadMissing = false; this.proxyData = event.proxyData; this.processQueue(); this.state @@ -110,14 +105,6 @@ export class ProxyWorker implements DurableObject { .forEach((ws) => ws.send("reload")); break; - - default: - // Bun currently drops `cf.hostMetadata`, so acknowledge only control - // requests whose payload actually reached the ProxyWorker, and fail - // requests instead of queueing them for a `play` that cannot arrive. - this.controlPayloadMissing = true; - this.failQueuedRequests(); - return new Response(null, { status: 400 }); } return new Response(null, { status: 204 }); @@ -133,38 +120,6 @@ export class ProxyWorker implements DurableObject { yield* this.requestQueue; } - /** - * Answers every queued request with the same 503 that new requests get once - * `controlPayloadMissing` is set. - */ - failQueuedRequests() { - for (const [request, deferredResponse] of this.getOrderedQueue()) { - this.requestRetryQueue.delete(request); - this.requestQueue.delete(request); - deferredResponse.resolve(controlPayloadMissingResponse()); - } - } - - /** - * Puts a request back on the retry queue, unless a control request has - * already arrived without its payload. Retries settle asynchronously, so one - * can land after `failQueuedRequests()` has drained the queues, and no `play` - * would ever release it. - * - * @param request The request to retry. - * @param deferredResponse The response promise its caller is waiting on. - */ - requeueForRetry( - request: Request, - deferredResponse: DeferredPromise - ) { - if (this.controlPayloadMissing) { - deferredResponse.resolve(controlPayloadMissingResponse()); - return; - } - this.requestRetryQueue.set(request, deferredResponse); - } - processQueue() { const { proxyData } = this; // store proxyData at the moment this function was called if (proxyData === undefined) return; @@ -296,7 +251,7 @@ export class ProxyWorker implements DurableObject { // the pre-reload Worker or send a stale preview token. // Requeue instead and let the current proxyData rebuild it. if (this.proxyData !== proxyData) { - this.requeueForRetry(request, deferredResponse); + this.requestRetryQueue.set(request, deferredResponse); this.processQueue(); return; } @@ -347,7 +302,7 @@ export class ProxyWorker implements DurableObject { // if the request can be retried (subset of idempotent requests which have no body), requeue it else if (request.method === "GET" || request.method === "HEAD") { - this.requeueForRetry(request, deferredResponse); + this.requestRetryQueue.set(request, deferredResponse); // we would only end up here if the downstream UserWorker is chang*ing* // i.e. we are in a `pause`d state and expecting a `play` message soon // this request will be processed (retried) when the `play` message arrives @@ -379,19 +334,6 @@ export class ProxyWorker implements DurableObject { } } -/** - * Builds the response for requests that the ProxyWorker cannot forward because - * its control requests arrive without their payload. - * - * @returns A 503 response naming the cause and the ways around it. - */ -function controlPayloadMissingResponse(): Response { - return new Response( - "The Wrangler ProxyWorker received a control request without its payload, so it cannot forward requests to your Worker. This happens when the JavaScript runtime's `fetch()` ignores undici's `dispatcher` option, which Bun does today (https://github.com/oven-sh/bun/issues/39247). Run under Node.js.", - { status: 503 } - ); -} - function isRequestFromProxyController(req: Request, env: Env): boolean { return req.headers.get("Authorization") === env.PROXY_CONTROLLER_AUTH_SECRET; }