Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/miniflare-loopback-keepalive.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions packages/miniflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
Expand Down
70 changes: 70 additions & 0 deletions packages/miniflare/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<p>👋</p>", {
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<string> {
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"
Expand Down
Loading