Skip to content

S0240-cli-pipe-1: terminate cleanly when a consumer closes stdout - #53

Merged
NSchatz merged 2 commits into
mainfrom
sdd/S0240-cli-pipe-1
Sep 2, 2026
Merged

S0240-cli-pipe-1: terminate cleanly when a consumer closes stdout#53
NSchatz merged 2 commits into
mainfrom
sdd/S0240-cli-pipe-1

Conversation

@NSchatz

@NSchatz NSchatz commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

A developer who pipes any cosyte command into head, a pager they quit, or any
early-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:

case exit stderr
no error listener on process.stdout 1 unhandled 'error', Error: write EPIPE, ten stack frames
cosyte parse msg.hl7 (single write) 0 empty, and nothing was delivered
cosyte parse big.ndjson --ndjson 70 value-free CLI_OUTPUT_WRITE_FAILED
cosyte-mcp with a request in flight 1 unhandled 'error', full stack trace naming node_modules paths

The platform error identity, which the research file could not reproduce and
carried as unverified: an Error with code: 'EPIPE', syscall: 'write',
errno: -32, delivered as an 'error' event on the process.stdout socket.
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 value sysexits.h assigns EX_IOERR. Additive: no
existing code is renamed, repurposed or removed, and no existing code's
published meaning changes. 70 stays "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.ts gains IOERR: 74, published on every surface that
    states the contract: README.md, docs-content/reference-commands.md,
    docs-content/concepts-archetype.md and docs-content/quickstart.md.
  • src/commands/parse.ts: the CLI_OUTPUT_WRITE_FAILED raise moves off
    EXIT.SOFTWARE onto EXIT.IOERR. One constant, the only src/commands
    change.
  • src/bin/cosyte.ts: the single-result write now goes through the same
    closed-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.ts and the cosyte mcp subcommand terminate quietly
    under 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 platform
    documents as truncating pending stream writes.
  • 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 spawns real processes and closes their real
    streams: 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.
  • The two contract-locking tests 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.

src/mcp/ tool logic is untouched, and no release is published (a changeset
is included; changeset publish is not run).

Gates

pnpm test 733/733 · pnpm test:coverage 98.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 attw all green.

Spec: work/specs/S0240-cli-pipe-1/spec.md

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`.
@NSchatz

NSchatz commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Fix loop for impl-gate finding F1 (work/specs/S0240-cli-pipe-1/verdict-impl-1.md), pushed as 808fb33.

F1: the single-result write already waited for the platform's acknowledgement, but the record stream did not. deps.writeStdout only consulted the stdoutOpen flag, which the platform flips asynchronously, so a stream short enough to enqueue every line before the closed-stream error arrived resolved to EXIT.OK, and the bin's delivery check then short-circuited on result.stdout === "". cosyte parse f --ndjson into a departed consumer exited 0 with a success summary over zero delivered bytes.

Fix, in src/bin/cosyte.ts only: both write paths go through one emit, every chunk is owed an acknowledgement, and the run may not resolve until each 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.

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 scripts/smoke.mjs asks the same question of the packaged bin under plain node, after build in the same required job. Mutation-checked against the unfixed adapter: 2 suite cases and 4 smoke checks go red.

@NSchatz
NSchatz merged commit d4c8ca6 into main Sep 2, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant