spawn: send a sliced Bun.file() at stdin in chunks of its window
Problem
Bun.spawn({ stdin: Bun.file(p).slice(10, 20) })sends the child the whole file. So dospawnSync,new Response(slice),slice.stream()and the shell.Stdio::extract_blob(src/runtime/api/bun/spawn/stdio.rs:565) turns a file Blob intoStdio::PathorStdio::Fd. Neither holds the window.
Fix
extract_blobopens the window in the parent before a child exists, and reads the first chunk.Stdio::FileWindowcarries it to theStaticPipeWriter. After each chunk the writer reads the next (at most 64 KiB) and waits for a writable event.- Verified:
test/js/bun/spawn/spawn.test.ts(43 cases, 32 fail on base). Self-reviewed: 9 concerns raised, 4 addressed (Notes).
Background
StaticPipeWriteris the writer in the parent that sends a stdin payload from memory.- This PR is on top of #41209, which adds
Blob::file_window(). - Draft: a maintainer must choose the transport. A buffer as in #31931 reads the whole window before the spawn: +537 MB of parent RSS for 512 MiB. A design review picked this design.
Downsides
- New errors: a window of a missing file throws
ENOENT(main creates the file). A window atstdio[3]or above throws (main: the whole file). - The child's fd 0 is a socket, so
/dev/stdingivesENXIO. A parent that exits afterunref()sends 0 bytes. A read error after the first chunk ends stdin early with no report. - Cost for a window: 3 syscalls at parse, then 4 per 64 KiB, one
preadon the JS thread per writable event. Other callers: 0 added syscalls.
Notes
Questions for a maintainer.
- Transport. The buffer of #31931 is small and reports every read error from
spawn(), and on Linux the child gets a memfd. Its cost grows with the window. #41209 carried a port of that buffer until 8206895c3a, and a pre-merge check of that head measured +537 MB of RSS and 616 ms on the JS thread for 512 MiB. A 3 GiB window in a 2 GB container killed the parent. This PR holds one chunk (+2.1 MB for 64 MiB and for 512 MiB,spawnSync+3.6 MB), and it pays with the costs in Downsides. A design review ranked six designs: this one first, the buffer last. A later self-review said that a person must choose between the two. landing-prs.md:31forbids a synchronous filesystem call on the JS thread in an async completion path. The refill is onepreadof at most 64 KiB in the writable callback.REVIEW.md:66asks for agreement before a feature PR adds a shared abstraction. This PR addsStdio::FileWindow,Source::FileWindowandSource::refill()to the writer thatBun.spawn, the shell andbun installshare.- A read error after the first chunk. The writer has no channel to JS, so the error takes the path of a failed write: a debug log, then the child gets EOF.
Self-review. Addressed: the open no longer creates a missing file. A window at stdio[3] throws, and the test no longer pins the whole file there. A groundwork commit that kept an unsafe move is gone, because #40204 rewrites the same arms. This change adds no unsafe. The exclusions are named below. Open: the four questions above, and the merge order.
Merge order. #41209 first. slice(-5) of a file that nothing has stat'd gives an empty stdin until #41257 merges (main: the whole file).
Not changed.
- The shell form with a path (
cat < ${Bun.file(path).slice(1, 3)}):shell_body.rsputs the path text into the command, so the window is gone beforeStdioexists. Shell builtins are the same. Bun.stdin.slice()at stdin: the slot inherits.- stdout and stderr: the child writes to the file, and a window is ignored there.
- A file that is not regular (a FIFO, a socket, a terminal, a character device) has no window. The child gets the file, as on main. The parent does not open it.
Measurements (Linux x64, debug build).
- Parent peak RSS (
VmHWM) at this head:spawn+2,056 KiB for a 64 MiB window and +2,104 KiB for 512 MiB.spawnSync+3,660 and +3,600 KiB. - Syscalls, from a ptrace logger on an earlier revision of this change (the later edits changed error paths only): a 64 MiB window is 1,024
pread64of 65,536 bytes and 1,024sendto, with at most 1pread64between twoepollwaits. At parse a path window isnewfstatat,openat,pread64. A descriptor window isfstat,fcntl(F_DUPFD_CLOEXEC),pread64. Both are the same at 1 MiB and at 512 MiB. An unsliced file adds none. - Nine stdin kinds that do not slice (
"ignore","pipe","inherit",Bun.file(path),Bun.file(fd), a Blob, a TypedArray, the stream of an unsliced file, a JSReadableStream), 20 spawns each: the same counts ofopenat,fstat,fcntl,socketpair,memfd_create,sendtoandvforkas the base. size_ofis unchanged:Stdio384,Source24,StaticPipeWriter<Subprocess>384.- Binary size was not measured: no release build of this head exists yet.
Test runs (Linux x64, debug build with ASAN).
- The new block: 43 pass. On the base (#41209) 32 of the 43 fail.
- All of
spawn.test.ts: one timeout ingcTick > spawn > stdout can be read, which takes 3 to 4.5 s of its 5 s here and passes alone. The machine had a load average above 240 on 16 cores. spawnSync.test.ts17 pass,spawn-empty-arrayBufferOrBlob.test.ts3 pass,spawn-stdin-pipe-fd-leak.test.ts4 pass,bunshell.test.ts436 pass.bun run rust:check-all: 12 targets pass.- CI at 448a2f834e (Buildkite 121866): the new block passed on macOS. On Windows it passed except two cases that compare the path of the error, because the error carried the NT form of the path (
\\?\C:\...). ec3c407fc6 reports the path that the caller gave. So the Windows writer and the restore of the file position ran in CI. I did not run Windows or macOS by hand.
Overlap. #40204 (remove unsafe from subprocess.rs and shell/subproc.rs) and #42819 (remove libuv on Windows) rewrite the files of the writer. This PR adds separate match arms and leaves the existing arms as they are.
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/spawn.test.ts