Skip to content

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

Description

@tarwin

Note: this bug was found, root-caused, and written up by an AI agent (Claude Code / Claude Fable 5). I double checked the problem, and read through this multiple times to double check the problem. Waiting a few days before publishing as to not add to AI slop. Please ignore if you want.

Symptom

With a subprocess spawned with stdin: 'pipe', only the first write to proc.stdin reaches the child. The promise returned by writer.write() never resolves, so the WritableStream's internal queue jams and every subsequent write is queued forever.

Reproduction

const proc = tjs.spawn(['cat'], { stdin: 'pipe', stdout: 'pipe' });
const writer = proc.stdin.getWriter();
const enc = new TextEncoder();

await writer.write(enc.encode('one\n')); // delivered, but this await never returns
await writer.write(enc.encode('two\n')); // never even starts

Reading the child's stdout shows it received "one\n" only; neither write promise ever settles.

Root cause

ProcessWritableStream's sink in src/js/core/process.js decides whether handle.write (chunk) completed synchronously with typeof result !== 'number'. That check matches the UDP contract — tjs_udp_send returns the byte count (JS_NewInt64) inline and undefined when an async send was queued — and appears to have been carried over from there in ccbb79c ("process: consolidate code for subprocess stdio handles").

But the native method it actually calls, tjs_stream_write (src/mod_streams.c), returns booleans: JS_TRUE when uv_try_write delivered the whole chunk inline, JS_FALSE when an async uv_write was queued (whose completion later fires onwrite).

So on the common fast path write() returns true, the sink misreads that as "async write pending", pushes a resolver onto the onwrite queue, and awaits a promise nothing will ever resolve — no async write exists, so onwrite never fires. The first chunk was delivered (the try-write succeeded), which is what makes it look like "only the first write works".

For comparison, src/js/core/direct-sockets/utils.js consumes the same native API with the correct boolean check: if (this.#handle.write(buf)) { return Promise.resolve(); }

Fix

One line in the sink: if (result === false) {. Verified locally: with the fix, all write promises resolve and the child receives every chunk. PR incoming.

Environment

txiki.js master (reproduced at fa9340a), macOS arm64; the bug is platform-independent (it triggers whenever uv_try_write accepts the whole chunk, which is the normal case for small writes to a pipe).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions