process: fix subprocess stdin pipe jamming after the first write#1028
Merged
Conversation
saghul
reviewed
Jul 11, 2026
saghul
left a comment
Owner
There was a problem hiding this comment.
Thanks for the fix! Left a small comment.
| const result = handle.write(chunk); | ||
|
|
||
| if (typeof result !== 'number') { | ||
| if (result === false) { |
Contributor
Author
There was a problem hiding this comment.
Could do that. Although I do feel that being explicit about "false" is better here given that we would not expect just generally "falsey" results. But I'm very much not wedded to this decision.
Owner
There was a problem hiding this comment.
I see your point but I feel like if we change the internal API breaking this we'd need to go through all parts of the code which use it anyway.
Can you change it please? :-)
Contributor
Author
There was a problem hiding this comment.
Sorry for the delay. I've updated the pull request.
With tjs.spawn(..., { stdin: 'pipe' }) only the first write to the
child's stdin was delivered; its promise never resolved and every
subsequent write queued forever.
The ProcessWritableStream sink decided whether handle.write(chunk)
completed synchronously by checking `typeof result !== 'number'`.
That check matches the UDP contract (tjs_udp_send returns the byte
count on the inline path and undefined when an async send is queued)
and was carried over in ccbb79c, but the stream write it actually
calls (tjs_stream_write) returns booleans: true when uv_try_write
delivered the whole chunk inline, false when an async uv_write was
queued whose completion later fires onwrite.
So on the common fast path write() returned true, the sink misread
that as "async write pending", pushed a resolver onto the onwrite
queue and awaited a promise nothing would ever resolve — no async
write existed. The first chunk was still delivered (the try-write
had succeeded), which made the jam look like "only the first write
works". Check for the boolean the native method actually returns,
matching how direct-sockets already consumes the same API.
Co-Authored-By: Claude Fable 5 <[email protected]>
abca541 to
49664df
Compare
saghul
approved these changes
Jul 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note: this fix was authored by an AI agent (Claude Code / Claude Fable 5). I went over the changes multiple times to confirm. Understand if you want to ignore as AI slop. I wasn't sure if
core.cshould be included or not but left as is.Fixes #1027
Problem
With
tjs.spawn(..., { stdin: 'pipe' }), only the first write to the child's stdin was delivered; its promise never resolved and every subsequent write queued forever.Cause
The
ProcessWritableStreamsink checkedtypeof result !== 'number'to decide whether handle.write(chunk) completed synchronously. That matches the UDP contract and was carried over inccbb79c— buttjs_stream_writereturns booleans: true whenuv_try_writedelivered the whole chunk inline,falsewhen an asyncuv_writewas queued whose completion fires onwrite. On the common fast pathwrite()returned true, the sink misread that as "async write pending" and awaited an onwrite completion that never came. The first chunk was still delivered, which made the jam look like "only the first write works".Fix
Check for the boolean the native method actually returns
(result === false), matching howdirect-sockets/utils.jsalready consumes the same API.Tests
tests/test-spawn-stdin-pipe-multiple-writes.jswrites three chunks to a child's piped stdin, awaits each write promise, and asserts the child echoed all of them back (hangs on the second write without the fix).tests/helpers/stdin-cat.jsis a portable cat (tjs.stdin.pipeTo(tjs.stdout)) run via tjs.exePath, so the test also works on Windows. Full test suite passes locally (macOS arm64). Includes the rebuilt src/bundles/c/core/core.c.