Skip to content

your CLI prints the right answer and then crashes with 0xC0000409, because process.exit ran while a socket was still closing

exit codes · case

bothmisleading-errorscriptciagentfirst-partyrepro: historicalhandle-close-race
Affectsnode, windows
Fails asFASTFAIL
Mechanismhandle close race
Safe fixexitcode not exit

The command works. The output is correct and complete. Then the process dies with an exit code that means “the runtime detected corruption”:

PS> mytool status
server: healthy
PS> $LASTEXITCODE
-1073740791 # 0xC0000409, STATUS_STACK_BUFFER_OVERRUN

Which sends you looking for memory corruption in your own code, where there is none. CI turns red on a command whose output the same job just asserted was right.

On macOS and Linux the identical code exits 0.

Any short-lived Node program that makes an HTTP request and then exits promptly:

const r = await fetch("http://127.0.0.1:8080/api/health");
console.log((await r.json()).status);
process.exit(0); // <- the crash

It is intermittent by nature — it needs the exit to land inside the window while a handle is closing — so it shows up as a flaky Windows CI job long before anyone reproduces it on purpose.

process.exit() tears the runtime down immediately, without waiting for libuv to finish closing handles. If a handle is in UV_HANDLE_CLOSING at that moment, libuv trips an internal assertion, and Windows reports an aborted runtime as 0xC0000409 — the fastfail code, whose documented meaning is stack buffer overrun. The exit code describes the abort mechanism, not your bug.

fetch makes this easy to hit because undici keeps connections alive by default: the socket is still open when your program is logically done, so an immediate exit lands squarely in the closing window. AbortSignal.timeout() adds a second handle with the same property — the timer outlives the request it was guarding unless you clear it.

POSIX platforms tear down without the assertion, so the same race produces a clean exit and nobody notices the code was wrong.

Set the code and let the loop drain:

process.exitCode = 1; // not process.exit(1)
return; // unwind normally; Node exits when handles are done

Then remove the handles that keep the loop alive rather than killing the loop:

// close keep-alive sockets on a short-lived client
await fetch(url, { headers: { connection: "close" } });
// clear a timeout guard once the await resolves
const t = setTimeout(() => ctrl.abort(), 600);
try { await fetch(url, { signal: ctrl.signal }); } finally { clearTimeout(t); }

If you have a deep call stack and need to bail out, throw a sentinel and catch it at the top level rather than calling exit from the middle.

The one case that still needs process.exit() is a deliberately stuck process, and there the right shape is a bounded unref’d timer that fires only after the graceful path has had its chance.


The corpus’s other exit-code cases are about a status being lost, faked, or misread. This one is about the exit itself being unsafe: the call you use to report success is what makes the process report corruption.