spawnSync: give the child a real pipe for stdin:"pipe" instead of the null device
A bare stdin: "pipe" under Bun.spawnSync / child_process.spawnSync was remapped to "ignore" (in Stdio::to_sync), so the child's fd 0 was /dev/null on POSIX or NUL on Windows rather than a pipe.
Inside a Windows AppContainer the default \Device\Null ACL denies the sandboxed token, so uv_spawn fails with EPERM for a spawnSync the user asked to be piped, even though the same stdio works on async Bun.spawn and on stdout/stderr:
// inside an AppContainer
Bun.spawnSync({ cmd: ["cmd","/c","exit","0"], stdio: ["pipe","inherit","inherit"] }) // throws EPERM
Bun.spawnSync({ cmd: ["cmd","/c","exit","0"], stdio: ["inherit","pipe","pipe"] }) // exitCode 0
child_process.spawnSync("cmd", ["/c","exit","0"], { stdio: ["pipe","pipe","pipe"] }) // error.code EPERM
Bun.spawn({ cmd: ["cmd","/c","exit","0"], stdio: ["pipe","inherit","inherit"] }) // exitCode 0 (async was fine)
Fix
Drop the Pipe -> Ignore remap. The pipe is created as requested, and the FileSink is ended right after spawn so the child reads EOF (Node's SyncProcessRunner closes its stdin pipe after writing input). Writable::Pipe already reports no pending activity, so the sync wait loop is unaffected; on_process_exit -> FileSink::on_attached_process_exit still runs at reap time as before.
What the child sees on fd 0 for stdin: "pipe" with no input now matches Node: a pipe/socket, not a character device.
$ node -e "const r=require('child_process').spawnSync(process.execPath,['-e','console.log(require(\"fs\").fstatSync(0).isCharacterDevice())'],{stdio:['pipe','pipe','inherit']});console.log(String(r.stdout))"
false
$ bun -e "..." # before: true after: false
The blocking fast path already requires all three stdio slots non-piped. Bun.spawnSync's default stdin is "ignore" and child_process.spawnSync's default already pipes stdout/stderr, so neither default changes eligibility. A call that explicitly passed stdin: "pipe" with non-piped stdout/stderr loses fast-path eligibility, which is correct: it asked for a pipe.
macOS fs.realpath on /dev/stdin
The socket stdin surfaced a pre-existing gap in realpath_inner on macOS (exercised by test-fs-realpath-pipe.js): bun resolves via open(O_RDONLY) + F_GETPATH, but XNU's fdesc rejects open("/dev/fd/N", O_RDONLY) with EACCES when N is a socket/pipe whose f_flag is not exactly FREAD, and F_GETPATH has no vnode to report for a dup'd socket anyway. Node's fs.realpath lstat-walks and never opens the target, so it returns /dev/fd/0. On that EACCES bun now falls back to realpath(3), which is the same lstat walk. Only the denied-open case changes; the normal F_GETPATH path is untouched.
Relation to #32977 and #33119
#32977 stopped staging stdin data in a memfd; its body explicitly scoped out making the no-input "pipe" case a socket, noting it "touches the sync path to close the parent end right after spawn" and that "no tool observes" the /dev/null vs closed-socket distinction. The AppContainer NUL denial is the concrete case where it is observed, so this PR is that follow-up. If #32977 lands first, its stdin 'pipe' without input is /dev/null assertion needs updating to socket: true, chr: false.
#33119 (AppContainer support) currently works around this by passing stdin: "inherit" in its probes; once this lands and is merged into that branch the workaround can be dropped.
Verification
# fail-before
$ git stash push -- src/ && bun bd test test/js/bun/spawn/spawnSync.test.ts -t "stdin 'pipe' gives"
(fail) spawnSync > stdin 'pipe' gives the child a pipe (not the null device) that reads EOF
isChar: true (expected false)
# pass-after
$ git stash pop && bun bd test test/js/bun/spawn/spawnSync.test.ts -t "stdin 'pipe' gives"
(pass) spawnSync > stdin 'pipe' gives the child a pipe (not the null device) that reads EOF
Same result on Windows. Cherry-picked onto #33119 and run inside a real AppContainer on Server 2019:
bunSpawnSync_stdinPipe: exit=0 (was threw:EPERM)
cpSpawnSync_stdinPipe: exit=0 (was err:EPERM)
test/js/node/test/parallel/test-child-process-spawnsync*.js, spawnSync.test.ts, and spawn.test.ts all pass.
no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/spawn/spawnSync.test.ts