S0240-cli-pipe-1: terminate cleanly when a consumer closes stdout - #53
Conversation
A downstream consumer that goes away (a pipeline into head, a pager the user
quit) now ends the run quietly under a new documented exit code, 74 (IOERR,
the value sysexits.h assigns EX_IOERR), carrying the stable value-free
CLI_OUTPUT_WRITE_FAILED diagnostic and nothing else.
The code was chosen on a measurement taken at the previous pin before anything
changed, not on a citation: a write to a closed stdout is a Node EPIPE on the
stream, an Error with code EPIPE / syscall write / errno -32, fatal when
nothing is listening (measured: a ten-frame stack trace and exit 1). It is a
handled condition the consumer owns, so publishing it as 70 told an operator
their pipeline had hit a bug in the CLI. The contract is additive: no existing
code is renamed, repurposed or removed, and no existing code's meaning changes.
What was measured at the previous pin, on real spawned processes:
single-record (parse msg.hl7): exit 0, empty stderr, nothing delivered
multi-record (parse --ndjson): exit 70, value-free CLI_OUTPUT_WRITE_FAILED
cosyte-mcp: exit 1, unhandled EPIPE, full stack trace
naming node_modules paths
Changes:
- src/core/exit-codes.ts: add IOERR: 74, published on every surface that
states the contract (README.md, docs-content/reference-commands.md,
docs-content/concepts-archetype.md, docs-content/quickstart.md).
- src/commands/parse.ts: the CLI_OUTPUT_WRITE_FAILED raise moves off
EXIT.SOFTWARE onto EXIT.IOERR. A constant, the only src/commands change.
- src/bin/cosyte.ts: the single-result write goes through the closed-stream
check and waits for the platform's acknowledgement, so a result that never
reached the consumer overrides the code the command had resolved to.
- src/bin/cosyte-mcp.ts and the cosyte mcp subcommand: quiet termination
under the same code when the client's end of stdout is gone.
- Both bins hear stderr's own error event, so a closed diagnostic channel
cannot turn a quiet termination into an unhandled error.
- test/pipe-close.test.ts: real spawned processes with their real streams
closed, an explicit timeout per case, and a negative control replaying the
unguarded platform behaviour so the value-free assertions are a verdict.
- test/exit-code-matrix.test.ts and test/exit-code-docs.test.ts admit the new
code: the golden matrix drives an invocation that reaches it, and the docs
guard binds EX_IOERR to 74 on every shipped surface.
Spec: work/specs/S0240-cli-pipe-1/spec.md
Impl-gate finding F1. The single-result write was routed through a sink that waits for the platform's acknowledgement, but the record stream was not: its sink only consulted the `stdoutOpen` flag, which the platform flips asynchronously when it dispatches the closed-stream error. A run short enough to enqueue every line before that error arrived saw an open stream, resolved to `EXIT.OK`, and the bin's delivery check then short-circuited on `result.stdout === ""` (empty precisely because a record stream writes through the sink), so the `74` override never ran. Measured on `dist/bin/cosyte.mjs` under plain `node` at 1, 2, 10, 100 and 500 records, and across the ndjson file, stdin and MLLP input routes: exit `0`, zero bytes delivered, and a success summary printed over them. Both write paths now go through one `emit`, every chunk is owed an acknowledgement from the platform, and the invocation may not resolve until each one has come back. An undelivered run reports the value-free `CLI_OUTPUT_WRITE_FAILED` under `74`, and the command's own summary is withdrawn with the code it computed, so a reassuring line never rides over output nobody received. The exit code is set pessimistically across the window where an acknowledgement is outstanding, so a platform that never came back could not leave a success standing either. Advisory F2, taken: `test/pipe-close.test.ts` now runs its closed-consumer record-stream cases at ONE and TWO records, the counts at which the defect is reachable, and asserts the absence of a success summary as well as a non-zero exit; `scripts/smoke.mjs` asks the same question of the PACKAGED bin under plain `node`, which is where the window is widest and which runs after `build` in CI. Mutation-checked against the unfixed adapter: 2 of the suite's cases and 4 of the smoke's checks go red, including the record stream's exit `0`.
|
Fix loop for impl-gate finding F1 ( F1: the single-result write already waited for the platform's acknowledgement, but the record stream did not. Fix, in Advisory F2 taken: the closed-consumer record-stream cases now run at one and two records (the counts where the defect is reachable) and assert the absence of a success summary, and |
A developer who pipes any cosyte command into
head, a pager they quit, or anyearly-exiting consumer now gets a clean termination under a documented exit
code instead of a silent success or a stack trace.
The measurement that chose the code
Task 1 of the spec is a measurement, taken on real spawned processes at the
previous pin before anything changed. Node v22.23.2:
errorlistener onprocess.stdout1'error',Error: write EPIPE, ten stack framescosyte parse msg.hl7(single write)0cosyte parse big.ndjson --ndjson70CLI_OUTPUT_WRITE_FAILEDcosyte-mcpwith a request in flight1'error', full stack trace namingnode_modulespathsThe platform error identity, which the research file could not reproduce and
carried as unverified: an
Errorwithcode: 'EPIPE',syscall: 'write',errno: -32, delivered as an'error'event on theprocess.stdoutsocket.The write callback receives the same error, which is what makes the
single-write path detectable without waiting on an event race.
The code
A new
IOERR: 74, the valuesysexits.hassignsEX_IOERR. Additive: noexisting code is renamed, repurposed or removed, and no existing code's
published meaning changes.
70stays "an unexpected exception, i.e. a bug",which is precisely why a closed pipe cannot live there: nothing went wrong
inside the CLI, and reporting a consumer's own choice as a defect is the
"documented lie" shape this repository already names elsewhere. The one
condition that moves onto the new code is the closed-consumer write failure
itself, which the spec authorises explicitly.
What changed
src/core/exit-codes.tsgainsIOERR: 74, published on every surface thatstates the contract:
README.md,docs-content/reference-commands.md,docs-content/concepts-archetype.mdanddocs-content/quickstart.md.src/commands/parse.ts: theCLI_OUTPUT_WRITE_FAILEDraise moves offEXIT.SOFTWAREontoEXIT.IOERR. One constant, the onlysrc/commandschange.
src/bin/cosyte.ts: the single-result write now goes through the sameclosed-stream check the record stream uses and waits for the platform's
acknowledgement, so a result that never reached the consumer overrides the
code the command had resolved to. A run whose output never arrived is never
a success.
src/bin/cosyte-mcp.tsand thecosyte mcpsubcommand terminate quietlyunder the same code when the client's end of stdout is gone, by closing the
transport's input rather than calling
process.exit(), which the platformdocuments as truncating pending stream writes.
'error'event, so a closed diagnostic channelcannot turn a quiet termination into an unhandled error.
test/pipe-close.test.tsspawns real processes and closes their realstreams: single-record, multi-record, nothing-delivered, closed stderr, and
both routes to the MCP server. Every case carries its own timeout, and a
negative control replays the unguarded platform behaviour so the value-free
assertions are a verdict rather than an absence.
an invocation that reaches it, and the docs guard binds
EX_IOERRto74on every shipped surface.
src/mcp/tool logic is untouched, and no release is published (a changesetis included;
changeset publishis not run).Gates
pnpm test733/733 ·pnpm test:coverage98.22% statements, 93.5% branches ·pnpm typecheck·pnpm lint·pnpm format:check·pnpm phi-scan·pnpm check:no-emdash·pnpm check:no-internal-refs·pnpm check:agent-notes·pnpm build·pnpm smoke·pnpm attwall green.Spec:
work/specs/S0240-cli-pipe-1/spec.md