Skip to content

🐛 Make piped xmd syntax output complete, and report a closed sink (#715) - #718

Merged
taras merged 6 commits into
mainfrom
agent/issue-715-syntax-pipe
Sep 2, 2026
Merged

🐛 Make piped xmd syntax output complete, and report a closed sink (#715)#718
taras merged 6 commits into
mainfrom
agent/issue-715-syntax-pipe

Conversation

@taras

@taras taras commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Closes #715.

Why

A consumer of the component catalog could receive truncated JSON. xmd syntax
handed the rendered catalog to process.stdout.write() and could finish before
an asynchronous pipe write drained, so xmd syntax --json | jq saw a catalog
that ended mid-token while xmd syntax --json > file was whole. And when the
consumer closed the pipe first, the command died on an unhandled stream error
event with a raw stack trace instead of saying which output was cut short.

What changes

Before:

  • xmd syntax --json | cat delivered 81,920 bytes under Node and 65,536 under
    Bun, of a 109,211-byte catalog. jq reported invalid JSON; xmd exited 0.
  • Closing the consumer early ended the process with
    node:events:487 throw er; // Unhandled 'error' event.

After:

  • The pipe receives exactly what a regular-file redirect receives, in both the
    Markdown and JSON forms, however slowly the reader consumes it.
  • Closing the consumer prints
    xmd syntax: stdout did not accept the whole catalog: write EPIPE on stderr
    and exits 1.

How it works

xmd syntax → render the catalog → deliverWhole(rendered, process.stdout) → Result<void> → report and exit 1 on failure

deliverWhole() (packages/cli/src/stdout-delivery.ts) waits for the write
callback, which is what makes the write finish before anything can exit. The
waiting half landed with #696; this change adds the failure half.

A broken pipe can arrive twice: at the write callback, and again as an
error event a tick later. An error event nobody is listening for ends the
process with a stack trace, so delivery listens — the first arrival is the
verdict and the duplicate is absorbed.

That listener lives for one delivery and no longer. It survives a failed
first arrival only until the paired event lands, and errored holding this
delivery's own failure
is what says one is still owed. Node and Deno destroy
the stream with the very error they are about to emit; Bun reports the failure
once and holds nothing, so waiting unconditionally would hang there. Success,
cancellation and a write that refuses outright all detach without waiting.

The observer is useErrorObserver(), a private resource() that attaches
before provide() and detaches in a synchronous finally, and deliverWhole()
is a scoped() that acquires it, writes, and awaits the outcome inside the
scope. That is effectionx's scope-bound event registration — a listener's
lifetime depends on a scope, never on its event firing — and the same shape as
@effectionx/node's on(). Awaiting inside the scope is what holds the
observer open for a trailing event; every other path leaves the scope and takes
the observer with it.

The outcome is bridged with withResolvers() rather than action(): a file or
a terminal calls the write callback synchronously inside write, and in
effection 4.1.0 an action() resolved before its executor has returned never
runs the cleanup that executor returns — which is exactly the common case here.
Measured:

yield* action<void>(resolve => { resolve(); return () => log.push("cleanup") })
sync resolve inside executor  -> []          # cleanup never ran
async resolve a turn later    -> ["cleanup"]

Review guide

Start with: packages/cli/tests/syntax-cli.test.ts, the
Tier SX — the catalog a pipe receives block.

Then review:

  1. specs/executable-mdx-spec.md — the delivery contract, beside the command's
    existing inspection-failure contract.
  2. deliverWhole() in packages/cli/src/stdout-delivery.ts, and the syntax
    case in packages/cli/src/cli.ts.
  3. runShell() / cliShellCommand() in packages/test-support/launch.ts.

Look carefully at:

  • The bound on how long delivery keeps observing. It is sink.errored === failure
    — identity, not presence — so an error the stream was already holding cannot
    cause a wait that never ends.
  • Tier SDL in packages/cli/tests/stdout-delivery.test.ts. Tier SD is the
    secret-detection opt-out's (Reject secrets before journal persistence by default #199); this tier is SDL to stay out of its way.

What must stay true

  • Delivery's listener never outlives the delivery that installed it — checked by
    SDL1–SDL8, which count listeners on the stream around every completion path.
  • A pipe and a file redirect receive the same bytes — checked by SX13 and SX14,
    which compare a real pipeline against a real redirect.
  • A closed sink fails the command — checked by SX15, which reads the CLI's own
    status out of band because a pipeline reports its last stage's.
  • The catalog under test exceeds one pipe buffer — asserted inside the helper,
    because a catalog that fits arrives whole however it was written.

How to verify it

deno task test packages/cli/tests/stdout-delivery.test.ts packages/cli/tests/syntax-cli.test.ts
npx tsx --tsconfig tsconfig.node.json --test packages/cli/tests/stdout-delivery.test.ts packages/cli/tests/syntax-cli.test.ts
bun test packages/cli/tests/stdout-delivery.test.ts packages/cli/tests/syntax-cli.test.ts

Deno 5 passed (27 steps) · Node 27 pass 0 fail · Bun 27 pass 0 fail.

Every break below was applied and observed, not reasoned about:

  • The pre-fix process.stdout.write(rendered): SX13, SX14 and SX15 all fail
    under Node. Under Deno only SX15 fails — Deno's process.stdout flushes a
    pipe on the way out, so it never showed the truncation. Recorded in the
    suite's own comment: verifying completeness under Deno alone proves nothing.
  • Removing the error listener when the write callback settles: SX15 fails
    and the process dies with Unhandled 'error' event, the string SX15 forbids.
  • Removing the finally teardown: all eight SDL rows fail.
  • Settling at the first arrival, without waiting for an owed event: SDL2
    alone fails — one listener at emit where two were required.
  • Waiting unconditionally for both arrivals: SDL4 hangs and fails, which is
    the Bun case.
  • Dropping scoped(), leaving a plain generator: SDL1, SDL2, SDL3, SDL4,
    SDL5, SDL7 and SDL8 fail. SDL6 still passes, because that row supplies a scope
    of its own — which is exactly why it cannot be the only lifetime row.

The runtime facts these rest on were measured against the real command, not
assumed:

runtime closed pipe, pre-fix arrivals errored at callback
Node 26 80,920 of 109,211 bytes callback, then event set
Bun 1.3 65,536 of 109,211 bytes callback only null
Deno 2.9 whole callback, then event set

Scope

Included

  • Delivery of the already-rendered xmd syntax catalog, in both forms.
  • The regression coverage, and the test-support seam that composes a pipeline.

Intentionally unchanged

  • TTY presentation and catalog generation.
  • The other process.stdout.write() sites, including xmd plan's. Issue Make piped xmd syntax output complete #715
    scopes this to xmd syntax and not to a general rewrite of CLI output; xmd plan writes a plan document rather than a catalog and has not been observed
    past the buffer.

New abstractions

  • deliverWhole() and DeliverySink in packages/cli/src/stdout-delivery.ts
    exist because the listener's lifetime is the claim, and a real pipe cannot
    show it. The narrow sink lets Tier SDL supply the arrival orders one runtime
    produces and another does not, and watch the listener come and go.
  • useErrorObserver() is private to that module and exists to make the
    listener's lifetime a scope's rather than an event's.
    @effectionx/node's once() cannot serve here: it installs its listener
    eagerly at call time and removes it only when the event fires, so a delivery
    that succeeds — the common case, where no error event ever arrives — would
    leave it attached (SDL1, SDL6 and SDL8). Its on() is correctly scope-bound,
    but yields a Stream that can only be read by suspending, and the
    first-arrival-wins rule has to consult state synchronously inside the write
    callback.
  • runShell() and cliShellCommand() in @executablemd/test-support/launch
    exist because SX13–SX15's subject is the pipeline — a file redirect and a
    reader that closes early cannot be expressed by capturing a stream this
    process owns. They reuse runCli's environment, timeout and reporting;
    bounded() now takes the launch and a label rather than the argv.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

…715)

`xmd syntax` waits for stdout to accept the whole catalog, so a pipe
receives exactly what a regular-file redirect receives however slowly it
is read. A sink that closes mid-write is now reported on stderr with exit
status 1 rather than ending the process with an unhandled write failure:
a broken pipe arrives twice — at the write callback, and again as an
`error` event a tick later — so the listener outlives the write.

Three rows in `packages/cli/tests/syntax-cli.test.ts` prove it through a
real pipeline against an oversize catalog. `runShell()` and
`cliShellCommand()` are the test-support seam that composes one.
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

PR #718: 🐛 Make piped xmd syntax output complete, and report a closed sink (#715)

8 files, +517 / -40

Scope

🟡 557 lines changed. PRs under 400 receive more thorough review.

Structural

Oxlint structural signals:

  • no-unused-vars ×2: packages/cli/src/cli.ts
  • no-empty-function ×1: packages/cli/src/cli.ts

Slop

  • packages/cli/src/stdout-delivery.ts:87// forever.
  • packages/cli/src/stdout-delivery.ts:114// else would settle this.

Oxlint slop signals:

  • no-console ×3: packages/cli/src/cli.ts

Static Analysis

Oxlint: 11 diagnostics across 2 files (6 rules)
Density: 0.021 violations/added-line

no-console (3): packages/cli/src/cli.ts
no-floating-promises (3): packages/test-support/launch.ts, packages/cli/src/cli.ts
no-unused-vars (2): packages/cli/src/cli.ts
no-empty-function (1): packages/cli/src/cli.ts
no-unsafe-type-assertion (1): packages/cli/src/cli.ts
consistent-return (1): packages/cli/src/cli.ts

Correctness

No extraneous code patterns detected.

The listener `process.stdout.on("error", …)` installed was never removed,
so it outlived the operation that owned it: repeated invocations would
accumulate listeners on a process-global stream, and a completed delivery
could absorb a later, unrelated stdout failure.

Delivery moves to `stdout-delivery.ts` behind a narrow `DeliverySink`, and
its listener now lives for exactly one delivery. A try/finally detaches on
every completion path — success, either failure order, cancellation, and a
`write` that refuses outright. The one path that waits is a failed arrival
whose paired event is still owed, and `errored` holding this delivery's own
failure is what says so: Node and Deno destroy the stream with the very
error they are about to emit, while Bun reports the failure once and holds
nothing. Waiting unconditionally would hang there.

Tier SD covers the lifetime a real pipe cannot show. SX13–SX15 and the
test-support pipeline seam are unchanged.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 redundant comments. Inline suggestions to remove them below.

Comment thread packages/cli/src/cli.ts
yield* writeStdoutWhole(rendered);
// Only the catalog goes through delivery today, because it is the one
// output this command writes in a single call and the only one already
// past a pipe buffer.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// past a pipe buffer.

Comment thread packages/cli/src/stdout-delivery.ts Outdated
});
} catch (error) {
// A stream that refuses the call outright never calls back, so
// nothing else will settle this.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// nothing else will settle this.

Tier SD is the CLI secret-detection opt-out's, and it holds SD1–SD13
(`packages/cli/tests/secret-detection-cli.test.ts`). The delivery evidence
took the same prefix, so two unrelated suites answered to one name.

The delivery tier and its eight rows become SDL. Nothing else moves: the
secret-detection tier keeps SD, and the implementation and SX13–SX15 are
untouched.
…715)

`deliverWhole` settled through a `new Promise` handed to `until()`, which
forced an `observe` placeholder declared outside the executor so the
`finally` could reach it. `withResolvers()` is Effection's own synchronous
bridge, so the listener, the write and the detach now sit in one generator.

Not `action()`: a file or a terminal calls the write callback synchronously,
inside `write`, and an `action()` resolved before its executor has returned
never runs the cleanup that executor returns (effection 4.1.0) — five SDL
rows fail that way. The detach stays in a `finally`, which is where
Effection puts synchronous cleanup.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 1 redundant comment. Inline suggestions to remove them below.

Comment thread packages/cli/src/stdout-delivery.ts Outdated
});
} catch (error) {
// A stream that refuses the call outright never calls back, so nothing
// else will settle this.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// else will settle this.

The error listener's lifetime is now a resource() of the delivery's own
scope, the way @effectionx/node's `on()` binds a listener — attached before
the write, detached in the resource's synchronous finally however the scope
ends — rather than a try/finally around the wait. `scoped()` is what makes
that scope the delivery's rather than the caller's: without it the listener
outlives the call and seven SDL rows fail.

`settled` is gone: withResolvers() ignores a second resolve, so the guard
duplicated it.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 3 redundant comments. Inline suggestions to remove them below.

Comment thread packages/cli/src/cli.ts
yield* writeStdoutWhole(rendered);
// Only the catalog goes through delivery today, because it is the one
// output this command writes in a single call and the only one already
// past a pipe buffer.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// past a pipe buffer.

}
// Identity, not presence: an error the stream was already holding before
// this delivery has been emitted already, and waiting for it would wait
// forever.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// forever.

});
} catch (error) {
// A stream that refuses the call outright never calls back, so nothing
// else would settle this.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// else would settle this.

SX15 fails under Bun on Linux and nowhere else: the command exits 0 with a
truncated catalog when the consumer closes the pipe. The captured stderr is
`xmd-exit=0` and nothing more — the diagnostic never printed, so delivery
returned Ok. Bun 1.3.14's `process.stdout.write` callback reports success on
a broken pipe there, and no `error` event follows it, so there is nothing for
the command to report. macOS reports correctly on both 1.3.14 and 1.4.0,
which is why only CI ever saw this.

1.4.0 is Bun rewritten in Rust, with 1,517 new Node.js test-suite passes.
This defect is exactly that class. The release notes do not name it, so this
commit is the experiment that answers it.

The whole Bun corpus passes under 1.4.0 locally: 4713 tests across 286 files,
0 failures. `test-weights.json` still records 1.3.14 as its measurement
provenance and wants a remeasure on a runner.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 redundant comments. Inline suggestions to remove them below.

}
// Identity, not presence: an error the stream was already holding before
// this delivery has been emitted already, and waiting for it would wait
// forever.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// forever.

});
} catch (error) {
// A stream that refuses the call outright never calls back, so nothing
// else would settle this.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// else would settle this.

taras added a commit that referenced this pull request Sep 2, 2026
Run 33583487112 attempt 3 at 77d4cbc (12/7/4): Deno shard 7/12 took
296s and the runtime's window was 301s — the shard itself is at the
ceiling. Node's steps were under (worst 255s); its 306s window was 74s of
runner queueing while #718's CI ran alongside, so Node holds at 7. Bun
passed at 237s.
@taras
taras merged commit b1c5534 into main Sep 2, 2026
30 checks passed
@taras
taras deleted the agent/issue-715-syntax-pipe branch September 2, 2026 03:07
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.

Make piped xmd syntax output complete

1 participant