diff --git a/.changeset/miniflare-loopback-keepalive.md b/.changeset/miniflare-loopback-keepalive.md new file mode 100644 index 0000000000..0aac83f3fa --- /dev/null +++ b/.changeset/miniflare-loopback-keepalive.md @@ -0,0 +1,7 @@ +--- +"miniflare": patch +--- + +Disable the keep-alive timeout on the loopback server + +The loopback server (which serves custom service bindings, `@cloudflare/vite-plugin`'s module transport, and other workerd → Node callbacks) used Node's default `server.keepAliveTimeout` of 5 seconds. workerd pools and reuses connections to the loopback server, so Node closing an idle pooled socket raced with workerd sending the next request on it, making that request fail with `Network connection lost`. The failure is probabilistic and load-dependent; under `@cloudflare/vite-plugin` with a large SSR module graph and a cold optimizer cache (thousands of `fetchModule` calls with multi-second idle gaps between bursts), it broke most dev sessions. Disable the idle keep-alive timeout on the loopback server, mirroring the undici pools used for dispatch in the opposite direction. diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index a34e0aae7d..756cc35db8 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -1899,6 +1899,14 @@ export class Miniflare { http.createServer(this.#handleLoopback), /* grace */ 0 ); + // Disable the idle keep-alive timeout for local dev — workerd pools + // and reuses connections to the loopback server, and Node's default + // `keepAliveTimeout` (5s) races with that reuse: Node closes an idle + // pooled socket just as workerd sends the next request on it, which + // surfaces in the Worker as "Network connection lost". This mirrors + // the undici pools used for dispatch in the opposite direction, which + // already disable their timeouts. + server.keepAliveTimeout = 0; server.on("upgrade", this.#handleLoopbackUpgrade); server.listen(0, hostname, () => resolve(server)); }); diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index 3de2c426c8..1b71ba832a 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -258,6 +258,76 @@ test("Miniflare: setOptions: can update host/port", async ({ expect }) => { expect(state2.loopbackPort).toBe(state3.loopbackPort); }); +test("Miniflare: loopback server keeps idle keep-alive connections open", async ({ + expect, +}) => { + // Regression test for https://github.com/cloudflare/workers-sdk/issues/14848: + // workerd pools and reuses connections to the loopback server, and Node's + // default `keepAliveTimeout` (5s) closed idle pooled sockets, racing with + // workerd reusing them and failing requests with "Network connection lost". + + // Extract loopback port from injected live reload script + const loopbackPortRegexp = /\/\/ Miniflare Live Reload.+url\.port = (\d+)/s; + const mf = new Miniflare({ + port: 0, + liveReload: true, + modules: true, + script: `export default { + fetch() { + return new Response("
👋
", { + headers: { "Content-Type": "text/html;charset=utf-8" } + }); + } + }`, + }); + useDispose(mf); + const res = await mf.dispatchFetch("http://localhost"); + const loopbackPort = loopbackPortRegexp.exec(await res.text())?.[1]; + assert(loopbackPort !== undefined); + + const socket = net.connect(parseInt(loopbackPort), "127.0.0.1"); + await once(socket, "connect"); + + // The loopback server responds 404 to unknown paths, which is enough to + // exercise keep-alive connection reuse + function sendRequest(): Promise