From 806d5e040a8a5662812015bf94b95bd5b34fdb05 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 26 Jul 2026 10:48:29 +0900 Subject: [PATCH 1/3] fix(miniflare): disable keep-alive timeout on the loopback server 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 fails inside the Worker with "Network connection lost". Disable the idle timeouts, mirroring the undici dispatch pools in the opposite direction. Fixes #14848 --- .changeset/miniflare-loopback-keepalive.md | 7 +++++++ packages/miniflare/src/index.ts | 9 +++++++++ 2 files changed, 16 insertions(+) create mode 100644 .changeset/miniflare-loopback-keepalive.md diff --git a/.changeset/miniflare-loopback-keepalive.md b/.changeset/miniflare-loopback-keepalive.md new file mode 100644 index 00000000000..eeb7651210f --- /dev/null +++ b/.changeset/miniflare-loopback-keepalive.md @@ -0,0 +1,7 @@ +--- +"miniflare": patch +--- + +Disable keep-alive and headers timeouts 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 timeouts 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 a34e0aae7d9..68d6379eca0 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -1899,6 +1899,15 @@ export class Miniflare { http.createServer(this.#handleLoopback), /* grace */ 0 ); + // Disable timeouts 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.headersTimeout = 0; server.on("upgrade", this.#handleLoopbackUpgrade); server.listen(0, hostname, () => resolve(server)); }); From 795b4185ed025e82ab6dfb1a107f670ccad471cf Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Tue, 28 Jul 2026 08:37:55 +0900 Subject: [PATCH 2/3] fix(miniflare): drop unnecessary headersTimeout override and add regression test headersTimeout only limits how long a client may take to send request headers; it does not apply to idle keep-alive sockets, so it is not needed for this fix. Verified empirically: with keepAliveTimeout = 0 and a short headersTimeout, an idle keep-alive socket survives past the headersTimeout and still serves a second request. The new test talks to the loopback server over a raw keep-alive socket, idles past Node's default keepAliveTimeout of 5 seconds, and asserts a second request still succeeds. Without the fix the socket is closed deterministically at ~5s and the test fails. --- .changeset/miniflare-loopback-keepalive.md | 4 +- packages/miniflare/src/index.ts | 15 +++-- packages/miniflare/test/index.spec.ts | 67 ++++++++++++++++++++++ 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/.changeset/miniflare-loopback-keepalive.md b/.changeset/miniflare-loopback-keepalive.md index eeb7651210f..0aac83f3fa0 100644 --- a/.changeset/miniflare-loopback-keepalive.md +++ b/.changeset/miniflare-loopback-keepalive.md @@ -2,6 +2,6 @@ "miniflare": patch --- -Disable keep-alive and headers timeouts on the loopback server +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 timeouts on the loopback server, mirroring the undici pools used for dispatch in the opposite direction. +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 68d6379eca0..756cc35db8e 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -1899,15 +1899,14 @@ export class Miniflare { http.createServer(this.#handleLoopback), /* grace */ 0 ); - // Disable timeouts 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. + // 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.headersTimeout = 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 db3378243b2..680332b88fd 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -258,6 +258,73 @@ 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, + script: `addEventListener("fetch", (event) => { + event.respondWith(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 { + return new Promise((resolve, reject) => { + const onData = (chunk: Buffer) => { + cleanup(); + resolve(chunk.toString("utf8").split("\r\n")[0]); + }; + const onCloseOrError = (errorOrHadError?: unknown) => { + cleanup(); + reject( + errorOrHadError instanceof Error + ? errorOrHadError + : new Error("Socket closed before response received") + ); + }; + function cleanup() { + socket.off("data", onData); + socket.off("close", onCloseOrError); + socket.off("error", onCloseOrError); + } + socket.on("data", onData); + socket.on("close", onCloseOrError); + socket.on("error", onCloseOrError); + socket.write( + "GET /unknown HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n" + ); + }); + } + + expect(await sendRequest()).toBe("HTTP/1.1 404 Not Found"); + + // Node's default `keepAliveTimeout` is 5 seconds, so without the fix this + // deterministically closes the idle socket after ~5 seconds and the second + // request fails + await new Promise((resolve) => setTimeout(resolve, 6000)); + expect(await sendRequest()).toBe("HTTP/1.1 404 Not Found"); + socket.destroy(); +}); + const interfaces = os.networkInterfaces(); const localInterface = (interfaces["en0"] ?? interfaces["eth0"])?.find( ({ family }) => family === "IPv4" From b5326cb561167de698dac99230697428e6a9b8ef Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Wed, 29 Jul 2026 21:40:09 +0900 Subject: [PATCH 3/3] test(miniflare): use a module worker in the loopback keep-alive regression test --- packages/miniflare/test/index.spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index 680332b88fd..4b2de864a5b 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -271,11 +271,14 @@ test("Miniflare: loopback server keeps idle keep-alive connections open", async const mf = new Miniflare({ port: 0, liveReload: true, - script: `addEventListener("fetch", (event) => { - event.respondWith(new Response("

👋

", { - headers: { "Content-Type": "text/html;charset=utf-8" } - })); - })`, + 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");