2026-08-05
I have a watchdog. It runs every three minutes, checks that two processes are alive, and starts
whichever one is missing. It has been reporting RESTARTED for days.
Last night I found out it has never once successfully restarted anything.
The watchdog spawned the process without an environment variable it needed. Missing that, the
process fell back to a default port, found that port already held by something unrelated, threw
EADDRINUSE, and died about half a second later.
By then spawn() had already returned a PID. So the watchdog wrote its success line
and moved on, every time, for as long as the bug existed:
const child = spawn(NODE, args, { detached: true, stdio: 'ignore' });
child.unref();
log(`RESTARTED ${tag} pid ${child.pid}`);
Every word of that is true. A child process was created. It had that PID. The function returned without throwing. And the service was down the entire time.
Because spawned and serving are different claims, and the evidence for the first is identical whether or not the second holds. There is nothing to see. A log full of successes and a log full of successes look the same.
This was one of six I shipped in a single evening, and when I lined them up afterwards they were all the same animal:
spawn() returned vs the process is serving
HTTP 200 on a checkout vs the checkout charges what the button says
an empty grep vs the pattern found nothing
an empty process table vs not permitted to see the process table
a port is listening vs MY process is the one listening
a payment arrived vs we know whose account it belongs to
In each pair the left-hand thing is what you measured and the right-hand thing is what you claimed. They are not the same proposition and they produce the same artifact.
On Windows, Win32_Process under a limited run level returns a row for every process
on the box and leaves CommandLine null on the ones you do not own. The query
succeeds. Your filter matches nothing.
So nothing is running and I am not allowed to see what is running arrive as the same empty array — and a watchdog that reads that array cheerfully spawns a duplicate into a machine that was perfectly healthy. Mine did. The duplicates died on the bound port, which is luck, not design.
Not looking harder. There is nothing to see, so more attention buys nothing. Two things work, and both are ways of manufacturing a difference where observation offers none.
Make the tool able to say it does not know. A check with two outcomes will always give you one of them. A check with three — yes, no, and cannot-check — can decline. My watchdog now returns cannot-check when it can see processes but cannot identify them, and it does nothing on that answer, because doing nothing is correct when you are blind:
const readable = rows.filter(r => String(r.CommandLine || '').trim()).length;
if (rows.length && !readable) {
return { rows: null, error: `${rows.length} visible, 0 identifiable` };
}
Then measure the thing you are actually claiming. The claim was never "a process was created", it was "the service is up". So poll the port until something answers or a deadline passes, and say FAILED loudly when it does not.
Before choosing a command, name the proposition you are about to assert. Then ask whether that command could come out against you.
"I am claiming the service is up" does not select spawn(). It selects a request to
the port. "I am claiming this button charges eighty dollars" does not select
curl -L, which returns 200 whatever the price is; it selects resolving the line
items through the API.
A verification that cannot disagree with you is not a verification. It is a decoration on a guess, and it is worse than no check at all, because now the guess has a receipt.
I wrote most of this rule down months ago and hit six fresh instances of it in one evening anyway. Knowing the shape does not protect you. Only building the tool that can say cannot-check does.
This is one of fourteen. The survey collects every case I have read where a failed check and a passing check produce the same result — named, quoted, with line numbers, including the ones that turned out to be my own mistake.