From 448a2f834ee51b2d5dcff8a0aed82f2ffe45cd68 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:59:34 +0000 Subject: [PATCH 1/2] spawn: send a sliced Bun.file() at stdin in chunks of its window A sliced Bun.file() at stdin gave the child the whole file: the child got the path or the descriptor of the store, and the window of the Blob was lost. Stdio::extract_blob now opens a window of a regular file in the parent, before a child exists, and reads its first chunk. Stdio::FileWindow carries it to the StaticPipeWriter as Source::FileWindow. When the writer has sent a chunk, it reads the next one (64 KiB at most) and waits for the next writable event. The parent holds one chunk for any length of the window. A window that the parent cannot open throws from spawn(). A window at stdio[3] or above throws: only stdin has a writer in the parent. A file that is not regular has no window, and the child gets that file as before. Co-authored-by: Dylan Conway --- src/runtime/api/bun/spawn/stdio.rs | 63 ++- src/runtime/api/bun/subprocess.rs | 3 +- src/runtime/api/bun/subprocess/Readable.rs | 2 + src/runtime/api/bun/subprocess/Writable.rs | 16 +- src/runtime/shell/subproc.rs | 16 + src/spawn/file_window.rs | 133 +++++++ src/spawn/lib.rs | 15 + src/spawn/static_pipe_writer.rs | 14 + test/js/bun/spawn/spawn.test.ts | 426 ++++++++++++++++++++- 9 files changed, 681 insertions(+), 7 deletions(-) create mode 100644 src/spawn/file_window.rs diff --git a/src/runtime/api/bun/spawn/stdio.rs b/src/runtime/api/bun/spawn/stdio.rs index b27a514e0968..d2bc872a2f0c 100644 --- a/src/runtime/api/bun/spawn/stdio.rs +++ b/src/runtime/api/bun/spawn/stdio.rs @@ -1,6 +1,6 @@ #[cfg(any(target_os = "linux", target_os = "android"))] use bun_collections::VecExt; -use bun_jsc::{self as jsc, JSGlobalObject, JSValue, JsResult}; +use bun_jsc::{self as jsc, JSGlobalObject, JSValue, JsResult, SysErrorJsc as _}; #[cfg(any(target_os = "linux", target_os = "android"))] use bun_sys::FdExt as _; #[cfg(windows)] @@ -9,13 +9,16 @@ use bun_sys::{self as sys, Fd}; // `bun.jsc.WebCore` lives in this crate (not `bun_jsc`); alias so the body can // say `webcore::ReadableStream` / `webcore::body::Value`. +use crate::node::types::PathLikeExt as _; use crate::webcore; use crate::webcore::blob::store::Data as StoreData; +use crate::webcore::blob::{BlobExt as _, MAX_SIZE}; use crate::webcore::node_types::{PathLike, PathOrFileDescriptor}; // `bun.jsc.Subprocess.StdioKind` is owned by `process.rs` (defined there to // keep `process` leaf; `subprocess` re-exports it). use crate::api::bun_process::{self as process, Dup2 as ProcessDup2, StdioKind}; +use crate::api::bun_subprocess::{FileWindow, file_window}; // `SpawnOptions.Stdio` is platform-dependent: process.rs exposes `PosixStdio` / // `WindowsStdio`; alias the active one as `SpawnOptionsStdio` so the body stays @@ -60,6 +63,8 @@ pub(crate) enum Stdio { Dup2(Dup2), Path(PathLike<'static>), Blob(webcore::blob::Any), + /// A sliced `Bun.file()` at stdin: the parent sends the window in chunks. `None` after `Writable::init` gave it to the writer. + FileWindow(Option>), #[cfg(any(target_os = "linux", target_os = "android"))] Memfd(Fd), Pipe, @@ -270,7 +275,9 @@ impl Stdio { out: d.out, to: d.to, }), - Self::Capture(_) | Self::Pipe | Self::ReadableStream(_) => buffer(), + Self::Capture(_) | Self::Pipe | Self::ReadableStream(_) | Self::FileWindow(_) => { + buffer() + } #[cfg(not(windows))] Self::SocketFd => SpawnOptionsStdio::SocketFd, // Windows extra-stdio is a libuv pipe handle (no raw-fd ownership @@ -292,7 +299,11 @@ impl Stdio { pub(crate) fn is_piped(&self) -> bool { match self { - Self::Capture(_) | Self::Blob(_) | Self::Pipe | Self::ReadableStream(_) => true, + Self::Capture(_) + | Self::Blob(_) + | Self::FileWindow(_) + | Self::Pipe + | Self::ReadableStream(_) => true, Self::Ipc => cfg!(windows), _ => false, } @@ -573,6 +584,19 @@ impl Stdio { if blob.needs_to_read_file() { if let Some(store) = blob.store() { if let StoreData::File(ref file) = store.data { + // stdout and stderr write to the file, so a window means nothing there. + let window = match &blob { + webcore::blob::Any::Blob(blob) if i != 1 && i != 2 => blob.file_window(), + _ => None, + }; + if window.is_some() && i != 0 { + // Only stdin has a writer in the parent, and the child would read the whole file. + return Err(global.throw_invalid_arguments(format_args!( + "A sliced Bun.file() cannot be used for stdio[{i}] yet" + ))); + } + let window = + window.map(|(offset, size)| (offset, (size != MAX_SIZE).then_some(size))); match file.pathlike { PathOrFileDescriptor::Fd(store_fd) => { if Some(store_fd) == fd { @@ -602,12 +626,28 @@ impl Stdio { } } + if let Some((offset, length)) = window { + let opened = FileWindow::open_fd(store_fd, offset, length); + if self.set_file_window(global, opened)? { + return Ok(()); + } + } + *self = Stdio::Fd(store_fd); } return Ok(()); } PathOrFileDescriptor::Path(ref path) => { + if let Some((offset, length)) = window { + let mut buf = bun_paths::path_buffer_pool::get(); + let opened = + FileWindow::open_path(path.slice_z(&mut buf), offset, length); + if self.set_file_window(global, opened)? { + return Ok(()); + } + } + *self = Stdio::Path(path.clone()); return Ok(()); } @@ -641,6 +681,23 @@ impl Stdio { } } +impl Stdio { + /// Returns `false` for a file that is not regular: it has no window, so the child gets the file. + fn set_file_window( + &mut self, + global: &JSGlobalObject, + opened: sys::Result, + ) -> JsResult { + *self = match opened { + Ok(file_window::Opened::Window(window)) => Stdio::FileWindow(Some(window)), + Ok(file_window::Opened::Empty) => Stdio::Ignore, + Ok(file_window::Opened::NotRegular) => return Ok(false), + Err(err) => return Err(global.throw_value(err.to_js(global))), + }; + Ok(true) + } +} + #[cfg(any(target_os = "linux", target_os = "android"))] impl Stdio { /// Move the memfd out (ownership passes to the caller); `self` becomes `Ignore`. diff --git a/src/runtime/api/bun/subprocess.rs b/src/runtime/api/bun/subprocess.rs index ff1676224d8e..811a12d65e55 100644 --- a/src/runtime/api/bun/subprocess.rs +++ b/src/runtime/api/bun/subprocess.rs @@ -1498,7 +1498,8 @@ impl Subprocess<'_> { } } -pub(crate) use bun_spawn::subprocess::{Source, SourceData}; +pub(crate) use bun_spawn::file_window; +pub(crate) use bun_spawn::subprocess::{FileWindow, Source, SourceData}; // JSC-tier payloads wrap as `Source::Any(Box)` — the lower-tier // `bun_spawn` crate cannot name `webcore`/`jsc`, so the vtable travels with the diff --git a/src/runtime/api/bun/subprocess/Readable.rs b/src/runtime/api/bun/subprocess/Readable.rs index 19b9cefaf402..fdf1e2fe0a34 100644 --- a/src/runtime/api/bun/subprocess/Readable.rs +++ b/src/runtime/api/bun/subprocess/Readable.rs @@ -140,6 +140,8 @@ impl Readable { Readable::Pipe(PipeReader::create(event_loop, process, result, max_size)) } Stdio::Blob(..) => panic!("TODO: implement Blob support in Stdio readable"), + // Made for stdin only in Stdio::extract_blob(); stdout/stderr never see this. + Stdio::FileWindow(..) => unreachable!("FileWindow at stdout/stderr"), Stdio::Capture(..) => panic!("TODO: implement capture support in Stdio readable"), // ReadableStream is handled separately Stdio::ReadableStream(..) => Readable::Ignore, diff --git a/src/runtime/api/bun/subprocess/Writable.rs b/src/runtime/api/bun/subprocess/Writable.rs index b9a5c9b90137..e1789dc0c896 100644 --- a/src/runtime/api/bun/subprocess/Writable.rs +++ b/src/runtime/api/bun/subprocess/Writable.rs @@ -14,7 +14,7 @@ use crate::webcore::streams::SourceHandle; #[cfg(windows)] use bun_io::pipe_writer::BaseWindowsPipeWriter as _; -use super::{Flags, StaticPipeWriter, StdioResult, Subprocess, js}; +use super::{Flags, Source, StaticPipeWriter, StdioResult, Subprocess, js}; pub(crate) enum Writable<'a> { Pipe(RefPtr), @@ -209,6 +209,14 @@ impl<'a> Writable<'a> { super::source_from_blob(blob), ))); } + Stdio::FileWindow(window) => { + return Ok(Writable::Buffer(StaticPipeWriter::create( + evtloop, + subprocess as *mut Subprocess<'a>, + result, + Source::FileWindow(window.take().unwrap()), + ))); + } Stdio::Fd(fd) => { return Ok(Writable::Fd(*fd)); } @@ -306,6 +314,12 @@ impl<'a> Writable<'a> { super::source_from_blob(blob), ))) } + Stdio::FileWindow(window) => Ok(Writable::Buffer(StaticPipeWriter::create( + evtloop, + std::ptr::from_mut::>(subprocess), + result, + Source::FileWindow(window.take().unwrap()), + ))), #[cfg(any(target_os = "linux", target_os = "android"))] Stdio::Memfd(_) => { // Transfer ownership: `Stdio`'s Drop would close the memfd, so diff --git a/src/runtime/shell/subproc.rs b/src/runtime/shell/subproc.rs index 0e2829249d4e..524770ca706a 100644 --- a/src/runtime/shell/subproc.rs +++ b/src/runtime/shell/subproc.rs @@ -1057,6 +1057,14 @@ impl Writable { JscSubprocess::source_from_blob(blob), ))); } + Stdio::FileWindow(window) => { + return Ok(Writable::Buffer(StaticPipeWriter::create( + event_loop, + subprocess, + result, + JscSubprocess::Source::FileWindow(window.take().unwrap()), + ))); + } Stdio::Fd(fd) => { return Ok(Writable::Fd(*fd)); } @@ -1110,6 +1118,12 @@ impl Writable { JscSubprocess::source_from_blob(blob), ))) } + Stdio::FileWindow(window) => Ok(Writable::Buffer(StaticPipeWriter::create( + event_loop, + subprocess, + result, + JscSubprocess::Source::FileWindow(window.take().unwrap()), + ))), #[cfg(any(target_os = "linux", target_os = "android"))] Stdio::Memfd(memfd) => { debug_assert!(memfd.is_valid()); @@ -1271,6 +1285,7 @@ impl Readable { // blobs are immutable, so we should only ever get the case // where the user passed in a Blob with an fd Stdio::Blob(_) => Readable::Ignore, + Stdio::FileWindow(_) => Readable::Ignore, Stdio::Pipe => Readable::Pipe(PipeReader::create( event_loop, process, @@ -1305,6 +1320,7 @@ impl Readable { // blobs are immutable, so we should only ever get the case // where the user passed in a Blob with an fd Stdio::Blob(_) => Readable::Ignore, + Stdio::FileWindow(_) => Readable::Ignore, #[cfg(any(target_os = "linux", target_os = "android"))] Stdio::Memfd(memfd) => { let fd = *memfd; diff --git a/src/spawn/file_window.rs b/src/spawn/file_window.rs new file mode 100644 index 000000000000..7bb37a115bea --- /dev/null +++ b/src/spawn/file_window.rs @@ -0,0 +1,133 @@ +//! A window of a regular file that a `StaticPipeWriter` sends one chunk at a time. + +use bun_core::ZStr; +use bun_sys::{self as sys, Fd, File, O}; + +pub struct FileWindow { + file: File, + /// Where the next chunk starts. + offset: u64, + /// Bytes of the window that no chunk has read. `None`: the window ends at EOF. + remaining: Option, + /// One buffer for every chunk. `chunk[..len]` holds the bytes of the chunk that is read. + chunk: Box<[u8]>, + len: usize, + /// A positional read moves the file pointer, and a duplicate shares it with the caller's descriptor. + #[cfg(windows)] + restore_position: bool, +} + +pub enum Opened { + /// The first chunk is read. + Window(Box), + /// The window holds no bytes. + Empty, + /// Only a regular file has a byte at an offset. + NotRegular, +} + +impl FileWindow { + /// The most bytes of a window that the parent holds. + const CHUNK_SIZE: u64 = 64 * 1024; + + /// `length` is `None` for a window that ends at EOF. + pub fn open_path(path: &ZStr, offset: u64, length: Option) -> sys::Result { + // An open of a FIFO takes its writer out of `open(2)`, so the type is read first. + let stat = sys::stat(path)?; + if !sys::is_regular_file(stat.st_mode as _) { + return Ok(Opened::NotRegular); + } + if Self::is_empty(offset, length, stat.st_size.max(0) as u64) { + return Ok(Opened::Empty); + } + // The path can name a FIFO or a terminal by now. The open must not wait for a writer. + let flags = O::RDONLY | O::CLOEXEC | O::NONBLOCK | O::NOCTTY; + Self::with_first_chunk(File::open(path, flags, 0)?, offset, length, false) + } + + /// `length` is `None` for a window that ends at EOF. + pub fn open_fd(fd: Fd, offset: u64, length: Option) -> sys::Result { + let stat = sys::fstat(fd)?; + if !sys::is_regular_file(stat.st_mode as _) { + return Ok(Opened::NotRegular); + } + if Self::is_empty(offset, length, stat.st_size.max(0) as u64) { + return Ok(Opened::Empty); + } + // The caller can close `fd` before the last chunk is read. + let file = File::from_fd(sys::dup(fd)?); + Self::with_first_chunk(file, offset, length, true) + } + + /// A size of 0 is not a length: procfs reports it for a file that has bytes. + fn is_empty(offset: u64, length: Option, size: u64) -> bool { + length == Some(0) || (size != 0 && offset >= size) + } + + fn with_first_chunk( + file: File, + offset: u64, + length: Option, + is_duplicate: bool, + ) -> sys::Result { + #[cfg(not(windows))] + let _ = is_duplicate; + let capacity = length.unwrap_or(u64::MAX).min(Self::CHUNK_SIZE) as usize; + let mut window = Box::new(FileWindow { + file, + offset, + remaining: length, + chunk: vec![0; capacity].into_boxed_slice(), + len: 0, + #[cfg(windows)] + restore_position: is_duplicate, + }); + Ok(match window.read_chunk()? { + 0 => Opened::Empty, + _ => Opened::Window(window), + }) + } + + /// Returns the length of the new chunk: 0 at the end of the window or of the file. + fn read_chunk(&mut self) -> sys::Result { + self.len = 0; + let want = self + .remaining + .unwrap_or(u64::MAX) + .min(self.chunk.len() as u64) as usize; + if want == 0 { + return Ok(0); + } + #[cfg(windows)] + let position = if self.restore_position { + Some(self.file.get_pos()?) + } else { + None + }; + let read = self.file.pread_all(&mut self.chunk[..want], self.offset); + #[cfg(windows)] + if let Some(position) = position { + self.file.seek_to(position)?; + } + let len = read?; + self.len = len; + self.offset += len as u64; + if let Some(remaining) = &mut self.remaining { + *remaining -= len as u64; + } + Ok(len) + } + + pub(crate) fn slice(&self) -> &[u8] { + &self.chunk[..self.len] + } + + /// Replaces the chunk with the next one. `Ok(false)`: the window or the file has no more bytes. + pub(crate) fn refill(&mut self) -> sys::Result { + Ok(self.read_chunk()? != 0) + } + + pub(crate) fn memory_cost(&self) -> usize { + core::mem::size_of::() + self.chunk.len() + } +} diff --git a/src/spawn/lib.rs b/src/spawn/lib.rs index 70eda17189d9..e770b2d2996e 100644 --- a/src/spawn/lib.rs +++ b/src/spawn/lib.rs @@ -28,6 +28,8 @@ pub mod process; #[path = "static_pipe_writer.rs"] pub mod static_pipe_writer; +pub mod file_window; + pub mod error; pub use error::{Error, Result}; @@ -133,6 +135,7 @@ pub mod subprocess { #[cfg(not(windows))] use bun_sys::Fd; + pub use crate::file_window::FileWindow; pub use crate::process::StdioKind; pub use crate::static_pipe_writer::{StaticPipeWriter, StaticPipeWriterProcess}; @@ -158,6 +161,8 @@ pub mod subprocess { pub enum Source { OwnedBytes(Box<[u8]>), Any(Box), + /// Holds one chunk of the payload at a time. [`Source::refill`] loads the next one. + FileWindow(Box), Detached, } @@ -182,11 +187,20 @@ pub mod subprocess { match self { Source::OwnedBytes(b) => b, Source::Any(s) => s.slice(), + Source::FileWindow(window) => window.slice(), // slice() after detach() is a bug. Source::Detached => unreachable!("Source::slice on Detached"), } } + /// Loads the next bytes into `slice()`. `Ok(false)`: the source has no more. + pub(crate) fn refill(&mut self) -> bun_sys::Result { + match self { + Source::FileWindow(window) => window.refill(), + _ => Ok(false), + } + } + /// Release the payload and flip to /// `Detached`. Calling `slice()` afterwards is invalid (panics). pub fn detach(&mut self) { @@ -200,6 +214,7 @@ pub mod subprocess { match self { Source::OwnedBytes(b) => b.len(), Source::Any(s) => s.memory_cost(), + Source::FileWindow(window) => window.memory_cost(), Source::Detached => 0, } } diff --git a/src/spawn/static_pipe_writer.rs b/src/spawn/static_pipe_writer.rs index 68db15028e23..d679beaa55af 100644 --- a/src/spawn/static_pipe_writer.rs +++ b/src/spawn/static_pipe_writer.rs @@ -238,6 +238,20 @@ impl StaticPipeWriter

{ return; } if self.buffer.is_empty() { + match self.source.refill() { + Ok(true) => { + // One chunk for each writable event, so a long source does not hold the event loop. + self.buffer = RawSlice::new(self.source.slice()); + #[cfg(windows)] + self.writer.write(); + #[cfg(not(windows))] + self.writer.watch(); + return; + } + Ok(false) => {} + // A pipe cannot carry the error, so the child gets the end of its stdin. + Err(err) => self.on_error(&err), + } // Token taken before `close()` so start()'s ref outlives the owner's. let release_start_ref = core::mem::replace(&mut self.started, false); self.writer.close(); diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index b7b9eb8f3ae6..3457335afc25 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -1,4 +1,4 @@ -import { ArrayBufferSink, readableStreamToText, spawn, spawnSync } from "bun"; +import { $, ArrayBufferSink, readableStreamToText, spawn, spawnSync } from "bun"; import { dlopen } from "bun:ffi"; import { beforeAll, describe, expect, it } from "bun:test"; import { @@ -17,7 +17,20 @@ import { tmpdirSync, withoutAggressiveGC, } from "harness"; -import { closeSync, fstatSync, openSync, readFileSync, readSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { + appendFileSync, + closeSync, + existsSync, + fstatSync, + openSync, + readFileSync, + readSync, + realpathSync, + rmSync, + truncateSync, + writeFileSync, +} from "node:fs"; import path, { join } from "path"; let tmp: string; @@ -1383,6 +1396,415 @@ describe("close handling", () => { }); }); +// A sliced Bun.file() is a view (`offset`, `size`) over a store that names the +// whole file. The child got the store's path or fd, so it read the whole file. +describe("stdin: a sliced Bun.file() sends only the slice", () => { + const content = "abcdefghijklmnopqrstuvwxyz"; + // The child writes its stdin to its stdout. `cat` starts faster than bun. + const echo = isWindows ? [bunExe(), "-e", "process.stdin.pipe(process.stdout)"] : [Bun.which("cat")!]; + + async function sendToChild(stdin: Blob | Response | ReadableStream) { + await using proc = spawn({ cmd: echo, env: bunEnv, stdin, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + function sendToChildSync(stdin: Blob) { + const { stdout, stderr, exitCode } = spawnSync({ + cmd: echo, + env: bunEnv, + stdin, + stdout: "pipe", + stderr: "pipe", + }); + return { stdout: stdout.toString(), stderr: stderr.toString(), exitCode }; + } + + const windows: [name: string, start: number, end: number | undefined][] = [ + ["a window in the middle", 10, 20], + ["a window at the start", 0, 5], + ["a window to EOF", 3, undefined], + ["a window that ends past EOF", 20, 100], + ["a window at the start that ends past EOF", 0, 100], + ["a window of the whole file", 0, content.length], + ["a window from the start to EOF", 0, undefined], + ["an empty window", 5, 5], + ["a window past EOF", 30, 40], + ]; + + it.concurrent.each(windows)("%s", async (_, start, end) => { + using dir = tempDir("spawn-stdin-slice", { "src.txt": content }); + const file = Bun.file(join(String(dir), "src.txt")); + + expect(await sendToChild(file.slice(start, end))).toEqual({ + stdout: content.slice(start, end), + stderr: "", + exitCode: 0, + }); + }); + + // Not concurrent: spawnSync blocks the event loop of the concurrent tests. + it.each(windows)("spawnSync: %s", (_, start, end) => { + using dir = tempDir("spawn-stdin-slice-sync", { "src.txt": content }); + const file = Bun.file(join(String(dir), "src.txt")); + + expect(sendToChildSync(file.slice(start, end))).toEqual({ + stdout: content.slice(start, end), + stderr: "", + exitCode: 0, + }); + }); + + it.concurrent.each([ + ["a Response over the slice", 10, 20, (slice: Blob) => new Response(slice)], + ["a Response over the slice's stream", 10, 20, (slice: Blob) => new Response(slice.stream())], + ["the slice's stream", 10, 20, (slice: Blob) => slice.stream()], + ["the stream of a window at the start", 0, 5, (slice: Blob) => slice.stream()], + ["a structured clone of the slice", 10, 20, (slice: Blob) => structuredClone(slice)], + ])("%s", async (_, start, end, wrap) => { + using dir = tempDir("spawn-stdin-slice-body", { "src.txt": content }); + const slice = Bun.file(join(String(dir), "src.txt")).slice(start, end); + + expect(await sendToChild(wrap(slice))).toEqual({ stdout: content.slice(start, end), stderr: "", exitCode: 0 }); + }); + + it.concurrent("a window of a file descriptor, without moving its position", async () => { + using dir = tempDir("spawn-stdin-slice-fd", { "src.txt": content }); + const fd = openSync(join(String(dir), "src.txt"), "r"); + try { + const child = await sendToChild(Bun.file(fd).slice(10, 20)); + const next = Buffer.alloc(5); + const n = readSync(fd, next, 0, 5, null); + + expect({ child, next: next.toString("utf8", 0, n) }).toEqual({ + child: { stdout: "klmnopqrst", stderr: "", exitCode: 0 }, + next: "abcde", + }); + } finally { + closeSync(fd); + } + }); + + // A window names bytes of the file, so it does not start at the position of the descriptor. + it.concurrent.each([ + ["slice(0, 5)", (file: Blob) => file.slice(0, 5), "abcde"], + ["slice(0, size)", (file: Blob) => file.slice(0, content.length), content], + ])("%s of a file descriptor that is not at byte 0", async (_, window, expected) => { + using dir = tempDir("spawn-stdin-slice-fd-position", { "src.txt": content }); + const fd = openSync(join(String(dir), "src.txt"), "r"); + try { + expect(readSync(fd, Buffer.alloc(4), 0, 4, null)).toBe(4); + const child = await sendToChild(window(Bun.file(fd))); + const next = Buffer.alloc(5); + const n = readSync(fd, next, 0, 5, null); + + expect({ child, next: next.toString("utf8", 0, n) }).toEqual({ + child: { stdout: expected, stderr: "", exitCode: 0 }, + next: "efghi", + }); + } finally { + closeSync(fd); + } + }); + + // The parent reads the window after spawn() returns, from a descriptor of its own. + it.concurrent("a window of a file descriptor that the caller closes after spawn()", async () => { + const payload = randomBytes(512 * 1024); + using dir = tempDir("spawn-stdin-slice-fd-closed", { "src.bin": payload }); + const fd = openSync(join(String(dir), "src.bin"), "r"); + let proc; + try { + proc = spawn({ + cmd: echo, + env: bunEnv, + stdin: Bun.file(fd).slice(7, payload.length - 5), + stdout: "pipe", + stderr: "pipe", + }); + } finally { + closeSync(fd); + } + await using _ = proc; + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.bytes(), proc.stderr.text(), proc.exited]); + expect({ + size: stdout.byteLength, + identical: Buffer.from(stdout).equals(payload.subarray(7, payload.length - 5)), + stderr, + exitCode, + }).toEqual({ size: payload.length - 12, identical: true, stderr: "", exitCode: 0 }); + }); + + // `Bun.file(relative)` names a file in the directory of the parent, also when the child starts in another one. + it.concurrent.each([ + [ + "in the directory of the parent", + { "parent/rel.txt": content, "child/rel.txt": content.toUpperCase() }, + "klmnopqrst", + ], + ["only in the directory of the child", { "parent/other.txt": "", "child/rel.txt": content }, "threw ENOENT"], + ])("a window of a relative path that exists %s", async (_, files, expected) => { + using dir = tempDir("spawn-stdin-slice-cwd", files); + const script = ` + try { + const proc = Bun.spawn({ + cmd: ${JSON.stringify(echo)}, + cwd: ${JSON.stringify(join(String(dir), "child"))}, + stdin: Bun.file("rel.txt").slice(10, 20), + stdout: "inherit", + stderr: "inherit", + }); + process.exitCode = await proc.exited; + } catch (error) { + process.stdout.write("threw " + error.code); + } + `; + await using proc = spawn({ + cmd: [bunExe(), "-e", script], + cwd: join(String(dir), "parent"), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: expected, stderr: "", exitCode: 0 }); + }); + + // The shell turns a path-backed file into its path, so only these two forms carry a window. + it.concurrent("the shell: a window at the stdin of a command", async () => { + using dir = tempDir("spawn-stdin-slice-shell", { "src.txt": content }); + const src = join(String(dir), "src.txt"); + const fd = openSync(src, "r"); + try { + const [fromFd, fromResponse] = await Promise.all([ + $`${echo} < ${Bun.file(fd).slice(10, 20)}`.env(bunEnv).text(), + $`${echo} < ${new Response(Bun.file(src).slice(3, 8))}`.env(bunEnv).text(), + ]); + + expect({ fromFd, fromResponse }).toEqual({ fromFd: "klmnopqrst", fromResponse: "defgh" }); + } finally { + closeSync(fd); + } + }); + + it.concurrent("a window larger than a pipe buffer", async () => { + const payload = randomBytes(3 * 1024 * 1024); + using dir = tempDir("spawn-stdin-slice-large", { "src.bin": payload }); + const start = 1024 * 1024 + 3; + const end = 2 * 1024 * 1024 + 11; + + await using proc = spawn({ + cmd: echo, + env: bunEnv, + stdin: Bun.file(join(String(dir), "src.bin")).slice(start, end), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.bytes(), proc.stderr.text(), proc.exited]); + expect({ + size: stdout.byteLength, + identical: Buffer.from(stdout).equals(payload.subarray(start, end)), + stderr, + exitCode, + }).toEqual({ size: end - start, identical: true, stderr: "", exitCode: 0 }); + }); + + // procfs files are regular files with st_size == 0, so only the window bounds the read. + it.concurrent.skipIf(!isLinux)("a window of a file that reports no size", async () => { + const version = readFileSync("/proc/version", "utf8"); + const file = Bun.file("/proc/version"); + const [middle, toEnd] = await Promise.all([sendToChild(file.slice(3, 8)), sendToChild(file.slice(3))]); + + expect({ middle, toEnd }).toEqual({ + middle: { stdout: version.slice(3, 8), stderr: "", exitCode: 0 }, + toEnd: { stdout: version.slice(3), stderr: "", exitCode: 0 }, + }); + }); + + it("spawnSync: a window larger than a pipe buffer", () => { + const payload = randomBytes(1024 * 1024); + using dir = tempDir("spawn-stdin-slice-large-sync", { "src.bin": payload }); + const start = 100_003; + const end = 900_011; + const { stdout, stderr, exitCode } = spawnSync({ + cmd: echo, + env: bunEnv, + stdin: Bun.file(join(String(dir), "src.bin")).slice(start, end), + stdout: "pipe", + stderr: "pipe", + }); + + expect({ + size: stdout.byteLength, + identical: stdout.equals(payload.subarray(start, end)), + stderr: stderr.toString(), + exitCode, + }).toEqual({ size: end - start, identical: true, stderr: "", exitCode: 0 }); + }); + + // The parent holds one chunk of the window. A window that is read whole adds its length to the peak. + it("a long window does not add its length to the memory of the parent", async () => { + const length = 64 * 1024 * 1024; + using dir = tempDir("spawn-stdin-slice-memory", { "src.bin": "" }); + const src = join(String(dir), "src.bin"); + truncateSync(src, length + 4096); + const countStdin = "let n = 0; for await (const chunk of Bun.stdin.stream()) n += chunk.length; console.log(n);"; + const count = isWindows ? [bunExe(), "-e", countStdin] : [Bun.which("wc")!, "-c"]; + const script = ` + const before = process.resourceUsage().maxRSS; + const proc = Bun.spawn({ + cmd: ${JSON.stringify(count)}, + stdin: Bun.file(${JSON.stringify(src)}).slice(1024, 1024 + ${length}), + stdout: "pipe", + stderr: "inherit", + }); + const received = Number(await proc.stdout.text()); + await proc.exited; + console.log(JSON.stringify({ received, grewKiB: process.resourceUsage().maxRSS - before })); + `; + await using proc = spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const { received, grewKiB } = JSON.parse(stdout); + + expect(received).toBe(length); + expect(grewKiB).toBeLessThan(length / 1024 / 2); + expect(exitCode).toBe(0); + }, 30_000); + + // Three ends of the writer: the window ends, the child exits first, and spawn() throws. + it.skipIf(isWindows)( + "a window leaves no descriptor open in the parent", + async () => { + using dir = tempDir("spawn-stdin-slice-fds", { "src.bin": Buffer.alloc(300 * 1024, "x") }); + const script = ` + const fs = require("fs"); + const count = () => fs.readdirSync(${JSON.stringify(isLinux ? "/proc/self/fd" : "/dev/fd")}).length; + const window = () => Bun.file(${JSON.stringify(join(String(dir), "src.bin"))}).slice(5, 250 * 1024); + const run = cmd => Bun.spawn({ cmd, stdin: window(), stdout: "ignore", stderr: "inherit" }).exited; + const readAll = ${JSON.stringify(echo)}; + const readNothing = [${JSON.stringify(Bun.which("true"))}]; + + await run(readAll); + const before = count(); + let threw = 0; + for (let i = 0; i < 4; i++) { + await run(readAll); + await run(readNothing); + try { + Bun.spawn({ cmd: ["/does/not/exist/" + i], stdin: window() }); + } catch { + threw++; + } + } + let open = count() - before; + for (let i = 0; i < 200 && open > 0; i++) { + Bun.gc(true); + await Bun.sleep(10); + open = count() - before; + } + console.log(JSON.stringify({ threw, open })); + `; + await using proc = spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ stdout: JSON.stringify({ threw: 4, open: 0 }), stderr: "" }); + expect(exitCode).toBe(0); + }, + 30_000, + ); + + it("spawnSync: a window at the end of a large file", () => { + const payload = randomBytes(1024 * 1024); + using dir = tempDir("spawn-stdin-slice-tail", { "src.bin": payload }); + const { stdout, stderr, exitCode } = spawnSync({ + cmd: echo, + env: bunEnv, + stdin: Bun.file(join(String(dir), "src.bin")).slice(payload.length - 12, payload.length), + stdout: "pipe", + stderr: "pipe", + }); + + expect({ stdout: stdout.toString("hex"), stderr: stderr.toString(), exitCode }).toEqual({ + stdout: payload.subarray(payload.length - 12).toString("hex"), + stderr: "", + exitCode: 0, + }); + }); + + // The parent opens the window, so spawn() throws for a file that it cannot open, as `slice.text()` rejects. + it.each([ + ["a missing file", "missing.txt", /^ENOENT$/], + ["a path below a file", join("src.txt", "below"), /^(ENOTDIR|ENOENT)$/], + ])("a window of %s throws", (_, name, code) => { + using dir = tempDir("spawn-stdin-slice-unopenable", { "src.txt": content }); + const target = join(String(dir), name); + const attempt = (run: () => unknown) => { + try { + run(); + return { code: "no error" }; + } catch (error) { + return { code: (error as NodeJS.ErrnoException).code, path: (error as NodeJS.ErrnoException).path }; + } + }; + const threw = { code: expect.stringMatching(code), path: target }; + + expect({ + spawn: attempt(() => spawn({ cmd: echo, env: bunEnv, stdin: Bun.file(target).slice(0, 5) }).kill()), + spawnSync: attempt(() => spawnSync({ cmd: echo, env: bunEnv, stdin: Bun.file(target).slice(0, 5) })), + created: existsSync(target), + }).toEqual({ spawn: threw, spawnSync: threw, created: false }); + }); + + // Only stdin has a writer in the parent, so another read slot rejects a window. It does not send the whole file. + it("a window at stdio[3] throws", () => { + using dir = tempDir("spawn-stdio3-slice", { "src.txt": content }); + const window = Bun.file(join(String(dir), "src.txt")).slice(10, 20); + + expect(() => spawn({ cmd: echo, env: bunEnv, stdio: ["ignore", "ignore", "ignore", window] }).kill()).toThrow( + "A sliced Bun.file() cannot be used for stdio[3] yet", + ); + }); + + // The window applies to stdin only. At stdout, the child writes to the file. + it.concurrent("a window at stdout", async () => { + using dir = tempDir("spawn-stdout-slice", { "out.txt": "" }); + const out = join(String(dir), "out.txt"); + await using proc = spawn({ + cmd: [bunExe(), "-e", "process.stdout.write('written')"], + env: bunEnv, + stdout: Bun.file(out).slice(0, 2), + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect({ stderr, exitCode, written: readFileSync(out, "utf8") }).toEqual({ + stderr: "", + exitCode: 0, + written: "written", + }); + }); + + it.concurrent("an unsliced Bun.file() whose size was read sends the whole file", async () => { + using dir = tempDir("spawn-stdin-size-read", { "src.txt": content }); + const src = join(String(dir), "src.txt"); + const file = Bun.file(src); + expect(file.size).toBe(content.length); + appendFileSync(src, "0123"); + + expect(await sendToChild(file)).toEqual({ stdout: content + "0123", stderr: "", exitCode: 0 }); + }); + + // A stat that fails must not leave a size of 0 behind. + it.concurrent.each(["exists()", ".size"])("a Bun.file() whose %s was read before the file existed", async probe => { + using dir = tempDir("spawn-stdin-created-later", {}); + const src = join(String(dir), "src.txt"); + const file = Bun.file(src); + expect(probe === "exists()" ? await file.exists() : file.size).toBe(probe === "exists()" ? false : 0); + writeFileSync(src, content); + + expect(await sendToChild(file)).toEqual({ stdout: content, stderr: "", exitCode: 0 }); + }); +}); + it("dispose keyword works", async () => { let captured; { From ec3c407fc600eca1a5de8bf90ed6e663d66ce3ae Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:18:30 +0000 Subject: [PATCH 2/2] spawn: name the caller's path in the error of a window that cannot be opened On Windows the path that the open gets is the NT form of the path, so the error carried a path with the \\?\ prefix. --- src/runtime/api/bun/spawn/stdio.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/runtime/api/bun/spawn/stdio.rs b/src/runtime/api/bun/spawn/stdio.rs index d2bc872a2f0c..341e15212510 100644 --- a/src/runtime/api/bun/spawn/stdio.rs +++ b/src/runtime/api/bun/spawn/stdio.rs @@ -641,8 +641,10 @@ impl Stdio { PathOrFileDescriptor::Path(ref path) => { if let Some((offset, length)) = window { let mut buf = bun_paths::path_buffer_pool::get(); + // The error names the path that the caller gave, not its NT form. let opened = - FileWindow::open_path(path.slice_z(&mut buf), offset, length); + FileWindow::open_path(path.slice_z(&mut buf), offset, length) + .map_err(|err| err.with_path(path.slice())); if self.set_file_window(global, opened)? { return Ok(()); }