spawn: reject ReadableStream, Request and Response for stdout/stderr

RO
1mo ago
+106-42

Problem

  • Bun.spawn(cmd, { stdout: new ReadableStream() }) is accepted. proc.stdout is undefined and the child's fd 1 is a socket that nothing reads. A child that fills the socket buffer blocks forever.
  • Cause: the ReadableStream branch of Stdio::extract (src/runtime/api/bun/spawn/stdio.rs) never rejects slot 1 or 2. Readable::init then drops the parent end.
  • A Request or Response with no body at stdout silently becomes "ignore". An unknown value throws the stdio-array message for every slot.

Fix

  • reject_as_output() rejects ReadableStream, Request and Response at stdout/stderr: ReadableStream cannot be used for stdout. To read the subprocess's stdout, use 'pipe' and read from subprocess.stdout.
  • The fallthrough error names the slot and lists the kinds it accepts.
  • Correct because the parent reads stdout/stderr. Spawn.Readable in bun.d.ts already excludes these kinds.
  • Verified: test/js/bun/spawn/spawn.test.ts (two new tests fail on 1.4.3 and pass here, the whole file passes).

Background

  • Stdio::extract(i, value) parses one stdio slot. Slot 0 is stdin, 1 is stdout, 2 is stderr.
  • For stdin, the parent pipes the stream into the child through a socketpair. Only stdin attaches a writer to the parent end.
  • Considered treating the stream as 'pipe'. Nothing can write into the caller's ReadableStream from outside, so the output would still reach nobody.

Downsides

  • stdout: new Response(null) or new Request(url) was accepted as "ignore". It now throws.
  • Code that matches the old message texts breaks: stdio must be an array of 'inherit', 'ignore', or null, and Blobs are immutable, and cannot be used for stdout/stderr for a Response with a body.
Notes
  • The unknown values in the tests are "bogus", true, {}, a URL and a Proxy around Bun.file().
  • Repro: Bun.spawn(['sh','-c','head -c 1000000 /dev/zero; echo done >&2'], { stdout: new ReadableStream(), stderr: 'inherit' }) printed child fd1 -> socket:[...] and was still running after 3 s.
  • Callers that pass 'pipe', 'inherit', 'ignore', null, a number or Bun.file() run the same code as before. The new check runs only on the Request, Response and ReadableStream branches.
  • Binary size: the message literals grow by 248 bytes net (counted from the source, not measured on a release build).
  • The check runs after the existing to_any_blob() shortcut, so a blob-backed stream still goes through extract_blob(). Index 3 and up keeps ReadableStream cannot be used for stdio[N] yet. Readable::init's Stdio::ReadableStream arm becomes unreachable!(), like the SocketFd arm next to it.
  • The stdin list for Bun.spawnSync omits ReadableStream, and the stdio[N] list omits 'socket-fd', because spawnSync rejects both.
  • Not addressed here: stdin: s3File with an unreachable endpoint gives the child EOF and the connection error surfaces nowhere. That is the S3 download path, not the option parser.
  • spawn-stdin-readable-stream*.test.ts on the debug build: 63 tests pass. Three time out at 5 s, with and without this change (ReadableStream object type count, does not leak native FileSink when ReadableStream is used as stdin, ReadableStream with spawn options variations). Each starts several debug-build child processes.
  • Rebased on main. The two runner commits that were on this branch landed on main separately.

no test proof · iteration 5 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/spawn/spawn.test.ts