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).
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
Reading the child's stdout shows it received
"one\n"only; neither write promise ever settles.Root cause
ProcessWritableStream's sink insrc/js/core/process.jsdecides whetherhandle.write(chunk) completed synchronously withtypeof result !== 'number'. That check matches the UDP contract —tjs_udp_send returnsthe byte count (JS_NewInt64) inline and undefined when an async send was queued — and appears to have been carried over from there inccbb79c("process: consolidate code for subprocess stdio handles").But the native method it actually calls,
tjs_stream_write(src/mod_streams.c), returns booleans:JS_TRUEwhenuv_try_writedelivered the whole chunk inline,JS_FALSEwhen an asyncuv_writewas 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.jsconsumes 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 wheneveruv_try_writeaccepts the whole chunk, which is the normal case for small writes to a pipe).