Skip to content

Miniflare#dispose() rejects with ERR_SERVER_NOT_RUNNING when called twice, skipping the rest of its cleanup #15097

Description

@LeSingh1

What versions & operating system are you using?

miniflare 5.20260801.1-alpha (reproduced against main @ 6dbd192), Node v24.14.1, macOS 15.

Please provide a link to a minimal reproduction

Inline below — six lines, no config or account needed.

Describe the Bug

Calling Miniflare#dispose() a second time rejects with ERR_SERVER_NOT_RUNNING ("Server is not running.").

dispose() has no "already disposed" guard, so a second call re-runs the whole teardown sequence. Two of those steps close a Node HTTP server that the first call already closed, and both propagate the resulting error:

#stopLoopbackServer() (packages/miniflare/src/index.ts):

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

InspectorProxyController#dispose() (.../inspector-proxy/inspector-proxy-controller.ts):

server.closeAllConnections();
return new Promise((resolve, reject) => {
	server.close((err) => (err ? reject(err) : resolve()));
});

server.close() calls back with ERR_SERVER_NOT_RUNNING when the server was not listening. Because the rejection escapes dispose() mid-way, every cleanup step after the failing one is skipped. await this.#stopLoopbackServer() sits early in the finally block, so a second dispose() abandons all of:

await this.#stopLoopbackServer();          // ← rejects here
this.#webSocketServer.close();
removeDir(this.#tmpPath, { fireAndForget: true });
// ... email session dirs ...
await this.#maybeInspectorProxyController?.dispose();   // ← would reject here too
await this.#devRegistry.dispose();                       // unregister + stop file watcher
await this.#hyperdriveProxyController.dispose();
maybeInstanceRegistry?.delete(this);

So the dev registry file watcher and its registration files, the Hyperdrive proxy servers, and the instance registry entry are all left behind — which is the opposite of what a caller retrying dispose() is trying to achieve.

This is already being worked around inside this repo. packages/miniflare/test/test-shared/miniflare.ts string-matches the message to keep test teardown green:

} catch (e) {
	lastError = e;
	// Treat "already disposed" as success (idempotent disposal)
	const message = (e as Error).message;
	if (message === "Server is not running.") {
		return;
	}

Steps to reproduce

import { Miniflare } from "miniflare";

const mf = new Miniflare({ script: "", modules: true });
await mf.ready;
await mf.dispose();
await mf.dispose(); // ← rejects

Actual

Error [ERR_SERVER_NOT_RUNNING]: Server is not running.
    at Server.close (node:net:2367:12)
    at Object.onceWrapper (node:events:622:28)
    at Server.emit (node:events:508:28)
    at emitCloseNT (node:net:2427:8)
    at processTicksAndRejections (node:internal/process/task_queues:89:21)

The same happens with an inspector proxy running (inspectorPort: 0 + unsafeInspectorProxy: true) — and there the inspector proxy server is a second independent source of the same rejection, so fixing only the loopback server still leaves that path broken.

Expected

dispose() is idempotent: a second call resolves and the remaining cleanup still runs. Closing an already-closed server is not a failure to close it.

Please provide any relevant error logs

See the stack above. The failure is silent about what it skipped — the leftover dev registry watcher just keeps the Node event loop alive.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions