Skip to content
7 changes: 7 additions & 0 deletions .changeset/miniflare-loopback-bind-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"miniflare": patch
---

Reject loopback server bind failures during Miniflare startup instead of leaving `ready` and `dispose()` hanging

`#startLoopbackServer` now attaches an `error` listener before `listen`, matching the inspector proxy. When the configured host cannot be bound (e.g. `192.0.2.1`), `ready` rejects and `dispose()` still settles even if the loopback server never started.
7 changes: 7 additions & 0 deletions .changeset/tidy-miniflare-listeners.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"miniflare": patch
---

Clean up Miniflare listener startup error handlers

Loopback and inspector servers now remove startup-only error handlers after binding and close the server after bind failures.
16 changes: 11 additions & 5 deletions fixtures/additional-modules/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ function get(worker: WranglerDev, pathname: string) {
return worker.fetch(url, { headers: { "MF-Disable-Pretty-Error": "true" } });
}

function waitForReload(callback: () => Promise<void>) {
// File watching and Wrangler reloads can exceed `waitFor`'s one-second default under Windows load
return vi.waitFor(callback, { timeout: 5_000 });
}

describe("find_additional_modules dev", () => {
let tmpDir: string;
let worker: WranglerDev;
Expand Down Expand Up @@ -70,23 +75,24 @@ describe("find_additional_modules dev", () => {
expect(await res.text()).toBe("hello");
});

test("watches additional modules", async ({ expect }) => {
// This test mutates shared fixture state and cannot be safely retried.
test("watches additional modules", { retry: 0 }, async ({ expect }) => {
const srcDir = path.join(tmpDir, "src");

// Update dynamically imported file
await fs.writeFile(
path.join(srcDir, "dynamic.js"),
'export default "new dynamic";'
);
await vi.waitFor(async () => {
await waitForReload(async () => {
const res = await get(worker, "/dynamic");
assert.strictEqual(await res.text(), "new dynamic");
});

// Delete dynamically imported file
await fs.rm(path.join(srcDir, "lang", "en.js"));

await vi.waitFor(async () => {
await waitForReload(async () => {
await expect(get(worker, "/lang/en")).rejects.toThrow(
'No such module "lang/en.js".'
);
Expand All @@ -98,7 +104,7 @@ describe("find_additional_modules dev", () => {
path.join(srcDir, "lang", "en", "us.js"),
'export default { hello: "hey" };'
);
await vi.waitFor(async () => {
await waitForReload(async () => {
const res = await get(worker, "/lang/en/us");
assert.strictEqual(await res.text(), "hey");
});
Expand All @@ -108,7 +114,7 @@ describe("find_additional_modules dev", () => {
path.join(srcDir, "lang", "en", "us.js"),
'export default { hello: "bye" };'
);
await vi.waitFor(async () => {
await waitForReload(async () => {
const res = await get(worker, "/lang/en/us");
assert.strictEqual(await res.text(), "bye");
});
Expand Down
14 changes: 9 additions & 5 deletions fixtures/multi-worker/tests/multi-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ describe("Multi Worker", () => {
["-c=workers/sentry/wrangler.jsonc", "-c=workers/default/wrangler.jsonc"]
);
try {
await vi.waitFor(async () => {
const response = await fetch(`http://${ip}:${port}/`);
const text = await response.text();
expect(text).toBe(`Hello World!`);
});
await vi.waitFor(
async () => {
const response = await fetch(`http://${ip}:${port}/`);
const text = await response.text();
expect(text).toBe(`Hello World!`);
},
// The TCP listener can be ready before the multi-worker runtime and Sentry integration can serve requests under Windows load
{ timeout: 10_000, interval: 100 }
);
} finally {
await stop();
}
Expand Down
27 changes: 23 additions & 4 deletions packages/miniflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1930,7 +1930,7 @@ export class Miniflare {
hostname = "::";
}

return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const server = stoppable(
http.createServer(this.#handleLoopback),
/* grace */ 0
Expand All @@ -1944,14 +1944,33 @@ export class Miniflare {
// already disable their timeouts.
server.keepAliveTimeout = 0;
server.on("upgrade", this.#handleLoopbackUpgrade);
server.listen(0, hostname, () => resolve(server));
const onError = (error: Error) => {
server.close();
reject(error);
};
server.once("error", onError);
server.listen(0, hostname, () => {
server.off("error", onError);
resolve(server);
});
});
}

#stopLoopbackServer(): Promise<void> {
const loopbackServer = this.#loopbackServer;
if (loopbackServer === undefined) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
assert(this.#loopbackServer !== undefined);
this.#loopbackServer.stop((err) => (err ? reject(err) : resolve()));
loopbackServer.stop((err) => {
if (err) {
reject(err);
return;
}
this.#loopbackServer = undefined;
this.#loopbackHost = undefined;
resolve();
});
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,8 @@ export class InspectorProxyController {
res.end(null);
});

this.#initializeWebSocketServer(server);

await this.#startListening(server);
this.#initializeWebSocketServer(server);

return server;
}
Expand All @@ -84,12 +83,15 @@ export class InspectorProxyController {
`Trying to listen on ${this.inspectorHostOption}:${this.inspectorPortOption}`
);
return new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(
this.inspectorPortOption,
this.inspectorHostOption,
resolve
);
const onError = (error: Error) => {
server.close();
reject(error);
};
server.prependOnceListener("error", onError);
server.listen(this.inspectorPortOption, this.inspectorHostOption, () => {
server.off("error", onError);
resolve();
});
});
}

Expand All @@ -108,6 +110,7 @@ export class InspectorProxyController {

#initializeWebSocketServer(server: Server) {
const devtoolsWebSocketServer = new WebSocketServer({ server });
devtoolsWebSocketServer.on("error", (error) => this.log.error(error));

devtoolsWebSocketServer.on("connection", (devtoolsWs, upgradeRequest) => {
const validationError =
Expand Down
18 changes: 14 additions & 4 deletions packages/miniflare/test/dev-registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2445,7 +2445,7 @@ describe.sequential("DevRegistry", () => {
await remote.ready;

const logs: string[] = [];
const local = new Miniflare({
const localOptions: MiniflareOptions = {
unsafeDevRegistryPath,
handleStructuredLogs: ({ message }) => void logs.push(message),
workers: [
Expand All @@ -2467,7 +2467,8 @@ describe.sequential("DevRegistry", () => {
},
},
],
});
};
const local = new Miniflare(localOptions);
useDispose(local);
await local.ready;

Expand All @@ -2480,11 +2481,20 @@ describe.sequential("DevRegistry", () => {
{ timeout: 10_000, interval: 100 }
);

// Drop the peer without letting it deregister, so `local` keeps a registry
// entry pointing at a debug port that is no longer accepting connections.
const remoteDefinitionPath = path.join(
unsafeDevRegistryPath,
"remote-worker"
);
const remoteDefinition = await fs.readFile(remoteDefinitionPath, "utf8");

// Restore the registry entry removed by disposal to model a peer that exited
// without cleaning up. This leaves `local` pointing at a debug port that is
// no longer accepting connections, regardless of watcher timing.
// The forwarding RPC now rejects; that rejection must be reported rather
// than escaping as an unhandled rejection.
await remote.dispose();
await fs.writeFile(remoteDefinitionPath, remoteDefinition);
await local.setOptions(localOptions);
logs.length = 0;

await vi.waitFor(
Expand Down
93 changes: 93 additions & 0 deletions packages/miniflare/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,99 @@ test("Miniflare: can use localhost as host", async ({ expect }) => {
expect(await res.text()).toBe("body");
});

test("Miniflare: removes loopback startup error handler after listening", async ({
expect,
onTestFinished,
}) => {
const createServer = vi.spyOn(http, "createServer");
onTestFinished(() => createServer.mockRestore());

const mf = new Miniflare({
workers: [
{
config: {
type: "worker",
name: "",
compatibilityDate: "2025-05-01",
manifest: singleModuleManifest(
`export default { fetch() { return new Response("ok"); } }`
),
},
},
],
});
useDispose(mf);

await mf.ready;

expect(createServer).toHaveBeenCalledOnce();
const server = createServer.mock.results[0].value;
expect(server.listenerCount("error")).toBe(0);
});

test("Miniflare: rejects ready when loopback server cannot bind", async ({
expect,
onTestFinished,
}) => {
const createServer = vi.spyOn(http, "createServer");
const close = vi.spyOn(http.Server.prototype, "close");
onTestFinished(() => {
createServer.mockRestore();
close.mockRestore();
});

const mf = new Miniflare({
host: "192.0.2.1",
workers: [
{
config: {
type: "worker",
name: "",
compatibilityDate: "2025-05-01",
manifest: singleModuleManifest(
`export default { fetch() { return new Response("ok"); } }`
),
},
},
],
});

await expect(mf.ready).rejects.toMatchObject({ code: "EADDRNOTAVAIL" });
expect(createServer).toHaveBeenCalledOnce();
const server = createServer.mock.results[0].value;
expect(close.mock.instances).toContain(server);
expect(server.listening).toBe(false);
await expect(mf.dispose()).rejects.toMatchObject({ code: "EADDRNOTAVAIL" });
});

test("Miniflare: setOptions: recovers after loopback bind failure", async ({
expect,
}) => {
const worker = {
config: {
type: "worker" as const,
name: "",
compatibilityDate: "2025-05-01",
manifest: singleModuleManifest(
`export default { fetch() { return new Response("ok"); } }`
),
},
};
const mf = new Miniflare({ host: "127.0.0.1", workers: [worker] });
useDispose(mf);

await mf.ready;

await expect(
mf.setOptions({ host: "192.0.2.1", workers: [worker] })
).rejects.toMatchObject({ code: "EADDRNOTAVAIL" });

await mf.setOptions({ host: "127.0.0.1", workers: [worker] });

const res = await mf.dispatchFetch("https://example.com");
expect(await res.text()).toBe("ok");
});

test("Miniflare: can use IPv6 loopback as host", async ({ expect }) => {
const mf = new Miniflare({
host: "::1",
Expand Down
3 changes: 2 additions & 1 deletion packages/miniflare/test/plugins/browser/process.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ test("gracefully closes Chrome over CDP", async ({ expect }) => {
await closeBrowserProcess(
browserProcess,
`ws://127.0.0.1:${address.port}`,
100
// Leave enough time for the WebSocket handshake when the suite is busy under macOS load
1_000
);

expect(browserProcess.kill).not.toHaveBeenCalled();
Expand Down
Loading
Loading