From cb72c405399e6ac59ec5e66055475c98661d3296 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 4 Aug 2026 21:57:58 +0100 Subject: [PATCH 1/5] [vite-plugin] Don't advertise a dev session's runtime until it is final The plugin starts workerd twice: once to discover each Worker's exports by running it, then again with a config built from what it found. The first runtime was published to the dev registry and then torn down, leaving peers holding a debug port that no longer existed. On Windows a peer with a tail_consumers edge to it aborts its own workerd with std::terminate. Miniflare gains unsafeDeferDevRegistryRegistration to hold back self-advertisement, and unsafeRegisterInDevRegistry() to release it once the runtime is final. Reading the registry is unaffected, so a starting session still resolves Workers from sessions already running. --- .changeset/tidy-donkeys-listen.md | 12 ++ .../dev-registry/tests/dev-registry.test.ts | 41 +++++++ packages/miniflare/src/index.ts | 41 ++++++- packages/miniflare/src/plugins/core/index.ts | 12 ++ packages/miniflare/test/dev-registry.spec.ts | 113 ++++++++++++++++++ .../src/miniflare-options.ts | 7 ++ .../vite-plugin-cloudflare/src/plugins/dev.ts | 11 ++ 7 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 .changeset/tidy-donkeys-listen.md diff --git a/.changeset/tidy-donkeys-listen.md b/.changeset/tidy-donkeys-listen.md new file mode 100644 index 00000000000..f890a19429d --- /dev/null +++ b/.changeset/tidy-donkeys-listen.md @@ -0,0 +1,12 @@ +--- +"@cloudflare/vite-plugin": patch +"miniflare": minor +--- + +Stop a starting `vite dev` session from crashing the runtime of another local dev session + +The Vite plugin brings `workerd` up twice while starting: once to discover each Worker's exports by running it, then again with a config built from what it found. The first runtime was advertised in the dev registry before being torn down, so another dev session that resolved it was left holding a debug port that no longer existed. On Windows that could abort the other session's runtime outright, taking down a Worker that had been running happily. + +Registration is now held back until the runtime that Vite settles on is the one peers will actually connect to. Reading the registry is unaffected, so a starting session still reaches Workers from sessions that are already running. + +This adds two Miniflare APIs for consumers that bring their runtime up in more than one step: the `unsafeDeferDevRegistryRegistration` option, and `unsafeRegisterInDevRegistry()` to release the hold once the final runtime is ready. diff --git a/fixtures/dev-registry/tests/dev-registry.test.ts b/fixtures/dev-registry/tests/dev-registry.test.ts index c60c9e7ac41..f6a784218e5 100644 --- a/fixtures/dev-registry/tests/dev-registry.test.ts +++ b/fixtures/dev-registry/tests/dev-registry.test.ts @@ -808,6 +808,47 @@ describe("Dev Registry: vite dev <-> vite dev", () => { ); }, waitForTimeout); }); + + it("only ever advertises one debug port while starting up", async ({ + expect, + devRegistryPath, + }) => { + // The plugin brings the runtime up twice during startup: once to discover + // each Worker's exports by running it, then again with a config built from + // what it found. The second runtime gets a new debug port, so advertising the + // first one hands peers an address that is about to disappear - and a peer + // holding a dead address can have its own `workerd` aborted. + // + // Sampling the registry throughout startup catches any intermediate address: + // before the fix the first one stayed published for well over a second. + const definitionPath = path.join(devRegistryPath, "worker-entrypoint"); + const advertised = new Set(); + let sampling = true; + const sampler = (async () => { + while (sampling) { + try { + const { debugPortAddress } = JSON.parse( + await fs.readFile(definitionPath, "utf8") + ); + if (typeof debugPortAddress === "string") { + advertised.add(debugPortAddress); + } + } catch { + // Not registered yet, or a partially written file. + } + await new Promise((resolve) => setTimeout(resolve, 15)); + } + })(); + + try { + await runViteDev("vite.worker-entrypoint.config.ts", devRegistryPath); + } finally { + sampling = false; + await sampler; + } + + expect([...advertised]).toHaveLength(1); + }); }); describe("Dev Registry: vite dev <-> wrangler dev", () => { diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 87314a6b275..2cb56ce503a 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -984,6 +984,12 @@ export class Miniflare { readonly #webSocketServer: WebSocketServer; readonly #webSocketExtraHeaders: WeakMap; readonly #devRegistry: DevRegistry; + // Whether `unsafeRegisterInDevRegistry()` has released the hold that + // `unsafeDeferDevRegistryRegistration` puts on advertising our Workers. + // Reset by every `setOptions()` that asks to defer again, so a consumer that + // rebuilds its config (a Vite dev server restart, for instance) gets the same + // protection on each cycle rather than only on the first one. + #devRegistryRegistrationReleased = false; #maybeInspectorProxyController?: InspectorProxyController; #previousRuntimeInspectorPort?: number; @@ -2588,7 +2594,15 @@ export class Miniflare { this.#proxyClient.setRuntimeEntryURL(this.#runtimeEntryURL); } - await this.#registerWorkers(); + // Deferred registration only holds back advertising *our* Workers. The + // registry push below is what lets us reach everyone else's, so it always + // runs. + if ( + !this.#sharedOpts.core.unsafeDeferDevRegistryRegistration || + this.#devRegistryRegistrationReleased + ) { + await this.#registerWorkers(); + } // Catch any registry updates that occurred while workerd was booting. if (this.#devRegistry.isEnabled()) { @@ -2729,6 +2743,24 @@ export class Miniflare { this.#devRegistry.register(Object.fromEntries(entries)); } + /** + * Advertise this instance's Workers in the dev registry, releasing the hold + * put in place by `unsafeDeferDevRegistryRegistration`. + * + * Call this once the runtime is the one peers should actually connect to. It + * is idempotent, and a no-op unless `unsafeDeferDevRegistryRegistration` is + * set. Registration does not restart `workerd`, so this is cheap. + */ + async unsafeRegisterInDevRegistry(): Promise { + this.#checkDisposed(); + await this.ready; + + return this.#runtimeMutex.runWith(async () => { + this.#devRegistryRegistrationReleased = true; + await this.#registerWorkers(); + }); + } + get ready(): Promise { return this.#waitForReady(); } @@ -2825,6 +2857,13 @@ export class Miniflare { this.#log = this.#sharedOpts.core.log ?? this.#log; this.#hyperdriveProxyController.log = this.#log; + // `updateRegistryPath()` below unregisters our Workers, and the runtime is + // about to be replaced, so re-arm the hold and wait to be told that the new + // runtime is the one to advertise. + if (sharedOpts.core.unsafeDeferDevRegistryRegistration) { + this.#devRegistryRegistrationReleased = false; + } + const newExternalOnUpdate = sharedOpts.core.unsafeHandleDevRegistryUpdate; await this.#devRegistry.updateRegistryPath( sharedOpts.core.unsafeDevRegistryPath, diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 53569af0075..95e01fece11 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -281,6 +281,18 @@ export const CoreSharedOptionsSchema = z.object({ // Enable auto service / durable objects discovery with the dev registry unsafeDevRegistryPath: z.string().optional(), + // Don't advertise this instance's Workers in the dev registry until + // `unsafeRegisterInDevRegistry()` is called. + // + // Consumers that bring the runtime up in more than one step (the Vite plugin + // discovers each Worker's exports by running it, then rebuilds the config and + // calls `setOptions()`) would otherwise publish a debug port that is about to + // be torn down. Peers who resolve it in that window hold an address that no + // longer exists, which on Windows can abort their `workerd` outright. + // + // Only self-advertisement is held back; the registry is still read, so + // external services provided by other sessions keep resolving as usual. + unsafeDeferDevRegistryRegistration: z.boolean().optional(), // Called when external workers this instance depends on are updated in the dev registry unsafeHandleDevRegistryUpdate: z .function({ diff --git a/packages/miniflare/test/dev-registry.spec.ts b/packages/miniflare/test/dev-registry.spec.ts index 5f7714e07f6..9323a11e0e7 100644 --- a/packages/miniflare/test/dev-registry.spec.ts +++ b/packages/miniflare/test/dev-registry.spec.ts @@ -1787,4 +1787,117 @@ describe.sequential("DevRegistry", () => { { timeout: 10_000, interval: 100 } ); }); + + describe("unsafeDeferDevRegistryRegistration", () => { + test("withholds advertisement until registration is released, and re-arms on setOptions", async ({ + expect, + }) => { + const unsafeDevRegistryPath = await useTmp(); + const sharedOptions = { + name: "deferred-worker", + unsafeDevRegistryPath, + unsafeDeferDevRegistryRegistration: true, + modules: true, + } satisfies Partial; + const mf = new Miniflare({ + ...sharedOptions, + script: `export default { fetch() { return new Response("one"); } }`, + }); + useDispose(mf); + + await mf.ready; + + // A peer resolving us now would get a debug port we are about to replace. + expect( + getWorkerRegistry(unsafeDevRegistryPath)["deferred-worker"] + ).toBeUndefined(); + + await mf.unsafeRegisterInDevRegistry(); + + const firstEntry = getWorkerRegistry(unsafeDevRegistryPath)[ + "deferred-worker" + ]; + expect(firstEntry).toBeDefined(); + expect(firstEntry.debugPortAddress).toMatch(/^127\.0\.0\.1:\d+$/); + + // A fresh runtime means a fresh debug port, so the hold goes back on + // rather than leaving the previous address advertised. + await mf.setOptions({ + ...sharedOptions, + script: `export default { fetch() { return new Response("two"); } }`, + }); + + expect( + getWorkerRegistry(unsafeDevRegistryPath)["deferred-worker"] + ).toBeUndefined(); + + await mf.unsafeRegisterInDevRegistry(); + + expect( + getWorkerRegistry(unsafeDevRegistryPath)["deferred-worker"] + ).toBeDefined(); + }); + + test("still resolves external services while its own advertisement is held back", async ({ + expect, + }) => { + const unsafeDevRegistryPath = await useTmp(); + const remote = new Miniflare({ + name: "remote-worker", + unsafeDevRegistryPath, + modules: true, + script: `export default { fetch() { return new Response("Hello from remote!"); } }`, + }); + useDispose(remote); + await remote.ready; + + // Deferring only holds back what we publish about ourselves; reading the + // registry has to keep working or startup would not be able to talk to + // sessions that are already running. + const local = new Miniflare({ + name: "local-worker", + unsafeDevRegistryPath, + unsafeDeferDevRegistryRegistration: true, + serviceBindings: { SERVICE: { name: "remote-worker" } }, + modules: true, + script: ` + export default { + fetch(request, env) { + return env.SERVICE.fetch(request); + } + } + `, + }); + useDispose(local); + + await vi.waitFor( + async () => { + const res = await local.dispatchFetch("http://placeholder"); + expect(await res.text()).toBe("Hello from remote!"); + }, + { timeout: 10_000, interval: 100 } + ); + + expect( + getWorkerRegistry(unsafeDevRegistryPath)["local-worker"] + ).toBeUndefined(); + }); + + test("advertises immediately when not deferring", async ({ expect }) => { + const unsafeDevRegistryPath = await useTmp(); + const mf = new Miniflare({ + name: "eager-worker", + unsafeDevRegistryPath, + modules: true, + script: `export default { fetch() { return new Response("ok"); } }`, + }); + useDispose(mf); + + await mf.ready; + + expect( + getWorkerRegistry(unsafeDevRegistryPath)["eager-worker"] + ).toBeDefined(); + }); + }); }); diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index 46ceb863a6f..3e37d9b54d6 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -558,6 +558,13 @@ export async function getDevMiniflareOptions( inspectorPort: inputInspectorPort === false ? undefined : inputInspectorPort, unsafeDevRegistryPath: getDefaultDevRegistryPath(), + // We bring the runtime up in two steps: once to discover each Worker's + // exports by running it, then again with a config built from what we + // found. Advertising the first runtime would publish a debug port we are + // about to tear down, and a peer holding a dead address can have its own + // `workerd` aborted. `configureServer` releases the hold once the runtime + // it leaves behind is the one peers should connect to. + unsafeDeferDevRegistryRegistration: true, unsafeTriggerHandlers: true, unsafeLocalExplorer: getLocalExplorerEnabledFromEnv(), // The switch for local observability capture: tells Miniflare core to diff --git a/packages/vite-plugin-cloudflare/src/plugins/dev.ts b/packages/vite-plugin-cloudflare/src/plugins/dev.ts index a386f517891..0ddded877c3 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/dev.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/dev.ts @@ -311,6 +311,17 @@ export const devPlugin = createPlugin("dev", (ctx) => { } } + // The runtime we are leaving behind is the one peers should connect to, so + // it is now safe to advertise it. Everything above may have replaced the + // runtime (rediscovering Worker exports, rebuilding container images), and + // publishing a debug port before that settles hands peers an address that + // is about to disappear. + // + // This runs for every plugin config type, and whether or not the export + // types turned out to differ, because `unsafeDevRegistryRegistration` is + // deferred unconditionally in `getDevMiniflareOptions`. + await ctx.miniflare.unsafeRegisterInDevRegistry(); + return () => { // In Vite 6, pre-middleware is placed before the host check middleware, // leaving the server vulnerable to DNS rebinding attacks. We move it to From c5edab7aa1b13a49a5d97ebe53780a35cad4da0d Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 4 Aug 2026 21:59:55 +0100 Subject: [PATCH 2/5] TEMP: validation harness for the deferred-registration fix --- .github/workflows/test-and-check.yml | 9 +- .../dev-registry/tests/dev-registry.test.ts | 590 +++++++++--------- 2 files changed, 308 insertions(+), 291 deletions(-) diff --git a/.github/workflows/test-and-check.yml b/.github/workflows/test-and-check.yml index ad538d90fc4..12e621fe2c1 100644 --- a/.github/workflows/test-and-check.yml +++ b/.github/workflows/test-and-check.yml @@ -82,7 +82,7 @@ jobs: run: node packages/wrangler/src/__tests__/test-old-node-version.js error test: - timeout-minutes: 30 + timeout-minutes: 60 concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.os }}-${{ matrix.suite }}-test cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} @@ -187,7 +187,12 @@ jobs: # Since the dev registry is now file-based (not network-based), fixture tests can safely run in parallel. # Concurrency is capped at 2 to avoid CPU starvation on CI runners when multiple fixtures # spawn workerd processes simultaneously (Windows runners are especially slow under load). - run: pnpm run test:ci --concurrency=2 --log-order=stream --filter="./fixtures/*" ${{ matrix.os == 'ubuntu-latest' && '--filter="!./fixtures/browser-run"' || '' }} + shell: bash + run: | + # TEMPORARY VALIDATION HARNESS — do not merge. + # Turbo runs the fixtures suite without --continue, so an unrelated + # fixture flake aborts the job before dev-registry runs. Isolate it. + pnpm run test:ci --force --log-order=stream --filter="@fixture/dev-registry" env: NODE_OPTIONS: "--max_old_space_size=8192" WRANGLER_LOG_PATH: ${{ runner.temp }}/wrangler-debug-logs/ diff --git a/fixtures/dev-registry/tests/dev-registry.test.ts b/fixtures/dev-registry/tests/dev-registry.test.ts index f6a784218e5..367ebdc76ac 100644 --- a/fixtures/dev-registry/tests/dev-registry.test.ts +++ b/fixtures/dev-registry/tests/dev-registry.test.ts @@ -18,10 +18,7 @@ import { } from "../../../packages/vite-plugin-cloudflare/e2e/helpers"; import { runWranglerDev as baseRunWranglerDev } from "../../shared/src/run-wrangler-long-lived"; -// TODO: These tests are consistently failing on Windows in CI and are blocking -// other work. Skipping them there as a temporary measure until the underlying -// issue is fixed. There's still value in running them on macOS and Linux. -const describe = baseDescribe.skipIf(process.platform === "win32"); +const describe = baseDescribe; const waitForTimeout = 20_000; const cwd = resolve(__dirname, ".."); @@ -53,6 +50,15 @@ async function runViteDev( console.log("::endgroup::"); }); + // TEMPORARY VALIDATION — not for merge. A crashed-and-restarted runtime can + // still let the test pass, so report it explicitly rather than only on failure. + onTestFinished(() => { + const output = proc.stdout + proc.stderr; + if (/std::terminate|crashed unexpectedly/.test(output)) { + console.log(`CRASH-DETECTED ${config}`); + } + }); + // Wait for the dev session to be ready await vi.waitFor(async () => { const resposne = await fetch(url, { method: "HEAD" }); @@ -517,339 +523,345 @@ describe("Dev Registry: wrangler dev <-> wrangler dev", () => { }); }); -describe("Dev Registry: vite dev <-> vite dev", () => { - it("supports exported handler fetch over service binding", async ({ - devRegistryPath, - }) => { - const workerEntrypointWithAssets = await runViteDev( - "vite.worker-entrypoint-with-assets.config.ts", - devRegistryPath - ); - await runViteDev("vite.worker-entrypoint.config.ts", devRegistryPath); - - // Test fallback before exported-handler is started - await vi.waitFor(async () => { - const searchParams = new URLSearchParams({ - "test-service": "exported-handler", - "test-method": "fetch", - }); - const response = await fetch( - `${workerEntrypointWithAssets}?${searchParams}` +// TEMPORARY VALIDATION — not for merge. The target test is ~50% flaky on Windows, +// so one CI pass proves nothing. Repeat the vite<->vite suite for a real sample. +for (const validationRound of [1, 2, 3, 4]) { + describe(`Dev Registry: vite dev <-> vite dev [round ${validationRound}]`, () => { + it("supports exported handler fetch over service binding", async ({ + devRegistryPath, + }) => { + const workerEntrypointWithAssets = await runViteDev( + "vite.worker-entrypoint-with-assets.config.ts", + devRegistryPath ); + await runViteDev("vite.worker-entrypoint.config.ts", devRegistryPath); - expect(response.status).toBe(503); - expect(await response.text()).toEqual( - `Worker "exported-handler" not found. Make sure it is running locally.` - ); - }, waitForTimeout); - - const exportedHandler = await runViteDev( - "vite.exported-handler.config.ts", - devRegistryPath - ); - - // Test exported-handler -> worker-entrypoint-with-assets - await vi.waitFor(async () => { - const searchParams = new URLSearchParams({ - "test-service": "worker-entrypoint-with-assets", - "test-method": "fetch", - }); - const response = await fetch(`${exportedHandler}?${searchParams}`); + // Test fallback before exported-handler is started + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "exported-handler", + "test-method": "fetch", + }); + const response = await fetch( + `${workerEntrypointWithAssets}?${searchParams}` + ); - expect(await response.text()).toBe("Hello from Worker Entrypoint!"); - expect(response.status).toBe(200); + expect(response.status).toBe(503); + expect(await response.text()).toEqual( + `Worker "exported-handler" not found. Make sure it is running locally.` + ); + }, waitForTimeout); - // Test fetching asset from "worker-entrypoint-with-assets" over service binding - // Exported handler has no assets, so it will hit the user worker and - // forward the request to "worker-entrypoint-with-assets" with the asset path - const assetResponse = await fetch( - `${exportedHandler}/example.txt?${searchParams}` + const exportedHandler = await runViteDev( + "vite.exported-handler.config.ts", + devRegistryPath ); - expect(await assetResponse.text()).toBe("This is an example asset file"); - }, waitForTimeout); - // Test worker-entrypoint-with-assets -> exported-handler - await vi.waitFor(async () => { - const searchParams = new URLSearchParams({ - "test-service": "exported-handler", - "test-method": "fetch", - }); - const response = await fetch( - `${workerEntrypointWithAssets}?${searchParams}` - ); + // Test exported-handler -> worker-entrypoint-with-assets + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint-with-assets", + "test-method": "fetch", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); - expect(await response.text()).toEqual("Hello from exported handler!"); - expect(response.status).toBe(200); - }, waitForTimeout); + expect(await response.text()).toBe("Hello from Worker Entrypoint!"); + expect(response.status).toBe(200); - // Test exported-handler -> named-entrypoint - await vi.waitFor(async () => { - const searchParams = new URLSearchParams({ - "test-service": "named-entrypoint", - "test-method": "fetch", - }); - const response = await fetch(`${exportedHandler}?${searchParams}`); + // Test fetching asset from "worker-entrypoint-with-assets" over service binding + // Exported handler has no assets, so it will hit the user worker and + // forward the request to "worker-entrypoint-with-assets" with the asset path + const assetResponse = await fetch( + `${exportedHandler}/example.txt?${searchParams}` + ); + expect(await assetResponse.text()).toBe( + "This is an example asset file" + ); + }, waitForTimeout); - expect(await response.text()).toEqual("Hello from Named Entrypoint!"); - expect(response.status).toBe(200); - }, waitForTimeout); + // Test worker-entrypoint-with-assets -> exported-handler + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "exported-handler", + "test-method": "fetch", + }); + const response = await fetch( + `${workerEntrypointWithAssets}?${searchParams}` + ); - // Test exported-handler -> named-entrypoint-with-assets - await vi.waitFor(async () => { - const searchParams = new URLSearchParams({ - "test-service": "named-entrypoint-with-assets", - "test-method": "fetch", - }); - const response = await fetch(`${exportedHandler}?${searchParams}`); + expect(await response.text()).toEqual("Hello from exported handler!"); + expect(response.status).toBe(200); + }, waitForTimeout); - expect(await response.text()).toEqual("Hello from Named Entrypoint!"); - expect(response.status).toBe(200); - }, waitForTimeout); - }); + // Test exported-handler -> named-entrypoint + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint", + "test-method": "fetch", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); - it("supports RPC over service binding", async ({ devRegistryPath }) => { - const exportedHandler = await runViteDev( - "vite.exported-handler.config.ts", - devRegistryPath - ); - await runViteDev("vite.worker-entrypoint.config.ts", devRegistryPath); + expect(await response.text()).toEqual("Hello from Named Entrypoint!"); + expect(response.status).toBe(200); + }, waitForTimeout); - // Test fallback before worker-entrypoint-with-assets is started - await vi.waitFor(async () => { - const searchParams = new URLSearchParams({ - "test-service": "worker-entrypoint-with-assets", - "test-method": "rpc", - }); - const response = await fetch(`${exportedHandler}?${searchParams}`); + // Test exported-handler -> named-entrypoint-with-assets + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint-with-assets", + "test-method": "fetch", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); - expect(response.status).toBe(500); - expect(await response.text()).toEqual( - `Worker "worker-entrypoint-with-assets" not found. Make sure it is running locally.` - ); - }, waitForTimeout); + expect(await response.text()).toEqual("Hello from Named Entrypoint!"); + expect(response.status).toBe(200); + }, waitForTimeout); + }); - await runViteDev( - "vite.worker-entrypoint-with-assets.config.ts", - devRegistryPath - ); + it("supports RPC over service binding", async ({ devRegistryPath }) => { + const exportedHandler = await runViteDev( + "vite.exported-handler.config.ts", + devRegistryPath + ); + await runViteDev("vite.worker-entrypoint.config.ts", devRegistryPath); - // Test exported-handler -> worker-entrypoint RPC - await vi.waitFor(async () => { - const searchParams = new URLSearchParams({ - "test-service": "worker-entrypoint", - "test-method": "rpc", - }); - const response = await fetch(`${exportedHandler}?${searchParams}`); + // Test fallback before worker-entrypoint-with-assets is started + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint-with-assets", + "test-method": "rpc", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); - expect(response.status).toBe(200); - expect(await response.text()).toEqual("Pong"); - }, waitForTimeout); + expect(response.status).toBe(500); + expect(await response.text()).toEqual( + `Worker "worker-entrypoint-with-assets" not found. Make sure it is running locally.` + ); + }, waitForTimeout); - // Test exported-handler -> worker-entrypoint-with-assets RPC - await vi.waitFor(async () => { - const searchParams = new URLSearchParams({ - "test-service": "worker-entrypoint-with-assets", - "test-method": "rpc", - }); - const response = await fetch(`${exportedHandler}?${searchParams}`); + await runViteDev( + "vite.worker-entrypoint-with-assets.config.ts", + devRegistryPath + ); - expect(response.status).toBe(200); - expect(await response.text()).toEqual("Pong"); - }, waitForTimeout); + // Test exported-handler -> worker-entrypoint RPC + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint", + "test-method": "rpc", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); - // Test exported-handler -> named-entrypoint RPC - await vi.waitFor(async () => { - const searchParams = new URLSearchParams({ - "test-service": "named-entrypoint", - "test-method": "rpc", - }); - const response = await fetch(`${exportedHandler}?${searchParams}`); + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Pong"); + }, waitForTimeout); - expect(response.status).toBe(200); - expect(await response.text()).toEqual("Pong from Named Entrypoint"); - }, waitForTimeout); + // Test exported-handler -> worker-entrypoint-with-assets RPC + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint-with-assets", + "test-method": "rpc", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); - // Test exported-handler -> named-entrypoint-with-assets RPC - await vi.waitFor(async () => { - const searchParams = new URLSearchParams({ - "test-service": "named-entrypoint-with-assets", - "test-method": "rpc", - }); - const response = await fetch(`${exportedHandler}?${searchParams}`); + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Pong"); + }, waitForTimeout); - expect(response.status).toBe(200); - expect(await response.text()).toEqual("Pong from Named Entrypoint"); - }, waitForTimeout); - }); + // Test exported-handler -> named-entrypoint RPC + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint", + "test-method": "rpc", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); - it("supports WebSocket upgrade over service binding", async ({ - devRegistryPath, - }) => { - const exportedHandler = await runViteDev( - "vite.exported-handler.config.ts", - devRegistryPath - ); - await runViteDev("vite.worker-entrypoint.config.ts", devRegistryPath); + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Pong from Named Entrypoint"); + }, waitForTimeout); - // Test exported-handler -> worker-entrypoint WebSocket proxy - await vi.waitFor(async () => { - const searchParams = new URLSearchParams({ - "test-service": "worker-entrypoint", - "test-method": "websocket-proxy", - }); - const wsUrl = `${exportedHandler.replace("http", "ws")}?${searchParams}`; - const ws = new WebSocket(wsUrl); - - const message = await new Promise((resolve, reject) => { - ws.addEventListener("open", () => ws.send("hello")); - ws.addEventListener("message", (event) => { - resolve(String(event.data)); - ws.close(); + // Test exported-handler -> named-entrypoint-with-assets RPC + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint-with-assets", + "test-method": "rpc", }); - ws.addEventListener("error", () => - reject(new Error("WebSocket connection failed")) - ); - }); + const response = await fetch(`${exportedHandler}?${searchParams}`); - expect(message).toBe("echo:hello"); - }, waitForTimeout); - }); + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Pong from Named Entrypoint"); + }, waitForTimeout); + }); - it("supports tail handler", async ({ devRegistryPath }) => { - const exportedHandler = await runViteDev( - "vite.exported-handler.config.ts", - devRegistryPath - ); - const workerEntrypointWithAssets = await runViteDev( - "vite.worker-entrypoint-with-assets.config.ts", - devRegistryPath - ); + it("supports WebSocket upgrade over service binding", async ({ + devRegistryPath, + }) => { + const exportedHandler = await runViteDev( + "vite.exported-handler.config.ts", + devRegistryPath + ); + await runViteDev("vite.worker-entrypoint.config.ts", devRegistryPath); - const searchParams = new URLSearchParams({ - "test-method": "tail", - }); + // Test exported-handler -> worker-entrypoint WebSocket proxy + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint", + "test-method": "websocket-proxy", + }); + const wsUrl = `${exportedHandler.replace("http", "ws")}?${searchParams}`; + const ws = new WebSocket(wsUrl); + + const message = await new Promise((resolve, reject) => { + ws.addEventListener("open", () => ws.send("hello")); + ws.addEventListener("message", (event) => { + resolve(String(event.data)); + ws.close(); + }); + ws.addEventListener("error", () => + reject(new Error("WebSocket connection failed")) + ); + }); - await vi.waitFor(async () => { - // Trigger tail handler of worker-entrypoint via exported-handler - await fetch(`${exportedHandler}?${searchParams}`, { - method: "POST", - body: JSON.stringify(["hello world", "this is the 2nd log"]), - }); - await fetch(`${exportedHandler}?${searchParams}`, { - method: "POST", - body: JSON.stringify(["some other log"]), - }); + expect(message).toBe("echo:hello"); + }, waitForTimeout); + }); - const response = await fetch( - `${workerEntrypointWithAssets}?${searchParams}` + it("supports tail handler", async ({ devRegistryPath }) => { + const exportedHandler = await runViteDev( + "vite.exported-handler.config.ts", + devRegistryPath + ); + const workerEntrypointWithAssets = await runViteDev( + "vite.worker-entrypoint-with-assets.config.ts", + devRegistryPath ); - expect(await response.json()).toEqual({ - worker: "Worker Entrypoint", - tailEvents: expect.arrayContaining([ - [["[exported-handler]"], ["hello world", "this is the 2nd log"]], - [["[exported-handler]"], ["some other log"]], - ]), + const searchParams = new URLSearchParams({ + "test-method": "tail", }); - }, waitForTimeout); - await vi.waitFor(async () => { - // Trigger tail handler of exported-handler via worker-entrypoint - await fetch(`${workerEntrypointWithAssets}?${searchParams}`, { - method: "POST", - body: JSON.stringify(["hello from test"]), - }); - await fetch(`${workerEntrypointWithAssets}?${searchParams}`, { - method: "POST", - body: JSON.stringify(["yet another log", "and another one"]), - }); + await vi.waitFor(async () => { + // Trigger tail handler of worker-entrypoint via exported-handler + await fetch(`${exportedHandler}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["hello world", "this is the 2nd log"]), + }); + await fetch(`${exportedHandler}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["some other log"]), + }); - const response = await fetch(`${exportedHandler}?${searchParams}`); + const response = await fetch( + `${workerEntrypointWithAssets}?${searchParams}` + ); - expect(await response.json()).toEqual({ - worker: "exported-handler", - tailEvents: expect.arrayContaining([ - [["[Worker Entrypoint]"], ["hello from test"]], - [["[Worker Entrypoint]"], ["yet another log", "and another one"]], - ]), - }); - }, waitForTimeout); - }); + expect(await response.json()).toEqual({ + worker: "Worker Entrypoint", + tailEvents: expect.arrayContaining([ + [["[exported-handler]"], ["hello world", "this is the 2nd log"]], + [["[exported-handler]"], ["some other log"]], + ]), + }); + }, waitForTimeout); - it("supports queues across dev sessions", async ({ devRegistryPath }) => { - const exportedHandler = await runViteDev( - "vite.exported-handler.config.ts", - devRegistryPath - ); - const workerEntrypoint = await runViteDev( - "vite.worker-entrypoint.config.ts", - devRegistryPath - ); + await vi.waitFor(async () => { + // Trigger tail handler of exported-handler via worker-entrypoint + await fetch(`${workerEntrypointWithAssets}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["hello from test"]), + }); + await fetch(`${workerEntrypointWithAssets}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["yet another log", "and another one"]), + }); - await vi.waitFor(async () => { - const sendParams = new URLSearchParams({ - "test-method": "queue-send", - }); - const sendResponse = await fetch(`${exportedHandler}?${sendParams}`, { - method: "POST", - body: "hello from vite producer", - }); - expect(await sendResponse.text()).toBe("Queued"); - expect(sendResponse.status).toBe(200); + const response = await fetch(`${exportedHandler}?${searchParams}`); - const receivedParams = new URLSearchParams({ - "test-method": "queue-received", - }); - const receivedResponse = await fetch( - `${workerEntrypoint}?${receivedParams}` + expect(await response.json()).toEqual({ + worker: "exported-handler", + tailEvents: expect.arrayContaining([ + [["[Worker Entrypoint]"], ["hello from test"]], + [["[Worker Entrypoint]"], ["yet another log", "and another one"]], + ]), + }); + }, waitForTimeout); + }); + + it("supports queues across dev sessions", async ({ devRegistryPath }) => { + const exportedHandler = await runViteDev( + "vite.exported-handler.config.ts", + devRegistryPath ); - expect(await receivedResponse.json()).toContain( - "hello from vite producer" + const workerEntrypoint = await runViteDev( + "vite.worker-entrypoint.config.ts", + devRegistryPath ); - }, waitForTimeout); - }); - it("only ever advertises one debug port while starting up", async ({ - expect, - devRegistryPath, - }) => { - // The plugin brings the runtime up twice during startup: once to discover - // each Worker's exports by running it, then again with a config built from - // what it found. The second runtime gets a new debug port, so advertising the - // first one hands peers an address that is about to disappear - and a peer - // holding a dead address can have its own `workerd` aborted. - // - // Sampling the registry throughout startup catches any intermediate address: - // before the fix the first one stayed published for well over a second. - const definitionPath = path.join(devRegistryPath, "worker-entrypoint"); - const advertised = new Set(); - let sampling = true; - const sampler = (async () => { - while (sampling) { - try { - const { debugPortAddress } = JSON.parse( - await fs.readFile(definitionPath, "utf8") - ); - if (typeof debugPortAddress === "string") { - advertised.add(debugPortAddress); + await vi.waitFor(async () => { + const sendParams = new URLSearchParams({ + "test-method": "queue-send", + }); + const sendResponse = await fetch(`${exportedHandler}?${sendParams}`, { + method: "POST", + body: "hello from vite producer", + }); + expect(await sendResponse.text()).toBe("Queued"); + expect(sendResponse.status).toBe(200); + + const receivedParams = new URLSearchParams({ + "test-method": "queue-received", + }); + const receivedResponse = await fetch( + `${workerEntrypoint}?${receivedParams}` + ); + expect(await receivedResponse.json()).toContain( + "hello from vite producer" + ); + }, waitForTimeout); + }); + + it("only ever advertises one debug port while starting up", async ({ + expect, + devRegistryPath, + }) => { + // The plugin brings the runtime up twice during startup: once to discover + // each Worker's exports by running it, then again with a config built from + // what it found. The second runtime gets a new debug port, so advertising the + // first one hands peers an address that is about to disappear - and a peer + // holding a dead address can have its own `workerd` aborted. + // + // Sampling the registry throughout startup catches any intermediate address: + // before the fix the first one stayed published for well over a second. + const definitionPath = path.join(devRegistryPath, "worker-entrypoint"); + const advertised = new Set(); + let sampling = true; + const sampler = (async () => { + while (sampling) { + try { + const { debugPortAddress } = JSON.parse( + await fs.readFile(definitionPath, "utf8") + ); + if (typeof debugPortAddress === "string") { + advertised.add(debugPortAddress); + } + } catch { + // Not registered yet, or a partially written file. } - } catch { - // Not registered yet, or a partially written file. + await new Promise((resolve) => setTimeout(resolve, 15)); } - await new Promise((resolve) => setTimeout(resolve, 15)); - } - })(); + })(); - try { - await runViteDev("vite.worker-entrypoint.config.ts", devRegistryPath); - } finally { - sampling = false; - await sampler; - } + try { + await runViteDev("vite.worker-entrypoint.config.ts", devRegistryPath); + } finally { + sampling = false; + await sampler; + } - expect([...advertised]).toHaveLength(1); + expect([...advertised]).toHaveLength(1); + }); }); -}); +} describe("Dev Registry: vite dev <-> wrangler dev", () => { it("uses the same dev registry path by default", async () => { From 1cf161668055aa20656d7c1f8601632d1e375b09 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Wed, 5 Aug 2026 09:14:33 +0100 Subject: [PATCH 3/5] TEMP: breadcrumbs through the workerd crash-recovery path --- packages/miniflare/src/index.ts | 13 +++++++++++++ .../vite-plugin-cloudflare/src/miniflare-options.ts | 2 ++ packages/vite-plugin-cloudflare/src/plugins/dev.ts | 2 ++ 3 files changed, 17 insertions(+) diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 2cb56ce503a..e69ad44f1fb 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -2360,12 +2360,16 @@ export class Miniflare { `The Workers runtime crashed unexpectedly and is being restarted (crash #${this.#workerdCrashCount}). ` + "Any additional runtime output above may indicate the cause." ); + this.#log.warn("CRASH-STEP 1 handler entered"); // A crash destroys the proxy server heap just like a config update. this.#proxyClient?.poisonProxies(); + this.#log.warn("CRASH-STEP 2 proxies poisoned, acquiring mutex"); void this.#runtimeMutex .runWith(async () => { + this.#log.warn("CRASH-STEP 3 mutex acquired, reassembling config"); try { await this.#assembleAndUpdateConfig(true); + this.#log.warn("CRASH-STEP 4 config reassembled"); } catch (error) { const cause = error instanceof Error ? error : new Error(String(error)); @@ -2383,7 +2387,9 @@ export class Miniflare { return; } try { + this.#log.warn("CRASH-STEP 5 invoking restart callback"); await this.#sharedOpts.core.unsafeHandleRuntimeRestart?.(); + this.#log.warn("CRASH-STEP 6 restart callback returned"); } catch (error) { const cause = error instanceof Error ? error : new Error(String(error)); @@ -2752,12 +2758,19 @@ export class Miniflare { * set. Registration does not restart `workerd`, so this is cheap. */ async unsafeRegisterInDevRegistry(): Promise { + this.#log.warn("REG-STEP 1 entered"); this.#checkDisposed(); + this.#log.warn( + `REG-STEP 2 awaiting ready (mutexHasWaiting=${this.#runtimeMutex.hasWaiting})` + ); await this.ready; + this.#log.warn("REG-STEP 3 ready resolved, acquiring mutex"); return this.#runtimeMutex.runWith(async () => { + this.#log.warn("REG-STEP 4 mutex acquired, registering"); this.#devRegistryRegistrationReleased = true; await this.#registerWorkers(); + this.#log.warn("REG-STEP 5 registered"); }); } diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index 3e37d9b54d6..683e4a03a2e 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -584,7 +584,9 @@ export async function getDevMiniflareOptions( debuglog( "workerd restarted after a crash; restarting the Vite dev server" ); + viteDevServer.config.logger.warn("VITE-STEP 1 calling viteDevServer.restart()"); await viteDevServer.restart(); + viteDevServer.config.logger.warn("VITE-STEP 2 viteDevServer.restart() returned"); }, resourcePersistencePath: getPersistenceRoot( resolvedViteConfig.root, diff --git a/packages/vite-plugin-cloudflare/src/plugins/dev.ts b/packages/vite-plugin-cloudflare/src/plugins/dev.ts index 0ddded877c3..ad8a77623d6 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/dev.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/dev.ts @@ -320,7 +320,9 @@ export const devPlugin = createPlugin("dev", (ctx) => { // This runs for every plugin config type, and whether or not the export // types turned out to differ, because `unsafeDevRegistryRegistration` is // deferred unconditionally in `getDevMiniflareOptions`. + viteDevServer.config.logger.warn("DEV-STEP 1 releasing dev registry registration"); await ctx.miniflare.unsafeRegisterInDevRegistry(); + viteDevServer.config.logger.warn("DEV-STEP 2 dev registry registration released"); return () => { // In Vite 6, pre-middleware is placed before the host check middleware, From c5623c107c6825ce0da72b2999bbf532d8f5d25e Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Wed, 5 Aug 2026 09:51:38 +0100 Subject: [PATCH 4/5] TEMP: breadcrumbs inside assembleAndUpdateConfig + control arm --- fixtures/dev-registry/tests/dev-registry.test.ts | 12 +++++++++++- packages/miniflare/src/index.ts | 8 ++++++++ .../src/miniflare-options.ts | 5 ++++- packages/vite-plugin-cloudflare/src/plugins/dev.ts | 14 +++++++++++--- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/fixtures/dev-registry/tests/dev-registry.test.ts b/fixtures/dev-registry/tests/dev-registry.test.ts index 367ebdc76ac..be44dc4d1a5 100644 --- a/fixtures/dev-registry/tests/dev-registry.test.ts +++ b/fixtures/dev-registry/tests/dev-registry.test.ts @@ -5,6 +5,7 @@ import { resolve } from "node:path"; /* eslint-disable workers-sdk/no-vitest-import-expect -- uses expect in module-scope helper functions */ import { describe as baseDescribe, + beforeEach, expect, onTestFailed, onTestFinished, @@ -34,12 +35,17 @@ const it = test.extend<{ }, }); +// TEMPORARY VALIDATION — not for merge. Set per round so a single CI run +// compares the deferred-registration arm against the pre-fix control arm. +let deferDevRegistryArm = "1"; + async function runViteDev( config: string, devRegistryPath?: string ): Promise { const proc = await runLongLived("pnpm", `vite --config ${config}`, cwd, { MINIFLARE_REGISTRY_PATH: devRegistryPath, + DEFER_DEV_REGISTRY: deferDevRegistryArm, }); const url = await waitForReady(proc); @@ -526,7 +532,11 @@ describe("Dev Registry: wrangler dev <-> wrangler dev", () => { // TEMPORARY VALIDATION — not for merge. The target test is ~50% flaky on Windows, // so one CI pass proves nothing. Repeat the vite<->vite suite for a real sample. for (const validationRound of [1, 2, 3, 4]) { - describe(`Dev Registry: vite dev <-> vite dev [round ${validationRound}]`, () => { + const arm = validationRound % 2 === 1 ? "1" : "0"; + describe(`Dev Registry: vite dev <-> vite dev [round ${validationRound}] [defer=${arm}]`, () => { + beforeEach(() => { + deferDevRegistryArm = arm; + }); it("supports exported handler fetch over service binding", async ({ devRegistryPath, }) => { diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index e69ad44f1fb..2c589f6fb4c 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -2413,7 +2413,9 @@ export class Miniflare { } async #assembleAndUpdateConfig(reusePorts = false) { + this.#log.warn(`ASM-STEP 1 entered (reusePorts=${reusePorts})`); await this.#closeBrowserProcesses(); + this.#log.warn("ASM-STEP 2 browsers closed"); // This function must be run with `#runtimeMutex` held const initial = !this.#runtimeEntryURL; @@ -2428,12 +2430,14 @@ export class Miniflare { maybeGetLocallyAccessibleHost(configuredHost) ?? getURLSafeHost(configuredHost); const loopbackPort = await this.#getLoopbackPort(); + this.#log.warn(`ASM-STEP 3 loopback port ${loopbackPort}`); const config = await this.#assembleConfig( loopbackHost, loopbackPort, this.#devRegistry.isEnabled(), reusePorts ); + this.#log.warn("ASM-STEP 4 config assembled"); const configBuffer = serializeConfig(config); // Get all socket names we expect to get ports for @@ -2501,12 +2505,16 @@ export class Miniflare { onWorkerdCrashRestart: () => this.#handleWorkerdCrash(), runtimeEnv: this.#sharedOpts.core.unsafeRuntimeEnv, }; + this.#log.warn( + `ASM-STEP 5 updating runtime (entry=${entryAddress} inspector=${runtimeInspectorAddress} sockets=${requiredSockets.join(",")})` + ); const maybeSocketPorts = await this.#runtime.updateConfig( configBuffer, runtimeOpts, this.#workerOpts.flatMap((w) => w.core.name ?? []), this.#disposeController.signal ); + this.#log.warn("ASM-STEP 6 runtime updated"); if (this.#disposeController.signal.aborted) return; if (maybeSocketPorts === undefined) { throw new MiniflareCoreError( diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index 683e4a03a2e..a593f440446 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -564,7 +564,10 @@ export async function getDevMiniflareOptions( // about to tear down, and a peer holding a dead address can have its own // `workerd` aborted. `configureServer` releases the hold once the runtime // it leaves behind is the one peers should connect to. - unsafeDeferDevRegistryRegistration: true, + // TEMP(validation): control arm. `DEFER_DEV_REGISTRY=0` reproduces the + // pre-fix behaviour so one CI round compares both arms. + unsafeDeferDevRegistryRegistration: + process.env.DEFER_DEV_REGISTRY !== "0", unsafeTriggerHandlers: true, unsafeLocalExplorer: getLocalExplorerEnabledFromEnv(), // The switch for local observability capture: tells Miniflare core to diff --git a/packages/vite-plugin-cloudflare/src/plugins/dev.ts b/packages/vite-plugin-cloudflare/src/plugins/dev.ts index ad8a77623d6..5d62bb42994 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/dev.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/dev.ts @@ -320,9 +320,17 @@ export const devPlugin = createPlugin("dev", (ctx) => { // This runs for every plugin config type, and whether or not the export // types turned out to differ, because `unsafeDevRegistryRegistration` is // deferred unconditionally in `getDevMiniflareOptions`. - viteDevServer.config.logger.warn("DEV-STEP 1 releasing dev registry registration"); - await ctx.miniflare.unsafeRegisterInDevRegistry(); - viteDevServer.config.logger.warn("DEV-STEP 2 dev registry registration released"); + if (process.env.DEFER_DEV_REGISTRY !== "0") { + viteDevServer.config.logger.warn( + "DEV-STEP 1 releasing dev registry registration" + ); + await ctx.miniflare.unsafeRegisterInDevRegistry(); + viteDevServer.config.logger.warn( + "DEV-STEP 2 dev registry registration released" + ); + } else { + viteDevServer.config.logger.warn("DEV-STEP 0 control arm, not deferring"); + } return () => { // In Vite 6, pre-middleware is placed before the host check middleware, From 2574eb4dd57ee220762e1809174fddac83e1599e Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Wed, 5 Aug 2026 12:28:39 +0100 Subject: [PATCH 5/5] TEMP: always-dump harness, timestamped crumbs, registry-push instrumentation --- .../dev-registry/tests/dev-registry.test.ts | 9 +++- packages/miniflare/src/index.ts | 44 ++++++++++++------- .../src/miniflare-options.ts | 4 +- .../vite-plugin-cloudflare/src/plugins/dev.ts | 10 ++--- 4 files changed, 39 insertions(+), 28 deletions(-) diff --git a/fixtures/dev-registry/tests/dev-registry.test.ts b/fixtures/dev-registry/tests/dev-registry.test.ts index be44dc4d1a5..a85d1f36737 100644 --- a/fixtures/dev-registry/tests/dev-registry.test.ts +++ b/fixtures/dev-registry/tests/dev-registry.test.ts @@ -49,8 +49,13 @@ async function runViteDev( }); const url = await waitForReady(proc); - onTestFailed(() => { - console.log(`::group::Vite dev session (${config})`); + // TEMPORARY VALIDATION — not for merge. Dump on *finish*, not only on + // failure: dumping only failed tests biases the breadcrumb record, and a + // buffer that ends mid-recovery is then indistinguishable from a real stall. + onTestFinished(() => { + console.log( + `::group::Vite dev session (${config}) captured-at ${new Date().toISOString()}` + ); console.log(proc.stdout); console.log(proc.stderr); console.log("::endgroup::"); diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 2c589f6fb4c..65a174e2ada 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -1178,6 +1178,9 @@ export class Miniflare { // call-time: if the registry changes between the initial call and a retry, // the retry should push the most-recent state, not a stale one. const registry = this.#devRegistry.getRegistry(); + this.#crumb( + `PUSH-STEP 1 pushing registry: ${Object.keys(registry ?? {}).join(",") || "(empty)"}` + ); try { const response = await this.#devRegistryDispatcher.request({ @@ -1190,6 +1193,7 @@ export class Miniflare { }); // Drain the response body to release the connection back to the pool await response.body.dump(); + this.#crumb(`PUSH-STEP 2 pushed (status ${response.statusCode})`); if (response.statusCode < 200 || response.statusCode >= 300) { this.#log.debug(`Registry push failed with ${response.statusCode}`); if (retries > 0) { @@ -2351,6 +2355,12 @@ export class Miniflare { }; } + // TEMP(validation): timestamped breadcrumb so a stalled step is + // distinguishable from a session buffer truncated at teardown. + #crumb(message: string): void { + this.#log.warn(`[${new Date().toISOString()}] ${message}`); + } + #handleWorkerdCrash(): void { this.#workerdCrashCount++; // Recovery used to be entirely silent, which made a crash look like an @@ -2360,16 +2370,16 @@ export class Miniflare { `The Workers runtime crashed unexpectedly and is being restarted (crash #${this.#workerdCrashCount}). ` + "Any additional runtime output above may indicate the cause." ); - this.#log.warn("CRASH-STEP 1 handler entered"); + this.#crumb("CRASH-STEP 1 handler entered"); // A crash destroys the proxy server heap just like a config update. this.#proxyClient?.poisonProxies(); - this.#log.warn("CRASH-STEP 2 proxies poisoned, acquiring mutex"); + this.#crumb("CRASH-STEP 2 proxies poisoned, acquiring mutex"); void this.#runtimeMutex .runWith(async () => { - this.#log.warn("CRASH-STEP 3 mutex acquired, reassembling config"); + this.#crumb("CRASH-STEP 3 mutex acquired, reassembling config"); try { await this.#assembleAndUpdateConfig(true); - this.#log.warn("CRASH-STEP 4 config reassembled"); + this.#crumb("CRASH-STEP 4 config reassembled"); } catch (error) { const cause = error instanceof Error ? error : new Error(String(error)); @@ -2387,9 +2397,9 @@ export class Miniflare { return; } try { - this.#log.warn("CRASH-STEP 5 invoking restart callback"); + this.#crumb("CRASH-STEP 5 invoking restart callback"); await this.#sharedOpts.core.unsafeHandleRuntimeRestart?.(); - this.#log.warn("CRASH-STEP 6 restart callback returned"); + this.#crumb("CRASH-STEP 6 restart callback returned"); } catch (error) { const cause = error instanceof Error ? error : new Error(String(error)); @@ -2413,9 +2423,9 @@ export class Miniflare { } async #assembleAndUpdateConfig(reusePorts = false) { - this.#log.warn(`ASM-STEP 1 entered (reusePorts=${reusePorts})`); + this.#crumb(`ASM-STEP 1 entered (reusePorts=${reusePorts})`); await this.#closeBrowserProcesses(); - this.#log.warn("ASM-STEP 2 browsers closed"); + this.#crumb("ASM-STEP 2 browsers closed"); // This function must be run with `#runtimeMutex` held const initial = !this.#runtimeEntryURL; @@ -2430,14 +2440,14 @@ export class Miniflare { maybeGetLocallyAccessibleHost(configuredHost) ?? getURLSafeHost(configuredHost); const loopbackPort = await this.#getLoopbackPort(); - this.#log.warn(`ASM-STEP 3 loopback port ${loopbackPort}`); + this.#crumb(`ASM-STEP 3 loopback port ${loopbackPort}`); const config = await this.#assembleConfig( loopbackHost, loopbackPort, this.#devRegistry.isEnabled(), reusePorts ); - this.#log.warn("ASM-STEP 4 config assembled"); + this.#crumb("ASM-STEP 4 config assembled"); const configBuffer = serializeConfig(config); // Get all socket names we expect to get ports for @@ -2505,7 +2515,7 @@ export class Miniflare { onWorkerdCrashRestart: () => this.#handleWorkerdCrash(), runtimeEnv: this.#sharedOpts.core.unsafeRuntimeEnv, }; - this.#log.warn( + this.#crumb( `ASM-STEP 5 updating runtime (entry=${entryAddress} inspector=${runtimeInspectorAddress} sockets=${requiredSockets.join(",")})` ); const maybeSocketPorts = await this.#runtime.updateConfig( @@ -2514,7 +2524,7 @@ export class Miniflare { this.#workerOpts.flatMap((w) => w.core.name ?? []), this.#disposeController.signal ); - this.#log.warn("ASM-STEP 6 runtime updated"); + this.#crumb("ASM-STEP 6 runtime updated"); if (this.#disposeController.signal.aborted) return; if (maybeSocketPorts === undefined) { throw new MiniflareCoreError( @@ -2766,19 +2776,19 @@ export class Miniflare { * set. Registration does not restart `workerd`, so this is cheap. */ async unsafeRegisterInDevRegistry(): Promise { - this.#log.warn("REG-STEP 1 entered"); + this.#crumb("REG-STEP 1 entered"); this.#checkDisposed(); - this.#log.warn( + this.#crumb( `REG-STEP 2 awaiting ready (mutexHasWaiting=${this.#runtimeMutex.hasWaiting})` ); await this.ready; - this.#log.warn("REG-STEP 3 ready resolved, acquiring mutex"); + this.#crumb("REG-STEP 3 ready resolved, acquiring mutex"); return this.#runtimeMutex.runWith(async () => { - this.#log.warn("REG-STEP 4 mutex acquired, registering"); + this.#crumb("REG-STEP 4 mutex acquired, registering"); this.#devRegistryRegistrationReleased = true; await this.#registerWorkers(); - this.#log.warn("REG-STEP 5 registered"); + this.#crumb("REG-STEP 5 registered"); }); } diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index a593f440446..2dace9d6968 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -587,9 +587,9 @@ export async function getDevMiniflareOptions( debuglog( "workerd restarted after a crash; restarting the Vite dev server" ); - viteDevServer.config.logger.warn("VITE-STEP 1 calling viteDevServer.restart()"); + viteDevServer.config.logger.warn(`[${new Date().toISOString()}] VITE-STEP 1 calling viteDevServer.restart()`); await viteDevServer.restart(); - viteDevServer.config.logger.warn("VITE-STEP 2 viteDevServer.restart() returned"); + viteDevServer.config.logger.warn(`[${new Date().toISOString()}] VITE-STEP 2 viteDevServer.restart() returned`); }, resourcePersistencePath: getPersistenceRoot( resolvedViteConfig.root, diff --git a/packages/vite-plugin-cloudflare/src/plugins/dev.ts b/packages/vite-plugin-cloudflare/src/plugins/dev.ts index 5d62bb42994..f331fac8d89 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/dev.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/dev.ts @@ -321,15 +321,11 @@ export const devPlugin = createPlugin("dev", (ctx) => { // types turned out to differ, because `unsafeDevRegistryRegistration` is // deferred unconditionally in `getDevMiniflareOptions`. if (process.env.DEFER_DEV_REGISTRY !== "0") { - viteDevServer.config.logger.warn( - "DEV-STEP 1 releasing dev registry registration" - ); + viteDevServer.config.logger.warn(`[${new Date().toISOString()}] DEV-STEP 1 releasing dev registry registration`); await ctx.miniflare.unsafeRegisterInDevRegistry(); - viteDevServer.config.logger.warn( - "DEV-STEP 2 dev registry registration released" - ); + viteDevServer.config.logger.warn(`[${new Date().toISOString()}] DEV-STEP 2 dev registry registration released`); } else { - viteDevServer.config.logger.warn("DEV-STEP 0 control arm, not deferring"); + viteDevServer.config.logger.warn(`[${new Date().toISOString()}] DEV-STEP 0 control arm, not deferring`); } return () => {