Build1 distinct publisher3 min readPublished
Weeks of notes recorded a vendor CLI as unreliable, but the blocked process was the truncator on the right of the pipe, waiting on an EOF that a concurrently spawned npm exec child had inherited and never released.
The Engineer · Build desk

Compiled by The EngineerSomething wrong?How this is made
EOF on a pipe is a reference count reaching zero, not a notification that your command finished [14]. `tail -N` without `-f` cannot emit a line before that count drops, because it does not know which lines are the last N until the stream ends [12]. `head -N` looks safer, and usually is, right up until total output comes in under N lines, at which point it waits for EOF too [13]. Both ends of the idiom are exposed, which is why a rule written after the first two cases mentioned only `tail` and did not cover the third [19].
The process holding the write end does not have to be related to your command. On the 2026-08-15 case, `lsof` on the blocked `head` showed fd 0 reading pipe address `0x4881848dc2e3034f` [8]. An `npm exec xcodemcp@latest` process, spawned in the same second and connected to nothing the author had typed, held fds 5 and 6 on that same address [9]. The 2026-08-14 pair matched the same way, one against `xcodemcp` again and one against `@modelcontextprotocol/server-redis` at pipe `0xe3e977fcc256b0c7` [10][11]. All three confirmed holders were `npm exec` children [2]. The author attributes the inheritance to a missing `close-on-exec` and says so with a hedge, which is the right amount of confidence for evidence that is a matching descriptor address rather than a traced fork [14].
The wrapper timeout cannot see any of this. `alarm 45` fires against the process that `exec` replaced, meaning the vendor CLI, and the CLI has already exited by the time the truncator is stuck [15]. Escalating the value for weeks changed nothing [15], and the scale gap says why: the idiom sets 45 seconds, and one case sat 4860 seconds, 108 times that [1].
The reason this survived weeks of notes is that the same defect produces two different verdicts. A caller that simply waits records an infinite hang. A caller with its own timeout records empty output, and empty output reads as a dead endpoint or a vendor outage [16]. The second version is the one that got written down about a third party [16].
What came out of the 81-minute pipe settles the diagnosis. Killing the write-end holder let `tail` finish in about two seconds, and the payload was 36 bytes: `Error: timeout waiting for response` [17]. The CLI had failed in the first second and said so, and the pipe held that sentence for 81 minutes [5][18]. The failure signal never arrived, and that teaches you to distrust a tool that reported accurately and on time [18].
The fix is cheap and slightly worse to use. Redirect to a file, let process exit close the descriptors, then cut the file [20]. You get correctness and you give up incremental output, because nothing is read until the command has exited [4]. Skipping the shell pipe entirely is the structural version, and a runtime that defaults descriptors to close-on-exec removes the inheritance path, as Python has since PEP 446 [21].
Three cases in one person's terminal is a claim about that terminal. For it to transfer you need a shell pipe into a truncator without `-f`, and something in the same session spawning short-lived processes that inherit fds. Agent tooling launched through `npm exec` fits that description [2].
Ranked by verification strength, evidence, and original report placement.
EOF arrives when the pipe's write-end reference count hits zero, not when your command exits; a process spawned around the same moment can inherit that write-end file descriptor and hold it open indefinitely, which the author attributes to a missing close-on-exec "as best I can tell".
The author had weeks of notes saying a vendor CLI "works sometimes", with availability appearing to depend on the hour, and had started routing around it.
Every one of those observations came through a variant of: perl -e 'alarm 45; exec @ARGV' agy models 2>&1 | tail -20, i.e. timeout on the left, truncate on the right.
The idiom hung indefinitely three separate times, in three separate sessions, on two different subcommands.
Case A, 2026-08-14: agy models | tail -20, PIDs 96135 to 96138, no response.
Case B, 2026-08-14, a different session: agy --print ... | tail -120, PIDs 87045 to 87048, sat there for 81 minutes.
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · September 4, 2026
Follow any of these and your For You feed starts watching them — no settings page required.
build
Pandex hooked a Fortune 500 agent four minutes after claiming a package name from llms.txt1 distinct publisher
build
Before you spend quota on an agent skill, make it pass an eval harness1 distinct publisher
build
Layered permissions, not just three GitHub tools, secure this dependency scanner1 distinct publisher
security
Reco puts 80% of employee AI tools outside IT oversight against 21% of SaaS2 distinct publishers
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Strong artifacts, one machine
The forensics are laid out where you can check them: PID ranges for all three hangs, two distinct pipe addresses matched on both ends via lsof, a spawn timestamp of 10:40:22, a 36-byte error string, and a control run that returned exit 0 in seconds every time. The author also names the test that would sink his own conclusion. What is absent is anyone else: three incidents over two days on a single workstation, a CLI referred to only as "agy" so the wrongly accused tool cannot be checked, and a close-on-exec cause the author declines to claim he traced to code.
One laptop, three incidents
Nothing here has travelled. The failure is documented on a single workstation across two days, and the only third parties involved are the accidental cast — xcodemcp and the Redis MCP server, both spawned by npm exec on that same machine. No other developer has reported the hang, no maintainer has acknowledged it, and the recommended fixes are the author's practice rather than anything observed in use elsewhere.
Headline surer than the author
The framing says the hang happened because an npm process held the write end. The body says the close-on-exec attribution is an inference supported by three correlations and not a root cause traced to a line of source. That gap is small and the author closes most of it himself — he supplies the falsifier and reports it failing to fire — but it exists, and the specific mechanism inside the npm children remains unexamined. Everything else runs the other way: the generalisable lesson about trusting your measurement apparatus is stated once, plainly, and left there.
The author is the one who looks wrong
No vendor is named, no product is sold, and the punchline is that the writer spent weeks blaming someone else's tool for his own plumbing — an awkward shape for promotional writing. The mild pull in the other direction is that a clean postmortem is reputational currency on dev.to, and clean is what this is: three cases that all resolved, each matching the thesis, with no mention of the runs that went nowhere.
Check it against your own pipe
The Unix layer is not in question: tail needs EOF to know which lines are last, head needs it too when output falls short of N, and EOF waits on the write end's reference count rather than on your command exiting. Any reader can confirm that much. The part that stays open is whose descriptor leaked and why — one person's inference from three same-second spawns and two matching pipe addresses, never confirmed by the maintainers of either npm package or reproduced on another machine. Run the lsof recipe next time something hangs; treat the culprit as a hypothesis.