Every program you run hands back three separate things. People read them as one. That is the whole bug this article is about.
- stdout: normal output. Results, progress, "started".
- stderr: the error stream. Warnings and failures, if the author sent them there.
-
exit code: one integer when the process ends.
0means "I did not report a failure". Anything else means "I did".
They are independent channels. A program can print cheerful text to stdout and exit non-zero. It can print nothing and exit 0. It can write to stderr and still exit 0 (many warnings do exactly that). None of the three tells you whether the thing you wanted now exists. Let's make that concrete.
A program that disagrees with itself on purpose
Save this as app.cjs. It writes a "started" line to stdout, then notices a required file is missing, complains on stderr and exits with 42.
// app.cjs
const fs = require("node:fs");
console.log("started synthetic-report");
if (!fs.existsSync("required-config.json")) {
console.error("CONFIG_MISSING: required-config.json is absent");
process.exitCode = 42;
}
Run it in a folder without required-config.json. I did this on Windows 11 with Node.js 22; the behaviour is the same on macOS and Linux.
In bash
node app.cjs
echo "exit: $?"
Output:
started synthetic-report
CONFIG_MISSING: required-config.json is absent
exit: 42
Both lines land in the same terminal, so by eye they look like one message. They are not. Prove it by splitting the streams:
node app.cjs 2>errors.txt # stderr goes to a file, stdout stays on screen
echo "exit: $?"
cat errors.txt
In PowerShell
node app.cjs
"exit: $LASTEXITCODE"
Same two lines, exit: 42. Note the variable: for a native program, $LASTEXITCODE holds the exit code. $? only tells you whether the last PowerShell statement succeeded, which is a different question and a common trap in scripts that mix the two.
The interpretation mistake
Here are three ways people turn that 42 into "it worked".
1. Reading stdout as the verdict. The first line says started. A script that greps for started, or a person skimming, sees success. stdout is narration, not a verdict.
2. Losing the exit code in a pipe. In bash:
node app.cjs | tee run.log
echo "exit: $?" # 0
That 0 is tee's exit code, not the app's. A pipeline's status is the status of its last command. Fix it with set -o pipefail at the top of the script (then the pipeline fails if any stage fails), or read ${PIPESTATUS[0]} right after the pipe.
PowerShell has the same shape:
node app.cjs | Tee-Object run.log
"exit: $LASTEXITCODE" # still 42 here, because node was the last *native* command
PowerShell keeps $LASTEXITCODE from the last native executable, so this particular pipeline is fine, but as soon as you wrap the call in a function or a try block, check that the code is actually propagated and not swallowed.
3. Trusting the wrapper's own exit. Suppose a script runs the app and then writes a report:
// run.cjs
const { spawnSync } = require("node:child_process");
const r = spawnSync(process.execPath, ["app.cjs"], { encoding: "utf8" });
console.log("report written");
node run.cjs exits 0. The wrapper is telling the truth about itself: it did write a report. The child exited 42, and nobody looked. This is how CI steps, deploy scripts and AI assistants end up saying "done" over a failure. The wrapper has to carry the child's verdict forward:
const r = spawnSync(process.execPath, ["app.cjs"], { encoding: "utf8" });
console.log(JSON.stringify({ exitCode: r.status, stdout: r.stdout, stderr: r.stderr }, null, 2));
process.exitCode = r.status ?? 1; // null status means the child was killed by a signal
Now the report contains all three channels, and the wrapper's own exit code is honest.
The check that answers the real question
Even a perfectly captured 42 only tells you what the process thought. Your question was different: did the report get produced? Did the files get copied? Did the service come back up? The exit code is evidence about the process. The result needs its own check, against the artifact you wanted.
Suppose app.cjs was meant to write report.json (our synthetic one never gets that far). Then the real check is:
node app.cjs; code=$?
if [ "$code" -ne 0 ]; then echo "process failed: $code"; exit "$code"; fi
test -s report.json || { echo "process exited 0 but report.json is missing or empty"; exit 1; }
node app.cjs
if ($LASTEXITCODE -ne 0) { throw "process failed: $LASTEXITCODE" }
if (-not (Test-Path report.json) -or (Get-Item report.json).Length -eq 0) {
throw "process exited 0 but report.json is missing or empty"
}
Two checks, in that order. First the process's verdict (exit code, with stderr next to it so the failure has a name). Then the artifact: does the thing exist, is it non-empty, does it have the rows or files you expected. The second check is the one people skip, and it is the one that catches "exit 0, wrong result": a copy with the right file count and one changed file, a build that passed with the old bundle still on disk, a service that started and then died.
The habit
- Print all three channels, as data, before you summarise. "It said something about config" is not evidence;
CONFIG_MISSING, exit42is. - Never let a pipe or a wrapper eat the exit code.
set -o pipefail,${PIPESTATUS[0]},$LASTEXITCODE,process.exitCode = child.status. - Name the artifact before you run, and check for it after. A clean exit code is a reason to look, not a reason to stop.
- Treat a missing number as missing. Truncated output, no exit code captured, a timeout: that is "unknown", not "probably fine".
Try the two-line app.cjs in your own shell and in whatever runs your scripts for you. If you find a place where the 42 disappears between the program and the summary, that is the bug worth fixing first.
Author note: I'm Peter. I build a tool that lets a chat run commands on your own computers, and this exact mistake is why I insist on seeing all three channels; the read-only checks I start with are at https://localcmd.app/use-cases/read-only-first-week.
Top comments (0)