Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 9 additions & 0 deletions .changeset/fix-runtime-ebadf-on-restart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"miniflare": patch
---

Fix potential EBADF error when restarting workerd process

Previously, when the workerd process was restarted (e.g., via `setOptions()` or Vite server restart), the stdio pipes from the previous process were not explicitly destroyed. This could lead to `EBADF` (Bad File Descriptor) errors during spawn on some systems.

The `Runtime#dispose()` method now explicitly destroys all stdio streams (stdin, stdout, stderr, and the control pipe) before killing the process to ensure file descriptors are properly released.
24 changes: 23 additions & 1 deletion packages/miniflare/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,35 @@ export class Runtime {
}

dispose(): Awaitable<void> {
const runtimeProcess = this.#process;
if (runtimeProcess === undefined) {
return;
}

// Clear reference to prevent potential race conditions
this.#process = undefined;

// Explicitly destroy all stdio streams to ensure file descriptors are
// properly released. This prevents EBADF errors when spawning a new
// process after restart.
// See https://github.com/cloudflare/workers-sdk/issues/11675
runtimeProcess.stdin?.destroy();
runtimeProcess.stdout?.destroy();
runtimeProcess.stderr?.destroy();
// The control pipe at stdio[3] is a Readable stream
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not consistent with l335?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this could be:

Suggested change
// The control pipe at stdio[3] is a Readable stream
// The control pipe at stdio[3] could be a Readable stream. If so also destroy it

const controlPipe = runtimeProcess.stdio[3];
if (controlPipe instanceof Readable) {
controlPipe.destroy();
}

// `kill()` uses `SIGTERM` by default. In `workerd`, this waits for HTTP
// connections to close before exiting. Notably, Chrome sometimes keeps
// connections open for about 10s, blocking exit. We'd like `dispose()`/
// `setOptions()` to immediately terminate the existing process.
// Therefore, use `SIGKILL` which force closes all connections.
// See https://github.com/cloudflare/workerd/pull/244.
this.#process?.kill("SIGKILL");
runtimeProcess.kill("SIGKILL");

return this.#processExitPromise;
}
}
Expand Down
40 changes: 40 additions & 0 deletions packages/miniflare/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3401,6 +3401,46 @@ test("Miniflare: custom Node outbound service", async () => {
);
});

test("Miniflare: setOptions: can restart workerd multiple times in succession", async () => {
// Regression test for https://github.com/cloudflare/workers-sdk/issues/11675
Copy link
Contributor

@vicb vicb Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think annotations are too noisy for this sort of thing. We don't need this to be displayed in every test run report.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://vitest.dev/guide/test-annotations.html#built-in-reporters
The default reporter prints annotations only if the test has failed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes but I regularly use verbose reporter.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And more importantly, this link to the original issue is not needed in the reporter outpiut. If the test fails it is because something has broken that needs fixing. The original issue is only one of the sources for looking into what could be the cause.

// EBADF errors can occur when spawning a new workerd process if the stdio
// pipes from the previous process are not properly cleaned up. This is
// especially relevant when other parts of the system (like worker_threads
// used by Miniflare or other Vite plugins) are also managing file descriptors.
// While this test doesn't reliably reproduce the race condition, it validates
// that the restart mechanism works correctly after the fix.
const mf = new Miniflare({
port: 0,
modules: true,
script: `export default {
fetch() {
return new Response("version 1");
}
}`,
});
useDispose(mf);

// First request to ensure initial startup is complete
let res = await mf.dispatchFetch("http://localhost");
expect(await res.text()).toBe("version 1");

// Perform multiple rapid setOptions calls to trigger workerd restarts.
// This tests that the stdio pipe cleanup in dispose() works correctly.
for (let i = 2; i <= 5; i++) {
await mf.setOptions({
port: 0,
modules: true,
script: `export default {
fetch() {
return new Response("version ${i}");
}
}`,
});
res = await mf.dispatchFetch("http://localhost");
expect(await res.text()).toBe(`version ${i}`);
}
});

test("Miniflare: MINIFLARE_WORKERD_CONFIG_DEBUG controls workerd config file creation", async () => {
const originalEnv = process.env.MINIFLARE_WORKERD_CONFIG_DEBUG;
const configFilePath = "workerd-config.json";
Expand Down
Loading