fix(stream): yield exit chunks and prevent hang on open pipes (#155)#169
Conversation
Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: #155
stream() now yields a final { type: 'exit', code } chunk when the
process exits, and neither stream() nor awaiting a command hangs when a
grandchild keeps the stdio pipes open.
- Add onExit handler to stream() that pushes { type: 'exit', code }.
- Extract exit detection + pump draining into $.process-runner-exit.mjs:
createExitPromise resolves on the child 'exit' event (not 'close') or
on cancellation, and drainPumpsAfterExit waits a short grace period for
buffered output before aborting lingering stdio reads.
- pumpReadable accepts an AbortSignal so a pending read can be cancelled
when pipes stay open after exit.
- exitPumpGrace option (default 100ms) configures the grace period.
- Tidy quote.mjs lint warnings touched by the pump change.
Add stream-exit-chunks.test.mjs verifying exit chunks (success and non-zero code) and that stream()/await do not hang when a grandchild keeps stdout open. Update the virtual async-generator test to guard on chunk.type now that exit chunks are yielded.
Working session summaryCI passed. Everything is complete. SummaryIssue #155 is fully resolved and PR #169 is ready for review. What was fixed:
Final state:
PR: #169 This summary was automatically extracted from the AI working session output. |
🤖 Solution Draft LogThis log file contains the complete execution trace of the AI solution draft process. 💰 Cost: $11.468476📊 Context and tokens usage:Claude Opus 4.8: (3 sub-sessions)
Total: (36.3K new + 236.3K cache writes + 14.2M cache reads) input tokens, 107.6K output tokens, $11.468476 cost 🤖 Models used:
📎 Log file uploaded as Gist (5428KB)Now working session is ended, feel free to review and add any feedback on the solution draft. |
✅ Ready to mergeThis pull request is now ready to be merged:
Monitored by hive-mind with --auto-restart-until-mergeable flag |
This reverts commit a1ec55a.
|
Double check there will be no breaking changes, and everything will work in the best possible way. If that breaks previously documented behavior, we might need to be able to configure it. Do we really need Also inside the loop we should have ability to stop the process. For example we may be can save to a variable, and access some fields or functions or both for the user to choose. See how competitors do it or what are best practices for perfect API. We may need to support both AbortSignal style (by giving access to it) and also explicit cancel/stop. |
|
🤖 AI Work Session Started Starting automated work session at 2026-06-09T22:03:41.810Z The PR has been converted to draft mode while work is in progress. This comment marks the beginning of an AI work session. Please wait for the session to finish, and provide your feedback. |
Address PR review feedback: document and test stopping a long-running command from inside the stream() loop, and clarify the exitPumpGrace rationale. - Add regression tests: kill() inside the loop ends the iterator with a terminating exit chunk; break also kills the process; and a normal command's exit chunk is yielded with zero added latency (the grace only applies when a grandchild holds the pipe open). - README: document stopping from inside the loop (kill() / break). - Example: demonstrate kill() inside the loop. - Changeset: note the stop-in-loop behavior and that grace adds no latency for ordinary commands.
|
Thanks for the review — I dug into all three points. Summary: exit detection is already immediate and event-driven; the grace period is not about detecting exit, only about not truncating the last line of output; and stopping from inside the loop already works (now documented + tested). 1. No breaking changesThe full suite passes with this branch (only the pre-existing The only behavior change is the additive 2. Do we really need
|
exitPumpGrace |
elapsed | output captured |
|---|---|---|
0 |
~11ms | done ✅ (but racy — can truncate the trailing chunk) |
100 (default) |
~106ms | done ✅ |
200 |
~206ms | done ✅ |
So: immediate exit detection ✔, no latency for normal commands ✔, and the grace is a safety margin only for the open-grandchild-pipe edge case. It's already configurable (set exitPumpGrace: 0 for strict immediacy), and the default of 100ms trades ~100ms in that rare case for not truncating output.
3. Ability to stop the process from inside the loop
This works today — I've now documented it and added regression tests. Two ways:
const cmd = $`some-endless-stream`;
for await (const chunk of cmd.stream()) {
if (chunk.type === 'stdout' && enough(chunk)) {
cmd.kill(); // loop then ends with an exit chunk (code 143 for SIGTERM)
} else if (chunk.type === 'exit') {
console.log('stopped with code', chunk.code);
}
}
// Or just break — the iterator's cleanup kills the process:
for await (const chunk of $`some-endless-stream`.stream()) {
if (done(chunk)) break;
}New tests in stream-exit-chunks.test.mjs:
kill()inside the loop stops promptly and still yields a terminating exit chunkbreakinside the loop terminates the process (cmd.finished === true)- a normal command's exit chunk arrives with zero added latency
All passing under both Bun and Node. Pushed in 9643a99.
🤖 Solution Draft LogThis log file contains the complete execution trace of the AI solution draft process. 💰 Cost: $3.866656📊 Context and tokens usage:
Total: (4.9K new + 91.4K cache writes + 5.0M cache reads) input tokens, 31.2K output tokens, $3.866656 cost 🤖 Models used:
📎 Log file uploaded as Gist (1843KB)Now working session is ended, feel free to review and add any feedback on the solution draft. |
✅ Ready to mergeThis pull request is now ready to be merged:
Monitored by hive-mind with --auto-restart-until-mergeable flag |
|
Can we configure signals we stop with also? And double check all the same is implemented in Rust syntax (if Rust supports something similar like some async iterators and so on). And anyway also double check that all features that are supported in JavaScript are fully supported in Rust and we have CI/CD rules, that check that we do changes in both languages always. |
|
🤖 AI Work Session Started Starting automated work session at 2026-06-09T23:01:31.308Z The PR has been converted to draft mode while work is in progress. This comment marks the beginning of an AI work session. Please wait for the session to finish, and provide your feedback. |
- Add a killSignal option (default SIGTERM) used by argument-less kill(),
break out of a stream() loop, and external AbortSignal cancellation.
- An explicit kill(signal) argument still overrides the configured signal.
- Exit codes follow the conventional 128 + signal mapping (SIGTERM=>143,
SIGINT=>130, SIGKILL=>137, ...) resolved from os.constants.signals.
- Fix a pre-existing hang: awaiting a command while an external AbortSignal
fired never resolved because the abort listener was only registered on the
start({...}) path. Register it (idempotently) in _doStartAsync so the
await/then path honors options.signal too.
- Tests + README updated for #155.
Bring the Rust StreamingRunner/OutputStream to parity with the JavaScript stream() implementation: - Yield an explicit OutputChunk::Exit(code) when the process exits. - No-hang on open pipes: wait on the child exit independently of the stdio readers (the old code awaited the readers first, which hangs when a grandchild keeps the pipe open), then drain readers for a grace period and abort lingering ones. The exit chunk is always delivered. - Configurable stop signal via StreamingRunner::kill_signal (default SIGTERM), mirroring the JS killSignal option; exit codes use the 128 + signal mapping. - Stop from inside the loop with OutputStream::kill()/kill_with(signal); dropping the stream (break) stops the process too (Drop sends a kill). - Run the child in its own process group so signals reach grandchildren. Adds Rust regression tests, a changelog fragment, and README docs.
Add a Language parity check workflow that fails a PR when js/src/** changes without rust/src/** (or vice versa), so behavioral changes are always made in both implementations. A 'parity-exempt' label skips the check for intentionally single-language PRs. Implements the CI/CD parity enforcement requested in the #155 review.
Working session summaryCI is still running (parity already green). The background poll will notify me when JS and Rust complete. Let me wait for it. I have completed the substantive work and am now waiting on CI confirmation for the final commit (9a3035a). Here's where things stand: Done this session (finalization):
Remaining: confirm JS + Rust CI go green on This summary was automatically extracted from the AI working session output. |
🤖 Solution Draft LogThis log file contains the complete execution trace of the AI solution draft process. 💰 Cost: $10.401908📊 Context and tokens usage:Claude Opus 4.8: (3 sub-sessions)
Total: (29.6K new + 335.2K cache writes + 11.2M cache reads) input tokens, 103.0K output tokens, $10.401909 cost 🤖 Models used:
📎 Log file uploaded as Gist (4794KB)Now working session is ended, feel free to review and add any feedback on the solution draft. |
✅ Ready to mergeThis pull request is now ready to be merged:
Monitored by hive-mind with --auto-restart-until-mergeable flag |
Summary
Fixes #155. The
stream()async iterator had two problems:exitchunks —stream()only yielded{type:'stdout'|'stderr', data}chunks. The documentedchunk.type === 'exit'handling was dead code, so the exit code could only be read from(await cmd).codeafter the loop.finish()(which emits'end', ending the iterator) was gated behindPromise.all([outPump, errPump, ...]). If a grandchild inherited the stdout pipe and kept it open (e.g.sh -c 'task & echo done'), the pump never finished and bothstream()andawaithung forever.Changes
stream()now pushes a final{ type: 'exit', code }chunk when the process exits (emitted byfinish()right after'end'), so the documented pattern works.src/$.process-runner-exit.mjs, extracted from execution):createExitPromise()resolves on the child'exit'event (not'close', which waits for all stdio to close) — and also on cancellation, so a kill that removes child listeners doesn't hang the awaiting caller. Exit detection is immediate and event-driven.drainPumpsAfterExit()waits a short grace period for buffered output to drain after exit, then aborts lingering stdio reads.pumpReadable()accepts anAbortSignalso a pendingread()(Web/BunReadableStreamvia an explicit reader, or a NodeReadableviadestroy()) can be interrupted when pipes stay open.exitPumpGraceoption (default100ms) configures the grace period.kill()(the loop ends with a terminatingexitchunk) or bybreaking out of the loop (the process is killed as the iterator unwinds). Documented + tested.On the
exitPumpGracegrace period (review question)Exit detection is immediate — we subscribe to the child
'exit'event. The grace period is a separate, bounded wait to drain output the OS pipe still holds after the process exits (the'exit'event and the final stdout'data'/EOF are independent, unordered events; aborting the pumps the instant'exit'fires could truncate the last line).echo quickthroughstream()completes in 0–1ms; the 100ms timer never elapses.exitPumpGrace: 0for strict immediacy (note: racy — can truncate the trailing chunk).Reproduction
Stopping from inside the loop:
Run the bundled demo:
bun js/examples/stream-exit-chunks.mjsTests
js/tests/stream-exit-chunks.test.mjs(all passing under Bun and Node):stream()does not hang when a grandchild keeps stdout open (elapsed < 10s)awaitdoes not hang in the same scenariokill()inside the loop stops promptly and yields a terminating exit chunkbreakinside the loop terminates the processThe full suite was run before and after: the only remaining failures are pre-existing and environmental (no
jqbinary installed locally; CI installsjqand they pass) — identical with and without this change.Compatibility note
Yielding exit chunks is a behavior change: consumers that read
chunk.datainside astream()loop must now guard onchunk.typefirst (theexitchunk carriescode, notdata). The README'sstream()examples have always guarded onchunk.type, so idiomatic consumers are unaffected. Hence the minor changeset.Fixes #155
Review follow-up (2026-06-09)
Addresses the three asks in the latest review.
1. Configurable stop signal
The signal used to stop a process is now configurable instead of hardcoded
SIGTERM.JavaScript: new
killSignaloption (default'SIGTERM'). It is honored by anargument-less
kill(), by abreakout of the loop, and by an externalAbortSignal. An explicitkill(signal)argument still overrides it. Exit codesfollow the conventional
128 + signalmapping (e.g.SIGINT=> 130,SIGKILL=> 137).Also fixed: awaiting a command while an external
AbortSignalfires no longer hangs.The abort listener was only registered on the
start()path; it is now registered onthe
await/then()path too (idempotently), soawait runningresolves promptly withthe configured signal's exit code.
2. Rust parity
The same stream features now exist in the Rust implementation (
rust/src/stream.rs),which already exposes an async-iterator-style
OutputStream(stream.next().await):OutputChunk::Exit(code)is always yielded when the process exits.independent of the readers (
tokio::select!), readers are drained forexit_pump_grace_ms(default 100ms, configurable) and then aborted.stream.kill()(configured signal)or
stream.kill_with(signal)(explicit override); dropping the stream (break) stopsit too (
impl Drop).StreamingRunner::kill_signal(..)(defaultSIGTERM), mirroring the JSkillSignaloption, with the same128 + signalexit codes.Covered by
rust/tests/stream.rs(exit-chunk-last, no-hang-on-open-pipe, kill-honors-signal,kill_with-override, break-stops-process) and a
rust/changelog.dfragment.3. CI/CD enforces dual-language changes
New Language parity check workflow (
.github/workflows/parity.yml+.github/scripts/check-language-parity.sh): a PR that changesjs/src/**withoutrust/src/**(or vice versa) fails the check, so behavioral changes are always made inboth languages. Escape hatch: the
parity-exemptlabel skips the check for legitimatelysingle-language changes.