Your Subprocess Timeout Fires On Time and Your Program Still Hangs

I set a 30-second timeout. The run took 120 seconds. The timeout was working perfectly.
I write a small Prettier plugin that formats Dart code blocks inside Markdown.
It does almost nothing on its own — it hands each block to the dart format binary you already have installed and puts the result back.
A thin wrapper around one subprocess.
Thin wrappers around subprocesses are where I keep finding out that I do not understand subprocesses.
The change that looked finished
The original code had no upper bound on how long dart could take.
If the binary wedged, Prettier waited forever. That is a bad property for something that runs on save.
Node makes this a one-liner. child_process.spawn accepts a timeout:
const child = spawn("dart", ["format", "--output=show", "--stdin-name", stdinName], {
stdio: ["pipe", "pipe", "pipe"],
timeout: 30_000,
killSignal: "SIGKILL",
});
The plugin's README puts one dart format invocation at roughly a quarter of a second on a warm machine, so thirty seconds is about two orders of magnitude of headroom and should never fire in normal use.
I wrote it, the tests passed, and I nearly shipped it.
Round 1: prove the timeout actually fires
Before believing a guard, I like to watch it fail.
So I wrote a fake dart — a two-line shell script that sleeps far longer than the timeout — put it on PATH, and ran the plugin against it.
Expected: the plugin gives up at 30 seconds and leaves the code block unchanged.
What I got:
exit=0
elapsed=120s
block preserved: yes
The block was preserved, which is correct. It took 120 seconds to do it, which is not.
The fake dart slept for 120 seconds and the plugin waited for every one of them.
At this point the obvious reading is that Node's timeout option does not work.
Round 2: the probe that told me I was wrong
Before blaming Node, I wrote a smaller probe. Three cases, a two-second timeout, printing both exit and close:
A spawn("sleep", ["10"]) EXIT 2008ms CLOSE 2009ms
B spawn("/bin/sh", ["-c", "sleep 10"]) EXIT 2005ms CLOSE 2005ms
C spawn("/bin/sh", ["-c", "exec sleep 10"]) EXIT 2005ms CLOSE 2005ms
All three died on time. The timeout was fine.
Case B is the interesting one. I wrote it specifically to create a grandchild — a shell that forks sleep, so that killing the shell leaves sleep running.
My theory was that the surviving grandchild was holding something open. Case B behaved exactly like case C, which explicitly execs and cannot have a grandchild.
So I crossed the theory off. The probe had disproven it.
The theory was correct. The probe was broken.
Round 3: what the probe could not do
sh -c "cmd", where cmd is the only command, does not fork.
The shell execs it and is replaced by the process. Case B was case C. It was structurally incapable of producing the grandchild I was hunting for.
A script file is different. Put a shebang and a command in a file and the shell stays alive as the parent.
So I ran the same measurement against a real script-file shim:
timeout: 3000, killSignal: SIGKILL
EXIT 3015ms code=null signal=SIGKILL
CLOSE 30029ms code=null signal=SIGKILL
There it is. The kill landed at 3 seconds, exactly as configured. close arrived twenty-seven seconds later.
Every millisecond figure in this post comes from one session on one machine — Node 24.19.0 on macOS — and none of it is archived in a log I can point you at. Treat the numbers as the shape of the thing, not as a benchmark.
What is actually going on
exit and close are not two names for the same moment.
exit fires when the process dies.
close fires when the process has died and its stdio streams have reached EOF.
A child inherits the write end of the stdout pipe, and so does anything it spawns.
Kill the child and the grandchild keeps that write end open. The pipe never EOFs. close waits.
My code settled on close, because that is the correct event for reading output — you want stdout complete before you use it. That single choice meant the timeout bounded when the process died, not when my caller was unblocked. Those are the same number only when nothing outlives the child.
The fix
Keep close for the success path, and let exit handle the case where output no longer matters:
child.on("exit", (_exitCode, signal) => {
if (signal !== null) {
resolve(undefined);
}
});
child.on("close", (exitCode) => {
resolve(exitCode === 0 ? stdout : undefined);
});
A process killed by a signal has discarded its output anyway, so there is nothing to wait for.
A second resolve on the later event is a no-op, so this needs no settled-flag.
Re-measured against a shim that runs for 60 seconds: the caller settles at 30.06 seconds.
Does this matter for real callees?
Mostly, no. That is exactly why it is worth writing down.
dart format is a single process. It does not fork workers. A single-process callee never triggers this, so against the real binary the original code was indistinguishable from the fixed code in every test I had.
The defect only appears when the callee spawns children — a shell wrapper, a launcher script, a build tool with a worker pool. If you shell out to anything you did not write, or to something whose implementation may change, you do not actually know which case you are in.
"The callee happens not to fork" is an assumption. It deserves a comment, not silence.
Lessons
A timeout bounds the event you listen to. Not the wall clock, not the operation, not the caller. If you settle on close, your timeout is a statement about close.
A probe that passes proves nothing until you have shown it can fail. My case B was a clean, fast, unambiguous measurement of nothing — and it did not merely fail to help, it actively argued for the wrong conclusion. That is worse than having no probe. Before trusting a fixture, confirm it can produce the state you are hunting: here, one pgrep -P <pid> while the shim ran would have shown no child and ended the confusion in seconds.
Shell semantics leak into your test fixtures. sh -c exec-optimization is fine behavior and terrible fixture behavior. If your test depends on a process tree, build the tree in a script file and verify its shape.
The numbers above are from one machine — Node 24.19.0 on macOS — and I have not checked whether the close delay behaves identically on Linux. If you reproduce it somewhere else, I would like to hear about it.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.