From 2f30b03adaeb425bd1ed7bb4ef0edce32a0db961 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:25:15 +0000 Subject: [PATCH 1/9] Blob: read file-backed parts in multi-part new Blob([...]) The multi-part join in from_js_without_defer_gc pushed blob.shared_view() for every Blob part, but shared_view() returns an empty slice for any non-Bytes store (Data::File, Data::S3). Every file-store Blob used as one part of a multi-part new Blob([...]) or new File([...]) therefore contributed zero bytes. The single-part fast path (new Blob([Bun.file(p)])) was unaffected because it shares the store instead of joining bytes. Fix: dispatch on the part's store. Data::Bytes keeps the shared_view() path. Data::File reads the file synchronously (the constructor is sync and the spec's "process blob parts" requires the bytes at construction time): path stores use NodeFS::read_file; fd stores use pread so repeated use of the same fd part reads the correct window regardless of cursor position. Data::S3 throws rather than silently contributing zero bytes, since there is no synchronous S3 read path. A nonexistent file part now throws ENOENT from the constructor instead of being silently ignored. Fixes #25851 --- src/runtime/webcore/Blob.rs | 142 +++++++++++++++++++++++++++++---- test/js/web/fetch/blob.test.ts | 76 +++++++++++++++++- 2 files changed, 202 insertions(+), 16 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index ec3d76a55648..fb63a91fe269 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3513,19 +3513,12 @@ impl BlobExt for Blob { if let Some(blob) = item.as_class_ref::() { could_have_non_ascii = could_have_non_ascii || blob.charset.get() != strings::AsciiStatus::AllAscii; - // A later part may run user JS that drops the - // last ref to this Blob's Store before `done()`. - if parts_can_run_js { - joiner.push_cloned(blob.shared_view()); - } else { - // SAFETY: the prescan above proved no - // remaining part can run user JS, so this - // Blob (rooted via `_keep`/`arg`) keeps its - // Store alive until `joiner.done()` below. - joiner.push(unsafe { - bun_ptr::detach_lifetime(blob.shared_view()) - }); - } + push_blob_part_bytes( + blob, + &mut joiner, + global, + !parts_can_run_js, + )?; continue; } else { let sliced = item.to_slice_clone(global)?; @@ -3552,8 +3545,8 @@ impl BlobExt for Blob { || blob.charset.get() != strings::AsciiStatus::AllAscii; // This arm only handles entries deferred onto the walk // stack; other pending entries may still run user JS and - // free this Blob's Store before `done()`, so always copy. - joiner.push_cloned(blob.shared_view()); + // free this Blob's Store before `done()`, so never borrow. + push_blob_part_bytes(blob, &mut joiner, global, false)?; } else { let sliced = current.to_slice_clone(global)?; could_have_non_ascii = could_have_non_ascii || sliced.is_allocated(); @@ -3790,6 +3783,125 @@ impl BlobExt for Blob { } } +/// Push one Blob part's bytes into the multi-part joiner; reads file-backed stores synchronously. +fn push_blob_part_bytes( + blob: &Blob, + joiner: &mut bun_core::string_joiner::StringJoiner, + global: &JSGlobalObject, + borrow_bytes: bool, +) -> JsResult<()> { + let Some(store) = blob.store.get() else { + return Ok(()); + }; + match &store.data { + store::Data::Bytes(_) => { + if borrow_bytes { + // SAFETY: caller's prescan proved no remaining part runs user + // JS, so this Blob (rooted via the constructor's `_keep`/`arg`) + // keeps its Store alive until `joiner.done()`. + joiner.push(unsafe { bun_ptr::detach_lifetime(blob.shared_view()) }); + } else { + joiner.push_cloned(blob.shared_view()); + } + } + store::Data::File(file) => { + let size = blob.size.get(); + let offset = blob.offset.get(); + match &file.pathlike { + PathOrFileDescriptor::Path(_) => { + let mut node_fs = crate::node::fs::NodeFS::default(); + let mut rf_args = crate::node::fs::args::ReadFile::default(); + rf_args.encoding = crate::node::types::Encoding::Buffer; + rf_args.path = file.pathlike.clone(); + rf_args.offset = offset; + rf_args.max_size = if size == MAX_SIZE { None } else { Some(size) }; + match node_fs.read_file(&rf_args, crate::node::fs::Flavor::Sync) { + Err(err) => return Err(global.throw_value(err.to_js(global))), + Ok(mut result) => { + joiner.push_cloned(result.slice()); + if let crate::node::types::StringOrBuffer::Buffer(buf) = &mut result { + buf.destroy(); + } + } + } + } + PathOrFileDescriptor::Fd(fd) => { + // pread (not read_file): the fd's cursor must not decide what a repeated part reads. + let stat = match bun_sys::fstat(*fd) { + bun_sys::Result::Ok(s) => s, + bun_sys::Result::Err(err) => { + return Err(global.throw_value(err.with_fd(*fd).to_js(global))); + } + }; + let file_len = stat.st_size.max(0) as SizeType; + // st_size==0 may be a virtual file (procfs): grow-until-EOF instead of capping at 0. + let avail = if file_len > 0 { + file_len.saturating_sub(offset) as usize + } else { + usize::MAX + }; + let cap = if size != MAX_SIZE { + (size as usize).min(avail) + } else { + avail + }; + if cap == 0 { + return Ok(()); + } + let enomem = || { + global.throw_value( + bun_sys::Error::from_code(bun_sys::E::ENOMEM, bun_sys::Tag::read) + .with_fd(*fd) + .to_js(global), + ) + }; + let initial = if file_len > 0 { cap } else { 8192.min(cap) }; + let mut buf: Vec = Vec::new(); + if buf.try_reserve_exact(initial.min(8 << 30)).is_err() { + return Err(enomem()); + } + buf.resize(initial.min(8 << 30), 0); + let mut total = 0usize; + loop { + if total == buf.len() { + if total == cap { + break; + } + let new_len = buf.len().saturating_mul(2).min(cap); + if buf.try_reserve(new_len - buf.len()).is_err() { + return Err(enomem()); + } + buf.resize(new_len, 0); + } + let n = match bun_sys::pread( + *fd, + &mut buf[total..], + (offset as i64).saturating_add(total as i64), + ) { + bun_sys::Result::Ok(n) => n, + bun_sys::Result::Err(err) => { + return Err(global.throw_value(err.with_fd(*fd).to_js(global))); + } + }; + if n == 0 { + break; + } + total += n; + } + buf.truncate(total); + joiner.push_owned(buf.into_boxed_slice()); + } + } + } + store::Data::S3(_) => { + return Err(global.throw_invalid_arguments(format_args!( + "Blob parts backed by S3 cannot be read synchronously; await .bytes() or .arrayBuffer() first" + ))); + } + } + Ok(()) +} + // ────────────────────────────────────────────────────────────────────────── // Basic accessors // ────────────────────────────────────────────────────────────────────────── diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 8aa907c12b92..8a79e3037490 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness"; import type { BlobOptions } from "node:buffer"; import type { BinaryLike } from "node:crypto"; +import fs from "node:fs"; import path from "node:path"; test("blob: imports have sourcemapped stacktraces", async () => { @@ -688,3 +689,76 @@ describe("file-backed slice bounds are respected when streaming and serving", () expect(s.size).toBe(5); }); }); + +describe("new Blob([...]) with a file-backed Blob part", () => { + async function check(blob: Blob, expected: string) { + const text = await blob.text(); + expect({ size: blob.size, text }).toEqual({ size: expected.length, text: expected }); + } + + test("contributes the file's bytes alongside other parts", async () => { + using dir = tempDir("blob-file-part", { "f.bin": "ABCDEFGHIJ" }); + const p = path.join(String(dir), "f.bin"); + const file = Bun.file(p); + + // single-part fast path (already worked; kept as baseline) + await check(new Blob([file]), "ABCDEFGHIJ"); + // file + string (after) + await check(new Blob([file, "-tail"]), "ABCDEFGHIJ-tail"); + // string + file (before) + await check(new Blob(["head-", file]), "head-ABCDEFGHIJ"); + // in-memory Blob + sliced file + await check(new Blob([new Blob(["M"]), file.slice(2, 6)]), "MCDEF"); + // File constructor + await check(new File([file, "!"], "n"), "ABCDEFGHIJ!"); + // file + typed array + file (same file twice) + await check(new Blob([file, new Uint8Array([0x2d]), file]), "ABCDEFGHIJ-ABCDEFGHIJ"); + // empty string sibling (must not take the single-part clone path) + await check(new Blob(["", file]), "ABCDEFGHIJ"); + // structuredClone of a Bun.file part + await check(new Blob(["<", structuredClone(file), ">"]), ""); + // Response.blob() over a Bun.file still has a file store + await check(new Blob(["<", await new Response(file).blob(), ">"]), ""); + }); + + test("fd-backed BunFile part", async () => { + using dir = tempDir("blob-fd-part", { "f.bin": "0123456789", "empty.bin": "" }); + const fd = fs.openSync(path.join(String(dir), "f.bin"), "r"); + const emptyFd = fs.openSync(path.join(String(dir), "empty.bin"), "r"); + try { + const f = Bun.file(fd); + await check(new Blob(["<", f, ">"]), "<0123456789>"); + // same fd used twice: each read must start at the Blob's offset, not + // wherever the previous part left the cursor + await check(new Blob([f, "-", f]), "0123456789-0123456789"); + await check(new Blob([f.slice(2, 6), "|", f.slice(7, 9)]), "2345|78"); + // slice end past EOF must clamp to the fd's actual size, not allocate to the declared end + await check(new Blob(["<", f.slice(0, 1e12), ">"]), "<0123456789>"); + // an empty fd with a huge slice (st_size==0 so avail is unbounded) must not over-allocate + await check(new Blob(["<", Bun.file(emptyFd).slice(0, 1e12), ">"]), "<>"); + if (!isWindows) { + // On POSIX pread(2) never touches the fd cursor, so the constructor + // must not mutate the caller's position. Windows ReadFile with an + // OVERLAPPED offset on a synchronous handle advances the pointer (a + // libuv/Node quirk), so this guarantee does not hold there. + const buf = Buffer.alloc(20); + const n = fs.readSync(fd, buf, 0, 20, null); + expect(buf.subarray(0, n).toString()).toBe("0123456789"); + } + } finally { + fs.closeSync(fd); + fs.closeSync(emptyFd); + } + }); + + test("throws when the file cannot be read", () => { + using dir = tempDir("blob-file-part-missing", {}); + const p = path.join(String(dir), "does-not-exist"); + expect(() => new Blob(["x", Bun.file(p)])).toThrow(expect.objectContaining({ code: "ENOENT" })); + // still throws after `.size` was accessed (which resolves to 0 for a + // nonexistent path) + const observed = Bun.file(p); + void observed.size; + expect(() => new Blob(["x", observed])).toThrow(expect.objectContaining({ code: "ENOENT" })); + }); +}); From b00b6dba025e54f5971cae97c2f7cddddf89781d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:15:19 +0000 Subject: [PATCH 2/9] Blob: keep the in-memory part arm inline; read file parts with a non-blocking open The in-memory arm is back at the two call sites as main has it. A part whose shared_view() is empty and whose store is a file or S3 goes to one cold function, so an in-memory part costs one emptiness check more than main. The path arm no longer calls NodeFS::read_file. It opens with O_NONBLOCK, fstats, and shares the fd arm's pread loop, so a FIFO with no writer can not block the JS thread, and the file is copied once less. A directory still throws EISDIR. A pipe, socket or device now throws a TypeError that names the async readers. --- src/runtime/webcore/Blob.rs | 233 +++++++++++++++++---------------- test/js/web/fetch/blob.test.ts | 47 +++++++ 2 files changed, 169 insertions(+), 111 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 2c898ad74308..0600d965d6f3 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3229,12 +3229,24 @@ impl BlobExt for Blob { if let Some(blob) = item.as_class_ref::() { could_have_non_ascii = could_have_non_ascii || blob.charset.get() != strings::AsciiStatus::AllAscii; - push_blob_part_bytes( - blob, - &mut joiner, - global, - !parts_can_run_js, - )?; + let view = blob.shared_view(); + if view.is_empty() { + if blob.needs_to_read_file() || blob.is_s3() { + push_file_part(blob, &mut joiner, global)?; + } + continue; + } + // A later part may run user JS that drops the + // last ref to this Blob's Store before `done()`. + if parts_can_run_js { + joiner.push_cloned(view); + } else { + // SAFETY: the prescan above proved no + // remaining part can run user JS, so this + // Blob (rooted via `_keep`/`arg`) keeps its + // Store alive until `joiner.done()` below. + joiner.push(unsafe { bun_ptr::detach_lifetime(view) }); + } continue; } else { let utf8 = item.to_utf8(global)?; @@ -3260,8 +3272,13 @@ impl BlobExt for Blob { || blob.charset.get() != strings::AsciiStatus::AllAscii; // This arm only handles entries deferred onto the walk // stack; other pending entries may still run user JS and - // free this Blob's Store before `done()`, so never borrow. - push_blob_part_bytes(blob, &mut joiner, global, false)?; + // free this Blob's Store before `done()`, so always copy. + let view = blob.shared_view(); + if !view.is_empty() { + joiner.push_cloned(view); + } else if blob.needs_to_read_file() || blob.is_s3() { + push_file_part(blob, &mut joiner, global)?; + } } else { let utf8 = current.to_utf8(global)?; could_have_non_ascii = could_have_non_ascii || utf8.is_owned(); @@ -3441,122 +3458,116 @@ impl BlobExt for Blob { } } -/// Push one Blob part's bytes into the multi-part joiner; reads file-backed stores synchronously. -fn push_blob_part_bytes( +/// Reads a file-backed part of a multi-part `new Blob([...])`: `shared_view()` is empty for it. +#[cold] +#[inline(never)] +fn push_file_part( blob: &Blob, joiner: &mut bun_core::string_joiner::StringJoiner, global: &JSGlobalObject, - borrow_bytes: bool, ) -> JsResult<()> { let Some(store) = blob.store.get() else { return Ok(()); }; - match &store.data { - store::Data::Bytes(_) => { - if borrow_bytes { - // SAFETY: caller's prescan proved no remaining part runs user - // JS, so this Blob (rooted via the constructor's `_keep`/`arg`) - // keeps its Store alive until `joiner.done()`. - joiner.push(unsafe { bun_ptr::detach_lifetime(blob.shared_view()) }); - } else { - joiner.push_cloned(blob.shared_view()); - } - } - store::Data::File(file) => { - let size = blob.size.get(); - let offset = blob.offset.get(); - match &file.pathlike { - PathOrFileDescriptor::Path(_) => { - let mut node_fs = crate::node::fs::NodeFS::default(); - let mut rf_args = crate::node::fs::args::ReadFile::default(); - rf_args.encoding = crate::node::types::Encoding::Buffer; - rf_args.path = file.pathlike.clone(); - rf_args.offset = offset; - rf_args.max_size = if size == MAX_SIZE { None } else { Some(size) }; - match node_fs.read_file(&rf_args, crate::node::fs::Flavor::Sync) { - Err(err) => return Err(global.throw_value(err.to_js(global))), - Ok(mut result) => { - joiner.push_cloned(result.slice()); - if let crate::node::types::StringOrBuffer::Buffer(buf) = &mut result { - buf.destroy(); - } - } - } - } - PathOrFileDescriptor::Fd(fd) => { - // pread (not read_file): the fd's cursor must not decide what a repeated part reads. - let stat = match bun_sys::fstat(*fd) { - bun_sys::Result::Ok(s) => s, - bun_sys::Result::Err(err) => { - return Err(global.throw_value(err.with_fd(*fd).to_js(global))); - } - }; - let file_len = stat.st_size.max(0) as SizeType; - // st_size==0 may be a virtual file (procfs): grow-until-EOF instead of capping at 0. - let avail = if file_len > 0 { - file_len.saturating_sub(offset) as usize - } else { - usize::MAX - }; - let cap = if size != MAX_SIZE { - (size as usize).min(avail) - } else { - avail - }; - if cap == 0 { - return Ok(()); - } - let enomem = || { - global.throw_value( - bun_sys::Error::from_code(bun_sys::E::ENOMEM, bun_sys::Tag::read) - .with_fd(*fd) - .to_js(global), - ) - }; - let initial = if file_len > 0 { cap } else { 8192.min(cap) }; - let mut buf: Vec = Vec::new(); - if buf.try_reserve_exact(initial.min(8 << 30)).is_err() { - return Err(enomem()); - } - buf.resize(initial.min(8 << 30), 0); - let mut total = 0usize; - loop { - if total == buf.len() { - if total == cap { - break; - } - let new_len = buf.len().saturating_mul(2).min(cap); - if buf.try_reserve(new_len - buf.len()).is_err() { - return Err(enomem()); - } - buf.resize(new_len, 0); - } - let n = match bun_sys::pread( - *fd, - &mut buf[total..], - (offset as i64).saturating_add(total as i64), - ) { - bun_sys::Result::Ok(n) => n, - bun_sys::Result::Err(err) => { - return Err(global.throw_value(err.with_fd(*fd).to_js(global))); - } - }; - if n == 0 { - break; - } - total += n; - } - buf.truncate(total); - joiner.push_owned(buf.into_boxed_slice()); - } - } - } + let file = match &store.data { + store::Data::Bytes(_) => return Ok(()), store::Data::S3(_) => { return Err(global.throw_invalid_arguments(format_args!( "Blob parts backed by S3 cannot be read synchronously; await .bytes() or .arrayBuffer() first" ))); } + store::Data::File(file) => file, + }; + let throw = |err: bun_sys::Error| { + let err = match &file.pathlike { + PathOrFileDescriptor::Path(path) => err.with_path(path.slice()), + PathOrFileDescriptor::Fd(fd) => err.with_fd(*fd), + }; + global.throw_value(err.to_js(global)) + }; + + let (fd, _close_on_drop) = match &file.pathlike { + PathOrFileDescriptor::Fd(fd) => (*fd, None), + PathOrFileDescriptor::Path(path) => { + let mut path_buf = bun_paths::path_buffer_pool::get(); + // O_NONBLOCK: open(2) of a FIFO that has no writer would block the JS thread. + let flags = bun_sys::O::RDONLY + | bun_sys::O::NONBLOCK + | bun_sys::O::CLOEXEC + | bun_sys::O::NOCTTY; + match bun_sys::File::open(path.slice_z(&mut path_buf), flags, 0) { + Ok(opened) => (opened.handle(), Some(opened)), + Err(err) => return Err(throw(err)), + } + } + }; + let stat = match bun_sys::fstat(fd) { + Ok(stat) => stat, + Err(err) => return Err(throw(err)), + }; + if bun_sys::S::ISDIR(stat.st_mode as _) { + return Err(throw(bun_sys::Error::from_code( + bun_sys::E::EISDIR, + bun_sys::Tag::read, + ))); + } + if !bun_sys::S::ISREG(stat.st_mode as _) { + return Err(global.throw_invalid_arguments(format_args!( + "Blob parts backed by a pipe, socket or device cannot be read synchronously; await .bytes() or .arrayBuffer() first" + ))); + } + + let offset = blob.offset.get(); + let size = blob.size.get(); + let file_len = stat.st_size.max(0) as SizeType; + // st_size==0 may be a virtual file (procfs): grow-until-EOF instead of capping at 0. + let avail = if file_len > 0 { + file_len.saturating_sub(offset) as usize + } else { + usize::MAX + }; + let cap = if size != MAX_SIZE { + (size as usize).min(avail) + } else { + avail + }; + if cap == 0 { + return Ok(()); + } + let initial = if file_len > 0 { cap } else { 8192.min(cap) }.min(8 << 30); + let mut buf: Vec = Vec::new(); + if buf.try_reserve_exact(initial).is_err() { + return Err(throw(bun_sys::Error::oom())); + } + buf.resize(initial, 0); + let mut total = 0usize; + loop { + if total == buf.len() { + if total == cap { + break; + } + let new_len = buf.len().saturating_mul(2).min(cap); + if buf.try_reserve(new_len - buf.len()).is_err() { + return Err(throw(bun_sys::Error::oom())); + } + buf.resize(new_len, 0); + } + // pread, not read: the cursor of a caller's fd must not decide what a repeated part reads. + let n = match bun_sys::pread( + fd, + &mut buf[total..], + (offset as i64).saturating_add(total as i64), + ) { + Ok(n) => n, + Err(err) => return Err(throw(err)), + }; + if n == 0 { + break; + } + total += n; } + buf.truncate(total); + joiner.push_owned(buf.into_boxed_slice()); Ok(()) } diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 41634a84bae0..260d2698313f 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness"; +import { mkfifo } from "mkfifo"; import type { BlobOptions } from "node:buffer"; import type { BinaryLike } from "node:crypto"; import fs from "node:fs"; @@ -897,5 +898,51 @@ describe("new Blob([...]) with a file-backed Blob part", () => { const observed = Bun.file(p); void observed.size; expect(() => new Blob(["x", observed])).toThrow(expect.objectContaining({ code: "ENOENT" })); + // a directory + expect(() => new Blob(["x", Bun.file(String(dir))])).toThrow(expect.objectContaining({ code: "EISDIR" })); + }); + + test.skipIf(isWindows || process.getuid?.() === 0)("throws when the file is not readable", () => { + using dir = tempDir("blob-file-part-eacces", { "secret.bin": "secret" }); + const p = path.join(String(dir), "secret.bin"); + fs.chmodSync(p, 0o000); + expect(() => new Blob(["x", Bun.file(p)])).toThrow(expect.objectContaining({ code: "EACCES" })); + }); + + // The constructor reads on the JS thread, so it must never wait for a writer. + // A child process runs it: a blocked constructor would also block this file's timeout. + test("a FIFO or pipe part throws instead of blocking", async () => { + using dir = tempDir("blob-fifo-part", {}); + const fifo = path.join(String(dir), "fifo"); + if (!isWindows) mkfifo(fifo); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const parts = { stdin: Bun.stdin }; + if (process.env.FIFO_PATH) parts.fifo = Bun.file(process.env.FIFO_PATH); + for (const [name, part] of Object.entries(parts)) { + try { + console.log(name, "built a Blob of", new Blob(["x", part]).size, "bytes"); + } catch (e) { + console.log(name, e.message); + } + } + `, + ], + env: { ...bunEnv, FIFO_PATH: isWindows ? "" : fifo }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const message = + "Blob parts backed by a pipe, socket or device cannot be read synchronously; await .bytes() or .arrayBuffer() first"; + expect({ stdout: stdout.trim().split("\n"), stderr, exitCode }).toEqual({ + stdout: isWindows ? [`stdin ${message}`] : [`stdin ${message}`, `fifo ${message}`], + stderr: "", + exitCode: 0, + }); }); }); From 80b64bd95895655f91af3eddd353f252fe267193 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:28:08 +0000 Subject: [PATCH 3/9] Blob: refuse a pipe or device part before open; test bytes, S3, procfs and the FIFO writer A path is now stat'ed first. A pipe, socket or device is refused without open(2), because the open of a FIFO wakes its writer, and the writer then fails before the async read that the error message names can run. Tests: every byte value and UTF-8 text, a part against the same part read alone (slices, a file that grows or shrinks), the path in ENOENT and EISDIR, an S3 part, a procfs file whose stat size is 0, and a FIFO whose writer still delivers to the async read after the constructor threw. --- src/runtime/webcore/Blob.rs | 20 ++++-- test/js/web/fetch/blob.test.ts | 124 ++++++++++++++++++++++++++++++--- 2 files changed, 131 insertions(+), 13 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 0600d965d6f3..8d23a3dd1bcc 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3486,16 +3486,28 @@ fn push_file_part( global.throw_value(err.to_js(global)) }; + let not_a_file = || { + global.throw_invalid_arguments(format_args!( + "Blob parts backed by a pipe, socket or device cannot be read synchronously; await .bytes() or .arrayBuffer() first" + )) + }; + let is_file_or_dir = |mode| bun_sys::S::ISREG(mode) || bun_sys::S::ISDIR(mode); + let (fd, _close_on_drop) = match &file.pathlike { PathOrFileDescriptor::Fd(fd) => (*fd, None), PathOrFileDescriptor::Path(path) => { let mut path_buf = bun_paths::path_buffer_pool::get(); - // O_NONBLOCK: open(2) of a FIFO that has no writer would block the JS thread. + let path = path.slice_z(&mut path_buf); + // open(2) of a FIFO wakes the writer that waits for a reader, so refuse it unopened. + if bun_sys::stat(path).is_ok_and(|stat| !is_file_or_dir(stat.st_mode as _)) { + return Err(not_a_file()); + } + // O_NONBLOCK: if the path is a FIFO by now, open(2) must not block the JS thread. let flags = bun_sys::O::RDONLY | bun_sys::O::NONBLOCK | bun_sys::O::CLOEXEC | bun_sys::O::NOCTTY; - match bun_sys::File::open(path.slice_z(&mut path_buf), flags, 0) { + match bun_sys::File::open(path, flags, 0) { Ok(opened) => (opened.handle(), Some(opened)), Err(err) => return Err(throw(err)), } @@ -3512,9 +3524,7 @@ fn push_file_part( ))); } if !bun_sys::S::ISREG(stat.st_mode as _) { - return Err(global.throw_invalid_arguments(format_args!( - "Blob parts backed by a pipe, socket or device cannot be read synchronously; await .bytes() or .arrayBuffer() first" - ))); + return Err(not_a_file()); } let offset = blob.offset.get(); diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 260d2698313f..57d0b7d5778d 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isLinux, isWindows, tempDir } from "harness"; import { mkfifo } from "mkfifo"; import type { BlobOptions } from "node:buffer"; import type { BinaryLike } from "node:crypto"; @@ -829,9 +829,14 @@ test.each([ }); describe("new Blob([...]) with a file-backed Blob part", () => { - async function check(blob: Blob, expected: string) { - const text = await blob.text(); - expect({ size: blob.size, text }).toEqual({ size: expected.length, text: expected }); + async function check(blob: Blob, expected: string | Uint8Array) { + if (typeof expected === "string") { + const text = await blob.text(); + expect({ size: blob.size, text }).toEqual({ size: Buffer.byteLength(expected), text: expected }); + } else { + const bytes = await blob.bytes(); + expect({ size: blob.size, bytes }).toEqual({ size: expected.byteLength, bytes: expected }); + } } test("contributes the file's bytes alongside other parts", async () => { @@ -859,6 +864,54 @@ describe("new Blob([...]) with a file-backed Blob part", () => { await check(new Blob(["<", await new Response(file).blob(), ">"]), ""); }); + test("keeps every byte of the file", async () => { + const all = Uint8Array.from({ length: 256 }, (_, i) => i); + using dir = tempDir("blob-file-part-bytes", { "all.bin": Buffer.from(all), "utf8.txt": "héllo wörld ✓" }); + const bin = Bun.file(path.join(String(dir), "all.bin")); + await check(new Blob([bin, bin.slice(250), new Uint8Array([7])]), Uint8Array.of(...all, ...all.subarray(250), 7)); + await check(new Blob(["é", Bun.file(path.join(String(dir), "utf8.txt")), "✓"]), "éhéllo wörld ✓✓"); + }); + + // Two parts are made for each case: a read must not change what the other part gives. + test("a part gives the bytes that the same part gives when it is read alone", async () => { + using dir = tempDir("blob-file-part-alone", { "f.bin": "0123456789ABCDEFGHIJ" }); + const p = path.join(String(dir), "f.bin"); + const sized = () => { + const file = Bun.file(p); + void file.size; + return file; + }; + const windows: [number?, number?][] = [ + [], + [0], + [5], + [0, 5], + [5, 5], + [10, 5], + [0, 20], + [0, 1000], + [19], + [20], + [25, 30], + ]; + const changes = { + "unchanged": () => {}, + "grown": () => fs.appendFileSync(p, "zzzzz"), + "shrunk": () => fs.truncateSync(p, 8), + }; + for (const make of [() => Bun.file(p), sized]) { + for (const window of windows) { + for (const [change, apply] of Object.entries(changes)) { + fs.writeFileSync(p, "0123456789ABCDEFGHIJ"); + const [alone, part] = [make().slice(...window), make().slice(...window)]; + apply(); + const label = `${make === sized ? "sized " : ""}slice(${window}), file ${change}`; + expect({ label, bytes: await new Blob(["", part]).bytes() }).toEqual({ label, bytes: await alone.bytes() }); + } + } + } + }); + test("fd-backed BunFile part", async () => { using dir = tempDir("blob-fd-part", { "f.bin": "0123456789", "empty.bin": "" }); const fd = fs.openSync(path.join(String(dir), "f.bin"), "r"); @@ -892,14 +945,30 @@ describe("new Blob([...]) with a file-backed Blob part", () => { test("throws when the file cannot be read", () => { using dir = tempDir("blob-file-part-missing", {}); const p = path.join(String(dir), "does-not-exist"); - expect(() => new Blob(["x", Bun.file(p)])).toThrow(expect.objectContaining({ code: "ENOENT" })); + const missing = expect.objectContaining({ code: "ENOENT", syscall: "open", path: p }); + expect(() => new Blob(["x", Bun.file(p)])).toThrow(missing); // still throws after `.size` was accessed (which resolves to 0 for a // nonexistent path) const observed = Bun.file(p); void observed.size; - expect(() => new Blob(["x", observed])).toThrow(expect.objectContaining({ code: "ENOENT" })); + expect(() => new Blob(["x", observed])).toThrow(missing); + // a slice with no bytes still names a file that must exist + expect(() => new Blob(["x", Bun.file(p).slice(3, 3)])).toThrow(missing); // a directory - expect(() => new Blob(["x", Bun.file(String(dir))])).toThrow(expect.objectContaining({ code: "EISDIR" })); + const directory = expect.objectContaining({ code: "EISDIR", path: String(dir) }); + expect(() => new Blob(["x", Bun.file(String(dir))])).toThrow(directory); + }); + + test("throws for an S3 part", () => { + const client = new Bun.S3Client({ + accessKeyId: "id", + secretAccessKey: "secret", + bucket: "bucket", + endpoint: "http://127.0.0.1:1", + }); + expect(() => new Blob(["x", client.file("key")])).toThrow( + "Blob parts backed by S3 cannot be read synchronously; await .bytes() or .arrayBuffer() first", + ); }); test.skipIf(isWindows || process.getuid?.() === 0)("throws when the file is not readable", () => { @@ -915,6 +984,10 @@ describe("new Blob([...]) with a file-backed Blob part", () => { using dir = tempDir("blob-fifo-part", {}); const fifo = path.join(String(dir), "fifo"); if (!isWindows) mkfifo(fifo); + // The writer waits in open(2) until a reader opens the FIFO. + await using writer = isWindows + ? null + : Bun.spawn({ cmd: ["sh", "-c", `printf 'from the writer' > "$1"`, "sh", fifo], stderr: "inherit" }); await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -929,6 +1002,8 @@ describe("new Blob([...]) with a file-backed Blob part", () => { console.log(name, e.message); } } + // The constructor did not open the FIFO, so the writer still waits and the async read gets its bytes. + if (parts.fifo) console.log("fifo, read async:", await parts.fifo.text()); `, ], env: { ...bunEnv, FIFO_PATH: isWindows ? "" : fifo }, @@ -940,7 +1015,40 @@ describe("new Blob([...]) with a file-backed Blob part", () => { const message = "Blob parts backed by a pipe, socket or device cannot be read synchronously; await .bytes() or .arrayBuffer() first"; expect({ stdout: stdout.trim().split("\n"), stderr, exitCode }).toEqual({ - stdout: isWindows ? [`stdin ${message}`] : [`stdin ${message}`, `fifo ${message}`], + stdout: isWindows + ? [`stdin ${message}`] + : [`stdin ${message}`, `fifo ${message}`, "fifo, read async: from the writer"], + stderr: "", + exitCode: 0, + }); + if (writer) expect(await writer.exited).toBe(0); + }); + + // procfs gives a stat size of 0 for a file that has bytes, so the read must go on to the end of the file. + test.skipIf(!isLinux)("reads a file whose stat size is 0 to its end", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const fs = require("fs"); + const environ = fs.readFileSync("/proc/self/environ"); + const blob = new Blob(["<", Bun.file("/proc/self/environ"), ">"]); + console.log(JSON.stringify({ + statSize: fs.statSync("/proc/self/environ").size, + moreThanOneChunk: environ.length > 8192, + size: blob.size - environ.length, + sameBytes: Buffer.from(await blob.bytes()).equals(Buffer.concat([Buffer.from("<"), environ, Buffer.from(">")])), + })); + `, + ], + env: { ...bunEnv, BLOB_PART_PADDING: Buffer.alloc(20_000, "x").toString() }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + stdout: { statSize: 0, moreThanOneChunk: true, size: 2, sameBytes: true }, stderr: "", exitCode: 0, }); From 14a5bbd91e55da15f7bbfef3bacd1ad403f8db11 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:27:34 +0000 Subject: [PATCH 4/9] test(blob): read the FIFO with cat, not with the lazy reader On macOS the child never exited after `await Bun.file(fifo).text()`, so the test timed out. The test is about the constructor: it must refuse the FIFO without an open, so that the writer keeps its bytes for the next reader. `cat` is that reader now. The pipe case (`Bun.stdin`) is a test of its own. --- test/js/web/fetch/blob.test.ts | 61 ++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 57d0b7d5778d..5ba3124fd212 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -980,48 +980,53 @@ describe("new Blob([...]) with a file-backed Blob part", () => { // The constructor reads on the JS thread, so it must never wait for a writer. // A child process runs it: a blocked constructor would also block this file's timeout. - test("a FIFO or pipe part throws instead of blocking", async () => { - using dir = tempDir("blob-fifo-part", {}); - const fifo = path.join(String(dir), "fifo"); - if (!isWindows) mkfifo(fifo); - // The writer waits in open(2) until a reader opens the FIFO. - await using writer = isWindows - ? null - : Bun.spawn({ cmd: ["sh", "-c", `printf 'from the writer' > "$1"`, "sh", fifo], stderr: "inherit" }); + async function constructInChild(part: string, env: Record = {}) { await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", ` - const parts = { stdin: Bun.stdin }; - if (process.env.FIFO_PATH) parts.fifo = Bun.file(process.env.FIFO_PATH); - for (const [name, part] of Object.entries(parts)) { - try { - console.log(name, "built a Blob of", new Blob(["x", part]).size, "bytes"); - } catch (e) { - console.log(name, e.message); - } + try { + console.log("built a Blob of", new Blob(["x", ${part}]).size, "bytes"); + } catch (e) { + console.log(e.message); } - // The constructor did not open the FIFO, so the writer still waits and the async read gets its bytes. - if (parts.fifo) console.log("fifo, read async:", await parts.fifo.text()); `, ], - env: { ...bunEnv, FIFO_PATH: isWindows ? "" : fifo }, + env: { ...bunEnv, ...env }, stdin: "pipe", stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - const message = - "Blob parts backed by a pipe, socket or device cannot be read synchronously; await .bytes() or .arrayBuffer() first"; - expect({ stdout: stdout.trim().split("\n"), stderr, exitCode }).toEqual({ - stdout: isWindows - ? [`stdin ${message}`] - : [`stdin ${message}`, `fifo ${message}`, "fifo, read async: from the writer"], - stderr: "", - exitCode: 0, + return { stdout: stdout.trim(), stderr, exitCode }; + } + const refused = { + stdout: + "Blob parts backed by a pipe, socket or device cannot be read synchronously; await .bytes() or .arrayBuffer() first", + stderr: "", + exitCode: 0, + }; + + test("a pipe part throws instead of blocking", async () => { + expect(await constructInChild("Bun.stdin")).toEqual(refused); + }); + + test.skipIf(isWindows)("a FIFO part throws and leaves its writer waiting", async () => { + using dir = tempDir("blob-fifo-part", {}); + const fifo = path.join(String(dir), "fifo"); + mkfifo(fifo); + // The writer waits in open(2) until a reader opens the FIFO. + await using writer = Bun.spawn({ + cmd: ["sh", "-c", `printf 'from the writer' > "$1"`, "sh", fifo], + stdout: "ignore", + stderr: "inherit", }); - if (writer) expect(await writer.exited).toBe(0); + expect(await constructInChild("Bun.file(process.env.FIFO_PATH)", { FIFO_PATH: fifo })).toEqual(refused); + // The constructor did not open the FIFO, so the next reader still gets the bytes of the writer. + await using reader = Bun.spawn({ cmd: ["cat", fifo], stdout: "pipe", stderr: "inherit" }); + expect(await reader.stdout.text()).toBe("from the writer"); + expect(await writer.exited).toBe(0); }); // procfs gives a stat size of 0 for a file that has bytes, so the read must go on to the end of the file. From ff530e78eaa52d92320f8a04077ba3553769fa26 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:00:33 +0000 Subject: [PATCH 5/9] test(blob): cover Response, Request and Bun.write; assert that the FIFO writer still waits The three callers that join an array of parts get one test: the bytes of a file part, and the synchronous ENOENT for a part that is missing. The FIFO test waits until the writer runs, and asserts after the constructor that the writer has not exited. A constructor that opens the FIFO now fails that assertion and does not run into the timeout. --- test/js/web/fetch/blob.test.ts | 36 ++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 5ba3124fd212..101e0d2f05ce 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -864,6 +864,28 @@ describe("new Blob([...]) with a file-backed Blob part", () => { await check(new Blob(["<", await new Response(file).blob(), ">"]), ""); }); + test("Response, Request and Bun.write join the parts of an array the same way", async () => { + using dir = tempDir("blob-file-part-callers", { "f.bin": "ABCDEFGHIJ" }); + const file = Bun.file(path.join(String(dir), "f.bin")); + const out = path.join(String(dir), "out.bin"); + const body = (part: Blob) => ["x", part] as unknown as BodyInit; + expect({ + response: await new Response(body(file)).text(), + request: await new Request("http://localhost/", { method: "POST", body: body(file) }).text(), + written: await Bun.write(out, ["x", file]), + out: fs.readFileSync(out, "utf8"), + }).toEqual({ response: "xABCDEFGHIJ", request: "xABCDEFGHIJ", written: 11, out: "xABCDEFGHIJ" }); + + // The parts are joined before Bun.write makes its promise, so it throws and does not reject. + const missing = Bun.file(path.join(String(dir), "missing")); + const enoent = expect.objectContaining({ code: "ENOENT" }); + fs.rmSync(out); + expect(() => new Response(body(missing))).toThrow(enoent); + expect(() => new Request("http://localhost/", { method: "POST", body: body(missing) })).toThrow(enoent); + expect(() => Bun.write(out, ["x", missing])).toThrow(enoent); + expect(fs.existsSync(out)).toBe(false); + }); + test("keeps every byte of the file", async () => { const all = Uint8Array.from({ length: 256 }, (_, i) => i); using dir = tempDir("blob-file-part-bytes", { "all.bin": Buffer.from(all), "utf8.txt": "héllo wörld ✓" }); @@ -1016,14 +1038,20 @@ describe("new Blob([...]) with a file-backed Blob part", () => { using dir = tempDir("blob-fifo-part", {}); const fifo = path.join(String(dir), "fifo"); mkfifo(fifo); - // The writer waits in open(2) until a reader opens the FIFO. + // The writer says that it runs, then waits in open(2) until a reader opens the FIFO. await using writer = Bun.spawn({ - cmd: ["sh", "-c", `printf 'from the writer' > "$1"`, "sh", fifo], - stdout: "ignore", + cmd: ["sh", "-c", `echo ready; printf 'from the writer' > "$1"`, "sh", fifo], + stdout: "pipe", stderr: "inherit", }); + for await (const chunk of writer.stdout) { + expect(new TextDecoder().decode(chunk)).toBe("ready\n"); + break; + } expect(await constructInChild("Bun.file(process.env.FIFO_PATH)", { FIFO_PATH: fifo })).toEqual(refused); - // The constructor did not open the FIFO, so the next reader still gets the bytes of the writer. + // An open of the FIFO by the constructor lets the writer run: it exits, or SIGPIPE kills it. + expect({ exitCode: writer.exitCode, signalCode: writer.signalCode }).toEqual({ exitCode: null, signalCode: null }); + // The next reader still gets the bytes of the writer. await using reader = Bun.spawn({ cmd: ["cat", fifo], stdout: "pipe", stderr: "inherit" }); expect(await reader.stdout.text()).toBe("from the writer"); expect(await writer.exited).toBe(0); From 3184042f3e777e77499460017a4e1b2b84ac82d7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:41:56 +0000 Subject: [PATCH 6/9] Blob: a null device part is an empty part A part that is not a regular file throws a TypeError, and that included the null device. main gave an empty part for it, and Node does too. The null device has no bytes and a read of it cannot block, so it is an empty part again. The part is the null device when it is a character device with the st_rdev of the platform's null device. That needs one more stat, and only for a character device. A path is still not opened for it. Each other device, a pipe and a socket still throw. --- src/runtime/webcore/Blob.rs | 28 ++++++++++++++++++++++------ test/js/web/fetch/blob.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 8d23a3dd1bcc..e6246915102b 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3458,6 +3458,16 @@ impl BlobExt for Blob { } } +/// The null device has no bytes, and a read of it cannot block. +fn is_null_device(stat: &bun_sys::Stat) -> bool { + #[cfg(windows)] + let null_device = bun_core::zstr!("\\\\.\\NUL"); + #[cfg(not(windows))] + let null_device = bun_core::zstr!("/dev/null"); + bun_sys::S::ISCHR(stat.st_mode as _) + && bun_sys::stat(null_device).is_ok_and(|null| null.st_rdev == stat.st_rdev) +} + /// Reads a file-backed part of a multi-part `new Blob([...])`: `shared_view()` is empty for it. #[cold] #[inline(never)] @@ -3486,10 +3496,13 @@ fn push_file_part( global.throw_value(err.to_js(global)) }; - let not_a_file = || { - global.throw_invalid_arguments(format_args!( + let no_bytes_or_refused = |stat: &bun_sys::Stat| -> JsResult<()> { + if is_null_device(stat) { + return Ok(()); + } + Err(global.throw_invalid_arguments(format_args!( "Blob parts backed by a pipe, socket or device cannot be read synchronously; await .bytes() or .arrayBuffer() first" - )) + ))) }; let is_file_or_dir = |mode| bun_sys::S::ISREG(mode) || bun_sys::S::ISDIR(mode); @@ -3499,8 +3512,11 @@ fn push_file_part( let mut path_buf = bun_paths::path_buffer_pool::get(); let path = path.slice_z(&mut path_buf); // open(2) of a FIFO wakes the writer that waits for a reader, so refuse it unopened. - if bun_sys::stat(path).is_ok_and(|stat| !is_file_or_dir(stat.st_mode as _)) { - return Err(not_a_file()); + match bun_sys::stat(path) { + Ok(stat) if !is_file_or_dir(stat.st_mode as _) => { + return no_bytes_or_refused(&stat); + } + _ => {} } // O_NONBLOCK: if the path is a FIFO by now, open(2) must not block the JS thread. let flags = bun_sys::O::RDONLY @@ -3524,7 +3540,7 @@ fn push_file_part( ))); } if !bun_sys::S::ISREG(stat.st_mode as _) { - return Err(not_a_file()); + return no_bytes_or_refused(&stat); } let offset = blob.offset.get(); diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 101e0d2f05ce..517ccd2070d6 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -4,6 +4,7 @@ import { mkfifo } from "mkfifo"; import type { BlobOptions } from "node:buffer"; import type { BinaryLike } from "node:crypto"; import fs from "node:fs"; +import { devNull } from "node:os"; import path from "node:path"; test("blob: imports have sourcemapped stacktraces", async () => { @@ -1034,6 +1035,29 @@ describe("new Blob([...]) with a file-backed Blob part", () => { expect(await constructInChild("Bun.stdin")).toEqual(refused); }); + // The null device has no bytes and a read of it cannot block. Node gives an empty part for it too. + test("a null device part is an empty part", async () => { + const fd = fs.openSync(devNull, "r"); + try { + await check(new Blob([Bun.file(devNull), "x"]), "x"); + await check(new Blob(["x", Bun.file(devNull)]), "x"); + await check(new Blob(["", Bun.file(devNull)]), ""); + await check(new Blob(["x", Bun.file(fd), "y"]), "xy"); + await check(new File([Bun.file(devNull), "x"], "n"), "x"); + } finally { + fs.closeSync(fd); + } + }); + + test.skipIf(isWindows)("only the null device is an empty part", async () => { + using dir = tempDir("blob-null-device-part", {}); + const link = path.join(String(dir), "null"); + fs.symlinkSync("/dev/null", link); + await check(new Blob(["x", Bun.file(link)]), "x"); + // /dev/zero never ends, so a child process constructs the Blob. + expect(await constructInChild("Bun.file('/dev/zero')")).toEqual(refused); + }); + test.skipIf(isWindows)("a FIFO part throws and leaves its writer waiting", async () => { using dir = tempDir("blob-fifo-part", {}); const fifo = path.join(String(dir), "fifo"); From dd1127f6cfb233b3db54437ea180c517519f30ee Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:36:22 +0000 Subject: [PATCH 7/9] Blob: find the null device by its device number on Linux On Linux the null device is major 1, minor 3, so no stat of /dev/null is needed. A process that has no /dev in its root, with an fd on the null device that it got from its parent, now also gets an empty part. The tests for the null device now have a regular file as one more part, so they fail on a build that drops each file part. --- src/runtime/webcore/Blob.rs | 22 ++++++++++++++++------ test/js/web/fetch/blob.test.ts | 13 ++++++++----- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index e6246915102b..8572b0e0b6b1 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3460,12 +3460,22 @@ impl BlobExt for Blob { /// The null device has no bytes, and a read of it cannot block. fn is_null_device(stat: &bun_sys::Stat) -> bool { - #[cfg(windows)] - let null_device = bun_core::zstr!("\\\\.\\NUL"); - #[cfg(not(windows))] - let null_device = bun_core::zstr!("/dev/null"); - bun_sys::S::ISCHR(stat.st_mode as _) - && bun_sys::stat(null_device).is_ok_and(|null| null.st_rdev == stat.st_rdev) + if !bun_sys::S::ISCHR(stat.st_mode as _) { + return false; + } + // Linux fixes the null device at major 1, minor 3, so a sandbox with no /dev still finds it. + #[cfg(any(target_os = "linux", target_os = "android"))] + { + stat.st_rdev == libc::makedev(1, 3) + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + #[cfg(windows)] + let null_device = bun_core::zstr!("\\\\.\\NUL"); + #[cfg(not(windows))] + let null_device = bun_core::zstr!("/dev/null"); + bun_sys::stat(null_device).is_ok_and(|null| null.st_rdev == stat.st_rdev) + } } /// Reads a file-backed part of a multi-part `new Blob([...])`: `shared_view()` is empty for it. diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 517ccd2070d6..d5b1e86e3298 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -1037,23 +1037,26 @@ describe("new Blob([...]) with a file-backed Blob part", () => { // The null device has no bytes and a read of it cannot block. Node gives an empty part for it too. test("a null device part is an empty part", async () => { + using dir = tempDir("blob-null-device-part", { "f.bin": "ABC" }); + const file = Bun.file(path.join(String(dir), "f.bin")); const fd = fs.openSync(devNull, "r"); try { + // The file part shows that the parts are read: a build that drops each file part gives "x" too. + await check(new Blob([Bun.file(devNull), file, "x"]), "ABCx"); + await check(new Blob(["x", file, Bun.file(fd)]), "xABC"); + await check(new File([file, Bun.file(devNull), "x"], "n"), "ABCx"); await check(new Blob([Bun.file(devNull), "x"]), "x"); - await check(new Blob(["x", Bun.file(devNull)]), "x"); await check(new Blob(["", Bun.file(devNull)]), ""); - await check(new Blob(["x", Bun.file(fd), "y"]), "xy"); - await check(new File([Bun.file(devNull), "x"], "n"), "x"); } finally { fs.closeSync(fd); } }); test.skipIf(isWindows)("only the null device is an empty part", async () => { - using dir = tempDir("blob-null-device-part", {}); + using dir = tempDir("blob-null-device-link", { "f.bin": "ABC" }); const link = path.join(String(dir), "null"); fs.symlinkSync("/dev/null", link); - await check(new Blob(["x", Bun.file(link)]), "x"); + await check(new Blob([Bun.file(path.join(String(dir), "f.bin")), Bun.file(link), "x"]), "ABCx"); // /dev/zero never ends, so a child process constructs the Blob. expect(await constructInChild("Bun.file('/dev/zero')")).toEqual(refused); }); From ecbd8ec029a9f74da99b45932f2c6f2865933529 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:41:20 +0000 Subject: [PATCH 8/9] Blob: a file part as large as the RAM throws ENOMEM on Linux fs.readFile has this rule: on Linux, a read as large as the RAM fails before the OOM killer acts. A file part of a Blob is the same kind of read, so it gets the same rule. Without it, a part of that size stalls the JS thread and takes the memory of the machine before it fails. --- src/runtime/webcore/Blob.rs | 7 +++++++ test/js/web/fetch/blob.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 65d890f74079..519995e8a296 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3580,6 +3580,13 @@ fn push_file_part( if cap == 0 { return Ok(()); } + // The rule of `fs.readFile`: on Linux, a read as large as the RAM fails before the OOM killer acts. + if cfg!(any(target_os = "linux", target_os = "android")) + && file_len > 0 + && cap >= bun_core::get_total_memory_size() + { + return Err(throw(bun_sys::Error::oom())); + } let initial = if file_len > 0 { cap } else { 8192.min(cap) }.min(8 << 30); let mut buf: Vec = Vec::new(); if buf.try_reserve_exact(initial).is_err() { diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 967bda9b0ffe..d1043949822a 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -1252,4 +1252,37 @@ describe("new Blob([...]) with a file-backed Blob part", () => { exitCode: 0, }); }); + + // The rule of fs.readFile: on Linux a read as large as the RAM fails at once, before the OOM killer acts. + // The sparse file has 4 TiB, which is more than the RAM of a machine. + test.skipIf(!isLinux)("a part as large as the RAM throws ENOMEM and reads nothing", async () => { + using dir = tempDir("blob-file-part-huge", { "sparse.bin": "x" }); + const sparse = path.join(String(dir), "sparse.bin"); + fs.truncateSync(sparse, 2 ** 42); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + let code; + try { + new Blob(["x", Bun.file(process.env.SPARSE_PATH)]); + } catch (e) { + code = e.code; + } + // maxRSS is in KiB. The first reserve of a read is 8 GiB. + console.log(JSON.stringify({ code, peakBelow4GiB: process.resourceUsage().maxRSS < 4 * 1024 * 1024 })); + `, + ], + env: { ...bunEnv, SPARSE_PATH: sparse }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + stdout: { code: "ENOMEM", peakBelow4GiB: true }, + stderr: "", + exitCode: 0, + }); + }); }); From c38f6b37f1dd62c4dfee892e73e3245226e68562 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:40:22 +0000 Subject: [PATCH 9/9] Blob: a file part that does not fit in the RAM twice throws ENOMEM The join holds a file part twice: in the buffer of the read and in the joined buffer. So the limit for a part is half the RAM, and it applies on each platform. Before, the limit was the RAM, and only on Linux. The test used a sparse file of 4 TiB. The filesystem of the Alpine lanes refuses a file of that size (EFBIG). The file is now 3/4 of the RAM. The tests that start a child process now run concurrently. --- src/runtime/webcore/Blob.rs | 7 ++----- test/js/web/fetch/blob.test.ts | 18 +++++++++--------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 519995e8a296..0a609b046f13 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3580,11 +3580,8 @@ fn push_file_part( if cap == 0 { return Ok(()); } - // The rule of `fs.readFile`: on Linux, a read as large as the RAM fails before the OOM killer acts. - if cfg!(any(target_os = "linux", target_os = "android")) - && file_len > 0 - && cap >= bun_core::get_total_memory_size() - { + // The join holds a part twice (this buffer, then the joined one), so half the RAM is the most that fits. + if file_len > 0 && cap >= bun_core::get_total_memory_size() / 2 { return Err(throw(bun_sys::Error::oom())); } let initial = if file_len > 0 { cap } else { 8192.min(cap) }.min(8 << 30); diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index d1043949822a..49d9d8e63d35 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -4,7 +4,7 @@ import { mkfifo } from "mkfifo"; import type { BlobPropertyBag } from "node:buffer"; import type { BinaryLike } from "node:crypto"; import fs from "node:fs"; -import { devNull } from "node:os"; +import { devNull, totalmem } from "node:os"; import path from "node:path"; test("blob: imports have sourcemapped stacktraces", async () => { @@ -1170,7 +1170,7 @@ describe("new Blob([...]) with a file-backed Blob part", () => { exitCode: 0, }; - test("a pipe part throws instead of blocking", async () => { + test.concurrent("a pipe part throws instead of blocking", async () => { expect(await constructInChild("Bun.stdin")).toEqual(refused); }); @@ -1191,7 +1191,7 @@ describe("new Blob([...]) with a file-backed Blob part", () => { } }); - test.skipIf(isWindows)("only the null device is an empty part", async () => { + test.concurrent.skipIf(isWindows)("only the null device is an empty part", async () => { using dir = tempDir("blob-null-device-link", { "f.bin": "ABC" }); const link = path.join(String(dir), "null"); fs.symlinkSync("/dev/null", link); @@ -1200,7 +1200,7 @@ describe("new Blob([...]) with a file-backed Blob part", () => { expect(await constructInChild("Bun.file('/dev/zero')")).toEqual(refused); }); - test.skipIf(isWindows)("a FIFO part throws and leaves its writer waiting", async () => { + test.concurrent.skipIf(isWindows)("a FIFO part throws and leaves its writer waiting", async () => { using dir = tempDir("blob-fifo-part", {}); const fifo = path.join(String(dir), "fifo"); mkfifo(fifo); @@ -1224,7 +1224,7 @@ describe("new Blob([...]) with a file-backed Blob part", () => { }); // procfs gives a stat size of 0 for a file that has bytes, so the read must go on to the end of the file. - test.skipIf(!isLinux)("reads a file whose stat size is 0 to its end", async () => { + test.concurrent.skipIf(!isLinux)("reads a file whose stat size is 0 to its end", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -1253,12 +1253,12 @@ describe("new Blob([...]) with a file-backed Blob part", () => { }); }); - // The rule of fs.readFile: on Linux a read as large as the RAM fails at once, before the OOM killer acts. - // The sparse file has 4 TiB, which is more than the RAM of a machine. - test.skipIf(!isLinux)("a part as large as the RAM throws ENOMEM and reads nothing", async () => { + // The join holds a part twice: in the buffer of the read and in the joined buffer. + // The sparse file is 3/4 of the RAM, so one copy fits and two copies do not. + test.concurrent.skipIf(!isLinux)("a part too large for the RAM throws ENOMEM and reads nothing", async () => { using dir = tempDir("blob-file-part-huge", { "sparse.bin": "x" }); const sparse = path.join(String(dir), "sparse.bin"); - fs.truncateSync(sparse, 2 ** 42); + fs.truncateSync(sparse, Math.floor(totalmem() * 0.75)); await using proc = Bun.spawn({ cmd: [ bunExe(),