debugging
A failure with no witnesses: one query pipeline that silenced all three channels
A jq command over a saved MCP result printed nothing and exited 0 — no data, no error, no failure. Every one of those was manufactured by the caller: a wrong schema emptied stdout, a defensive 2>/dev/null ate the one diagnostic line, and | head laundered jq exit code 5 into 0. A Unix pipeline has three signal channels; habits acquired as defenses can close all three at once.
By the X-GIS team 5 min read
A jq command over a saved query result produced nothing. The tool harness
reported “completed with no output,” exit 0. No error, no data, no failure —
and every one of those three was manufactured by the command itself. A Unix
pipeline gives a caller exactly three channels to watch: standard output,
standard error, and the exit code. This one line closed all three, each by a
different habit, and left a failure with no witnesses.
The command
We had saved an oversized MCP list_issues result to a text file and wanted a
quick number-and-title list out of it:
jq -r '.[] | "\(.number)\t\(.title)"' file 2>/dev/null | head -60It printed nothing. Exit 0. “Completed with no output.” The natural read is
“no issues matched” — an empty result, not a failure. That read was wrong on
every count.
What actually happened
Each assumption in that line was false, and each false assumption silenced one channel.
stdout was emptied by a schema assumption. The GitHub REST API returns a
bare array of issues, and .[] iterates an array. But this file was not the
REST payload — it was the MCP tool’s envelope:
{"issues": [ ... ], "pageInfo": { ... }, "totalCount": 33}.[] on an object does not iterate issues; it yields the object’s values —
here three of them: the issues array, the pageInfo object, and the number 33.
The format string then tries to index the first of those values, the array,
with a string key, and jq stops on a runtime error before emitting a single
line.
stderr was emptied by a defensive redirect. jq said exactly what was wrong:
jq: error (at …/mcp-github-list_issues-1784025821882.txt:0): Cannot index array with string "title"One line that names the entire bug. We never saw it, because 2>/dev/null was
already on the command. Why? The tool-result preamble had announced “Format:
Plain text,” priming us for possibly-non-JSON content, so stderr had been
pre-suppressed as a defense against jq complaining about a non-JSON file. The
file was valid JSON the whole time; the defense against a hypothetical parse
error suppressed the actual, informative one.
the exit code was emptied by the pipe tail. jq exited 5. The pipeline
exited 0 — because | head -60 makes head’s status the pipeline’s status,
and head succeeded. This shell runs without pipefail, so a failing upstream
stage’s code is simply discarded. We reproduced it both ways: without
pipefail the pipeline reports 0; with set -o pipefail it reports jq’s 5.
So all three channels a caller can watch were silenced at once — the schema assumption emptied stdout, the defensive redirect emptied stderr, the pipe tail rewrote the exit code — and not one of the three silences was a bug in jq or in the data. Each was a habit the caller brought to the command.
The recovery
One command, and the reflex that should have come first:
cut -c1-300 fileRead a sample of the actual bytes before asserting a schema over them. The
prefix {"issues":[… explained the whole thing at a glance; the corrected query
— .issues[] in place of .[] — worked on the first try. The debugger’s
cheapest instrument is not a flag; it is looking at the input.
The same shape without any suppression
The pattern does not need a redirect to bite. The same day, bun scripts/emit-gap-matrix.ts
was run to regenerate a report — and exited 0, looking successful, while
leaving the target file untouched with merge-conflict markers still inside it.
The script’s contract is stdout-emission; its header says so outright:
Run via: bun scripts/emit-gap-matrix.ts > scripts/gap-matrix.mdand its final statement is process.stdout.write(...), under the comment “Emit
to stdout — caller redirects.” Run without the > redirect, it dutifully prints
the fresh matrix to the terminal and writes nothing to the file. Exit 0,
because printing to a terminal is a success. The caller’s mental model — “the
script rewrites the file in place” — was wrong, and the mismatch between a
stdout-emitting contract and a write-in-place assumption produced the same
no-witnesses shape: a green exit over an untouched, still-broken file, this time
with no suppression at all.
What generalizes
A Unix pipeline has exactly three signal channels — stdout, stderr, exit code —
and the habits we acquire as defenses can close all three. 2>/dev/null is a
real defense against noisy stderr and a perfect way to hide the one line you
needed. | head (or | tail, | grep) is a real way to bound output and a
perfect way to launder a non-zero exit into a zero — which is exactly why
set -o pipefail exists: to keep the third channel honest through a pipe tail.
“Exit 0 means it worked” is true only when the exit code is the code of the
thing you care about, and both a pipe tail and a wrong output-contract quietly
break that.
Under all of it sits the cheapest instrument in the kit: read a few raw bytes of
the input before asserting a schema over it. cut -c1-300, head -c 300,
file — three seconds that would have shown the envelope and skipped the entire
hunt.
One asymmetry is worth naming. | head appears in this story and in
today’s commit-kill
as two different failures. Here it masks a reader’s exit code; there it
SIGPIPE-kills a writer mid-transaction. One idiom, two wounds — a masking read
on this side of the pipe, a killed writer on the other. The pipe is the day’s
recurring shape, and this is its query-side face.
References
- “A pipe into git commit is a kill signal with a delay fuse”
— the same
| headidiom on a state-mutating command, where it kills the writer instead of masking the reader; the write-side sibling to this read-side failure. - jq manual, invocation and exit codes
—
jqexits5on a runtime error such as indexing an array with a string key; a status a pipe tail discards unlesspipefailis set. set -o pipefail— makes a pipeline’s status that of the last stage to fail, restoring the third channel that| headotherwise rewrites to zero.
Read next
rendering
7 min read
Draped at the wrong tile: a deferred write turned a shared uniform pool into a data race
A draped landcover polygon rendered hundreds of kilometres offshore — but only on WebGPU. Every vector slice of one source shares one Material uniform pool, and each slice resets the pool cursor to 0, so a later slice's tile i overwrote an earlier slice's tile-i buffer. WebGPU's queue.writeBuffer defers to submit (last-writer-wins), so every draw read the final bytes; WebGL2's immediate bufferSubData never showed it — and the differential test was structurally blind, because both frames aliased identically.
rendering
7 min read
The hemisphere that was never selected: a fast-path bbox that collapsed to a point
Half the globe rendered as background again — same symptom as the drape-suppression bug, opposite root cause. This time the trans-antimeridian tiles were never selected: the overzoom fast-path unprojects the viewport corners to a lon/lat box, but on a small globe disc the corners miss the sphere, the box collapses to the sub-camera point, and it emits the single tile column under the camera — which a contiguous longitude range can never wrap across the dateline. Told apart from the draw bug by seam location and persistence.
git
5 min read
A pipe into git commit is a kill signal with a delay fuse
A sequel scar. Piping a merge commit into head -3 closed the pipe after three lines and SIGPIPE-killed lint-staged mid-run — orphaning its backup stash onto the shared stack and leaving the merge half-done. The worktrees post predicted this pile collects scars; today added one through a mechanism nobody had listed. Treat a hook-running commit stdout as a transaction log, not a stream to sample.