Skip to content

process: fix subprocess stdin pipe jamming after the first write#1028

Merged
saghul merged 1 commit into
saghul:masterfrom
tarwin:fix-spawn-stdin-pipe-writes
Jul 12, 2026
Merged

process: fix subprocess stdin pipe jamming after the first write#1028
saghul merged 1 commit into
saghul:masterfrom
tarwin:fix-spawn-stdin-pipe-writes

Conversation

@tarwin

@tarwin tarwin commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

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.c should 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 ProcessWritableStream sink checked typeof result !== 'number' to decide whether handle.write(chunk) completed synchronously. That matches the UDP contract and was carried over in ccbb79c — but 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 fires onwrite. On the common fast path write() 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 how direct-sockets/utils.js already consumes the same API.

Tests

tests/test-spawn-stdin-pipe-multiple-writes.js writes 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.js is 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.

@saghul saghul left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for the fix! Left a small comment.

Comment thread src/js/core/process.js Outdated
const result = handle.write(chunk);

if (typeof result !== 'number') {
if (result === false) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Can you use !result instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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? :-)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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]>
@tarwin tarwin force-pushed the fix-spawn-stdin-pipe-writes branch from abca541 to 49664df Compare July 12, 2026 17:40
@saghul saghul merged commit c1f4860 into saghul:master Jul 12, 2026
40 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.

tjs.spawn(..., { stdin: 'pipe' }): only the first stdin write is delivered, write promises never resolve

2 participants