native-readable: accept a stream with no native handle
Problem
- A debug build asserts after a short-lived child exits when one of its piped stdio streams was never touched:
[internal:streams/native-readable] ASSERTION FAILED: typeof bunNativePtr === "object" Invalid native ptratconstructNativeReadable (internal:streams/native-readable:51). Repro:spawn("/bin/sh", ["-c", "echo hello"], { stdio: ["inherit", "pipe"] }), readstdout, never touchstderr. The assertion throws out of the exit handler, so'exit'and'close'never fire. The in-tree Node testtest/js/node/test/parallel/test-stdin-from-file-spawn.jsfails underbun bdfor this reason. - The cause: when the child's stderr pipe closes with no output before JS reads the stream, the native side turns the pipe into an empty buffer (
subprocess.rs:on_close_io) andReadable::to_jsreturnsReadableStream::empty(subprocess/Readable.rs:235). That stream has no$bunNativePtr.#handleOnExitinchild_process.tsconstructs the node stream at that point, andconstructNativeReadableasserted on the missing handle. - Release builds strip
$assert. There,read()already ends a handle-less NativeReadable (if (!ptr) push(null)), anddestroy,ref,unrefalready guard a missing handle. The only defect is the assertion.
Fix
constructNativeReadable(src/js/internal/streams/native-readable.ts:59) acceptsundefinedas the handle. The rest of the module already treatsundefinedas "nothing left to read".child_process.tskeeps its single construction path, sochild.stdoutandchild.stderrstay one class.- Verified:
test/js/node/child_process/child_process.test.ts(new block "piped stdio first touched after the child exits", the subprocess test fails on the unfixed debug build). Alsotest/js/node/child_process/,test/js/node/stream/,test/js/node/tty/,process-stdin.test.ts, and 27 upstreamtest-child-process-{stdio,exec,spawn}-*andtest-stdin-from-file-spawnfiles.
Background
Bun.spawnreads apipestdout/stderr into a bufferedPipeReaderuntil JS touches the stream. At EOF before that, the bytes move intoReadable::Buffer. An empty buffer becomes a plain closedReadableStream. A non-empty one becomes a blob-backed stream with a native handle.node:child_processwraps a native stream withnative-readable.ts, aReadablewhose_readpulls from the$bunNativePtrhandle. With no handle,_readpushesnull, so the stream ends and then closes like an empty pipe.
Notes
Self-reviewed: 11 concerns raised, all addressed by the rework. The first version of this PR branched in child_process.ts to Readable.fromWeb when the handle was missing, with stub ref()/unref(). The review found that shape wrong: it duplicated the tryTransferToNativeReadable predicate, made the stream class depend on whether EOF beat waitpid, and sat on the line that #36316, #39760 and #30850 rewrite (and #36316's unconditional constructNativeReadable call would trip the same assert). The one-line change in native-readable.ts covers all callers.
The second new test (in-process, state checks after 'close') passes on the unfixed build for every stdio shape tried (["ignore","pipe"], ["ignore","pipe","pipe"], ["inherit","pipe"]): inside the test runner the exit notification arrives before the pipe EOF, so the stream takes the native path. It is kept as a check of the end state, not as the fail-before proof. The subprocess test is the proof.
Pre-existing in this container, with or without the change: child_process.test.ts "spawn in the default shell" fails, "set env" and "extra stdio pipes are not double-closed on GC" exceed their 5 s budgets (four sequential debug-bun spawns at 1.5 s each, and 7.5 s standalone for the GC test).
no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/child_process/child_process.test.ts