diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index ebad4a2f12fb..c796809210c2 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -268,6 +268,16 @@ use bun_sys as Syscall; #[cfg(windows)] use bun_sys::sys_uv as Syscall; +// Kernel limit on iovec count for a single readv(2)/writev(2). libuv's +// `uv__getiovmax()` prefers compile-time `IOV_MAX`; Linux headers spell it +// `UIO_MAXIOV`. Windows has no kernel iovec limit (sys_uv chunks internally). +#[cfg(any(target_os = "linux", target_os = "android"))] +const IOV_MAX: usize = libc::UIO_MAXIOV as usize; +#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))] +const IOV_MAX: usize = libc::IOV_MAX as usize; +#[cfg(windows)] +const IOV_MAX: usize = core::ffi::c_uint::MAX as usize; + /// In-place RAII wrapper for a libuv `fs_t` request. /// /// `scopeguard::guard(fs_t, |mut r| r.deinit())` is *wrong* here: its `Drop` @@ -6344,7 +6354,10 @@ impl NodeFS { fn preadv_inner(&mut self, args: &args::Readv) -> Maybe { let position = args.position.unwrap(); - match Syscall::preadv(args.fd, args.buffers.buffers.as_slice(), position as i64) { + let bufs = args.buffers.buffers.as_slice(); + // libuv `uv__fs_read`: cap `nbufs` at IOV_MAX and issue one syscall. + let bufs = &bufs[..bufs.len().min(IOV_MAX)]; + match Syscall::preadv(args.fd, bufs, position as i64) { Err(err) => Err(err), Ok(amt) => Ok(ret::Readv { bytes_read: amt as u64, @@ -6353,7 +6366,10 @@ impl NodeFS { } fn readv_inner(&mut self, args: &args::Readv) -> Maybe { - match Syscall::readv(args.fd, args.buffers.buffers.as_slice()) { + let bufs = args.buffers.buffers.as_slice(); + // libuv `uv__fs_read`: cap `nbufs` at IOV_MAX and issue one syscall. + let bufs = &bufs[..bufs.len().min(IOV_MAX)]; + match Syscall::readv(args.fd, bufs) { Err(err) => Err(err), Ok(amt) => Ok(ret::Readv { bytes_read: amt as u64, @@ -6362,7 +6378,7 @@ impl NodeFS { } fn pwritev_inner(&mut self, args: &args::Writev) -> Maybe { - let position = args.position.unwrap(); + let mut position = args.position.unwrap() as i64; // `PlatformIoVec` // and `PlatformIoVecConst` are layout-identical (`{ *void, usize }`); the // kernel never writes through `iov_base` for pwritev(2). @@ -6376,12 +6392,32 @@ impl NodeFS { args.buffers.buffers.len(), ) }; - match Syscall::pwritev(args.fd, vecs, position as i64) { - Err(err) => Err(err), - Ok(amt) => Ok(ret::Write { - bytes_written: amt as u64, - }), + // libuv `uv__fs_write_all`: loop IOV_MAX-sized batches until every + // buffer is written; an error after the first batch returns the + // accumulated total instead of the error. + let mut remaining = vecs; + let mut total: u64 = 0; + while !remaining.is_empty() { + let chunk_len = remaining.len().min(IOV_MAX); + let chunk = &remaining[..chunk_len]; + match Syscall::pwritev(args.fd, chunk, position) { + Err(err) if total == 0 => return Err(err), + Err(_) => break, + Ok(0) => break, + Ok(amt) => { + total += amt as u64; + position = position.wrapping_add(amt as i64); + let chunk_capacity: usize = chunk.iter().map(|b| b.len as usize).sum(); + if amt < chunk_capacity { + break; + } + remaining = &remaining[chunk_len..]; + } + } } + Ok(ret::Write { + bytes_written: total, + }) } fn writev_inner(&mut self, args: &args::Writev) -> Maybe { @@ -6389,12 +6425,31 @@ impl NodeFS { // never writes through `iov_base`. `PlatformIoVec` and // `PlatformIoVecConst` are layout-identical (`{ *void, usize }`), so // pass the slice through `Syscall::writev` as-is. - match Syscall::writev(args.fd, args.buffers.buffers.as_slice()) { - Err(err) => Err(err), - Ok(amt) => Ok(ret::Write { - bytes_written: amt as u64, - }), + // libuv `uv__fs_write_all`: loop IOV_MAX-sized batches until every + // buffer is written; an error after the first batch returns the + // accumulated total instead of the error. + let mut remaining = args.buffers.buffers.as_slice(); + let mut total: u64 = 0; + while !remaining.is_empty() { + let chunk_len = remaining.len().min(IOV_MAX); + let chunk = &remaining[..chunk_len]; + match Syscall::writev(args.fd, chunk) { + Err(err) if total == 0 => return Err(err), + Err(_) => break, + Ok(0) => break, + Ok(amt) => { + total += amt as u64; + let chunk_capacity: usize = chunk.iter().map(sys::platform_iovec_len).sum(); + if amt < chunk_capacity { + break; + } + remaining = &remaining[chunk_len..]; + } + } } + Ok(ret::Write { + bytes_written: total, + }) } pub fn readdir(&mut self, args: &args::Readdir, flavor: Flavor) -> Maybe { diff --git a/src/sys/lib.rs b/src/sys/lib.rs index cc90af006966..ac9615af70c7 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -4618,6 +4618,18 @@ pub fn platform_iovec_create(buf: &mut [u8]) -> PlatformIoVec { } } +#[inline] +pub const fn platform_iovec_len(iov: &PlatformIoVec) -> usize { + #[cfg(unix)] + { + iov.iov_len + } + #[cfg(windows)] + { + iov.len as usize + } +} + /// Windows `PlatformIOVecConst` — same `uv_buf_t` layout (libuv has no /// const-buf type), with `base` typed `*const u8` so callers can build it /// from `&[u8]` without casts. diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 8d081789ba98..7bd7f52dc398 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -1680,6 +1680,117 @@ it("preadv", () => { expect(buffers[2]).toEqual(new Uint8Array([10, 11, 12])); }); +describe.concurrent("writev/readv with more than IOV_MAX buffers", () => { + // IOV_MAX is 1024 on Linux and macOS. Node's libuv loops writev in + // IOV_MAX-sized batches and caps readv at IOV_MAX; Bun previously passed + // the whole array to one syscall and got EINVAL for any count > 1024. + const n = 2000; + const makeWriteBufs = () => Array.from({ length: n }, (_, i) => Buffer.from([i & 0xff])); + const expectedBytes = Buffer.from(Array.from({ length: n }, (_, i) => i & 0xff)); + // libuv caps readv at IOV_MAX on POSIX; Windows libuv reads every buffer. + const readvCap = isWindows ? n : 1024; + + it("writevSync writes every buffer", () => { + using dir = tempDir("writev-iovmax-sync", {}); + const file = join(String(dir), "out"); + const fd = openSync(file, "w"); + try { + expect(writevSync(fd, makeWriteBufs())).toBe(n); + } finally { + closeSync(fd); + } + expect(readFileSync(file).equals(expectedBytes)).toBe(true); + }); + + it("writevSync with position writes every buffer", () => { + using dir = tempDir("pwritev-iovmax-sync", {}); + const file = join(String(dir), "out"); + const fd = openSync(file, "w"); + try { + writeSync(fd, Buffer.from("head"), 0, 4, 0); + expect(writevSync(fd, makeWriteBufs(), 4)).toBe(n); + } finally { + closeSync(fd); + } + const out = readFileSync(file); + expect(out.subarray(0, 4).toString()).toBe("head"); + expect(out.subarray(4).equals(expectedBytes)).toBe(true); + }); + + it("fs.writev (callback) writes every buffer", async () => { + using dir = tempDir("writev-iovmax-cb", {}); + const file = join(String(dir), "out"); + const fd = openSync(file, "w"); + try { + const { promise, resolve, reject } = Promise.withResolvers(); + fs.writev(fd, makeWriteBufs(), (err, written) => (err ? reject(err) : resolve(written))); + expect(await promise).toBe(n); + } finally { + closeSync(fd); + } + expect(readFileSync(file).equals(expectedBytes)).toBe(true); + }); + + it("FileHandle.writev writes every buffer", async () => { + using dir = tempDir("writev-iovmax-fh", {}); + const file = join(String(dir), "out"); + const fh = await _promises.open(file, "w"); + try { + const { bytesWritten } = await fh.writev(makeWriteBufs()); + expect(bytesWritten).toBe(n); + } finally { + await fh.close(); + } + expect(readFileSync(file).equals(expectedBytes)).toBe(true); + }); + + it("readvSync caps at IOV_MAX instead of failing", () => { + using dir = tempDir("readv-iovmax-sync", {}); + const file = join(String(dir), "in"); + writeFileSync(file, Buffer.alloc(n, 7)); + const fd = openSync(file, "r"); + try { + const buffers = Array.from({ length: n }, () => Buffer.alloc(1)); + expect(readvSync(fd, buffers)).toBe(readvCap); + expect(buffers[0][0]).toBe(7); + expect(buffers[readvCap - 1][0]).toBe(7); + } finally { + closeSync(fd); + } + }); + + it("readvSync with position caps at IOV_MAX instead of failing", () => { + using dir = tempDir("preadv-iovmax-sync", {}); + const file = join(String(dir), "in"); + writeFileSync(file, Buffer.concat([Buffer.from("xxx"), Buffer.alloc(n, 7)])); + const fd = openSync(file, "r"); + try { + const buffers = Array.from({ length: n }, () => Buffer.alloc(1)); + expect(readvSync(fd, buffers, 3)).toBe(readvCap); + expect(buffers[0][0]).toBe(7); + expect(buffers[readvCap - 1][0]).toBe(7); + } finally { + closeSync(fd); + } + }); + + it("FileHandle.readv caps at IOV_MAX instead of failing", async () => { + using dir = tempDir("readv-iovmax-fh", {}); + const file = join(String(dir), "in"); + writeFileSync(file, Buffer.alloc(n, 7)); + const fh = await _promises.open(file, "r"); + try { + const buffers = Array.from({ length: n }, () => Buffer.alloc(1)); + const { bytesRead } = await fh.readv(buffers, 0); + expect(bytesRead).toBe(readvCap); + expect(buffers[0][0]).toBe(7); + expect(buffers[readvCap - 1][0]).toBe(7); + } finally { + await fh.close(); + } + }); +}); + describe("writeSync", () => { it("works with bigint", () => { const dest = join(tmpdir(), "writeSync-large-file-bigint.txt");