From 4cb626c56da1b3f7e85180108757207416d7dd59 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:14:55 +0000 Subject: [PATCH 1/7] Blob: keep File and FormData entry names on the Blob, not in the shared store new File([blob], name) shares the source blob's byte store and wrote the name into it, renaming the source (and anything else sharing the store). It also freed the previous name under FormData entries, which hold a zero-copy view of the name they read at append() time. Blob__setAsFile wrote FormData entry filenames into the same shared store, and lost them entirely for zero-byte blobs, which have no store. The File constructor and Blob__setAsFile now set the per-Blob name. The consumers that read a name for something other than the .name getter (FormData's default filename, the blob: loader, Bun.serve's Content-Disposition) go through the same per-Blob-first lookup the getter uses. The store's stored_name is only written when a store is created. The File constructor also converts the name as a USVString, which the old UTF-8 copy did implicitly for byte-backed Files but not for file-backed ones. --- src/jsc/webcore_types.rs | 23 ++-- src/runtime/server/RequestContext.rs | 4 +- src/runtime/webcore/Blob.rs | 89 +++++++-------- test/js/web/fetch/blob.test.ts | 157 +++++++++++++++++++++++++++ test/js/web/html/FormData.test.ts | 69 ++++++++++++ 5 files changed, 277 insertions(+), 65 deletions(-) diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index b3a6a52daae4..7f3aa947ba1f 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -138,7 +138,9 @@ pub struct Blob { pub ref_count: bun_ptr::RawRefCount, pub global_this: Cell<*const JSGlobalObject>, pub last_modified: Cell, - /// Only used by `` / `File` (issue #10178). + /// Name given to this Blob in particular (`File` constructor, FormData + /// filename, `.name =`); `Dead` when none was, and the store's + /// [`Self::get_file_name`] applies. pub name: bun_core::OwnedStringCell, } @@ -434,8 +436,10 @@ impl Blob { matches!(self.store.get().as_deref(), Some(s) if matches!(s.data, store::Data::File(_))) } - /// `Blob.getFileName()` — the user-visible name: `Bytes.stored_name`, - /// the file path, or the S3 key. `None` for fd-backed or unnamed blobs. + /// `Blob.getFileName()` — the name the *store* carries: `Bytes.stored_name`, + /// the file path, or the S3 key. `None` for fd-backed or unnamed stores. + /// A name given to this Blob itself is in `name`; `BlobExt::get_name_string` + /// combines the two. pub fn get_file_name(&self) -> Option<&[u8]> { match &self.store.get().as_deref()?.data { store::Data::Bytes(bytes) => { @@ -611,7 +615,10 @@ pub mod store { pub len: SizeType, pub cap: SizeType, pub allocator: bun_alloc::StdAllocator, - /// Used by standalone module graph and the `File` constructor. + /// Set when the store is created (standalone module graph, structured + /// clone) and never afterwards: the store is shared by every Blob + /// viewing it, so a name given to one Blob (`new File([blob], name)`, + /// a FormData filename) lives in `Blob::name` instead. /// Heap-owned (or empty); freed by `Bytes`'s `Drop`. pub stored_name: Box<[u8]>, } @@ -713,14 +720,6 @@ pub mod store { } } - #[inline] - pub fn init_empty_with_name(name: Box<[u8]>) -> Bytes { - Bytes { - stored_name: name, - ..Default::default() - } - } - #[inline] pub fn allocator(&self) -> bun_alloc::StdAllocator { self.allocator diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index bf000c8dc535..3732bf07110c 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -3857,8 +3857,8 @@ where // 1. Bun.file("foo") // 2. The content-disposition header is not present if !has_content_disposition && content_type.category.autoset_filename() { - if let Some(filename) = blob.get_file_name() { - let basename = bun_paths::basename(filename); + if let Some(filename) = blob.get_name_utf8() { + let basename = bun_paths::basename(filename.slice()); if !basename.is_empty() { let mut filename_buf = [0u8; 1024]; let truncated = &basename[..basename.len().min(1024 - 32)]; diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index c0072d8e89a8..c72c355c5d3e 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -246,6 +246,7 @@ pub trait BlobExt { fn get_mime_type_or_content_type(&self) -> Option; fn get_type(&self, global_this: &JSGlobalObject) -> JSValue; fn get_name_string(&self) -> Option; + fn get_name_utf8(&self) -> Option; fn get_name(&self, _: JSValue, global_this: &JSGlobalObject) -> JsResult; fn set_name( &self, @@ -2054,6 +2055,16 @@ impl BlobExt for Blob { None } + /// The name `.name` reports, for consumers that derive something from it + /// (loader, `filename=`). `None` when there is none or it is empty. + fn get_name_utf8(&self) -> Option { + let name = self.get_name_string()?; + if name.is_empty() { + return None; + } + Some(name.to_utf8()) + } + // TODO: Move this to a separate `File` object or BunFile fn get_name(&self, _: JSValue, global_this: &JSGlobalObject) -> JsResult { Ok(match self.get_name_string() { @@ -2087,8 +2098,8 @@ impl BlobExt for Blob { fn get_loader(&self, jsc_vm: &VirtualMachine) -> Option { use bun_resolver::fs::PathResolverExt as _; - if let Some(filename) = self.get_file_name() { - let current_path = bun_resolver::fs::Path::init(filename); + if let Some(filename) = self.get_name_utf8() { + let current_path = bun_resolver::fs::Path::init(filename.slice()); return Some( current_path .loader(&jsc_vm.transpiler.options.loaders) @@ -4239,19 +4250,14 @@ pub(crate) extern "C" fn Blob__dupeFromJS(value: JSValue) -> Option`) and freed by `Bytes::Drop`. - bytes.stored_name = path_str.to_owned_slice().into_boxed_slice(); - } - } + if !path_str.is_empty() { + this.name.set(path_str.dupe_ref()); } } @@ -4260,12 +4266,12 @@ pub(crate) extern "C" fn Blob__dupe(this: &Blob) -> *mut Blob { Blob::new(this.dupe_with_content_type(true)) } +/// JSDOMFormData.cpp: the default entry filename when `append`/`set` is given +/// none. Borrowed (`this` keeps it alive); the caller takes its own ref via +/// `toWTFString` and never derefs it. #[unsafe(no_mangle)] pub(crate) extern "C" fn Blob__getFileNameString(this: &Blob) -> BunString { - if let Some(filename) = this.get_file_name() { - return BunString::from_bytes(filename); - } - BunString::empty() + this.get_name_string().unwrap_or_else(BunString::empty) } // ────────────────────────────────────────────────────────────────────────── @@ -5525,7 +5531,6 @@ pub(crate) fn jsdom_file_construct( callframe: &CallFrame, ) -> JsResult<*mut Blob> { jsc::mark_binding(); - let blob: Blob; let args = callframe.arguments(); if args.len() < 2 { @@ -5533,39 +5538,21 @@ pub(crate) fn jsdom_file_construct( "new File(bits, name) expects at least 2 arguments" ))); } - { - use bun_jsc::StringJsc as _; - // +1 WTF ref; `OwnedString` releases it at scope exit. - // Every consumer below either - // copies bytes (`to_owned_slice`) or takes its own ref (`dupe_ref`). - let name_value_str = OwnedString::new(BunString::from_js(args[1], global_this)?); - - blob = Blob::get::(global_this, args[0])?; - if let Some(store_) = blob.store.get() { - match store_.data_mut() { - store::Data::Bytes(bytes) => { - // `get::<_, true>` on a single-Blob sequence returns - // `dupe()` (a shared StoreRef), so this `Bytes` may already - // carry an owned `stored_name` from the source blob; the - // assignment drops (frees) the previous `Box<[u8]>`. - bytes.stored_name = name_value_str.to_owned_slice().into_boxed_slice(); - } - store::Data::S3(_) | store::Data::File(_) => { - blob.name.set(name_value_str.dupe_ref()); - } - } - } else if !name_value_str.is_empty() { - // not store but we have a name so we need a store - blob.store.set(Some(StoreRef::from(Store::new(Store { - data: store::Data::Bytes(store::Bytes::init_empty_with_name( - name_value_str.to_owned_slice().into_boxed_slice(), - )), - ref_count: bun_ptr::ThreadSafeRefCount::init(), - mime_type: bun_http_types::MimeType::NONE, - is_all_ascii: None, - })))); - } + + // +1 WTF ref; `OwnedString` releases it if `get` throws, otherwise + // `into_inner` hands it to `blob.name`. + let mut name = OwnedString::new(BunString::from_js(args[1], global_this)?); + if name.is_utf16() { + // `name` is a USVString: the UTF-8 round trip turns lone surrogates + // (only possible in a 16-bit string) into U+FFFD. + let utf8 = name.to_utf8(); + name = OwnedString::new(BunString::clone_utf8(utf8.slice())); } + let blob = Blob::get::(global_this, args[0])?; + // A single-Blob `bits` shares the source's store (and `dupe()` copied its + // name), so the name goes on this Blob only: writing it into the store + // would rename every Blob sharing it. + blob.name.set(name.into_inner()); let mut set_last_modified = false; @@ -6345,9 +6332,9 @@ impl Any { } } - pub(crate) fn get_file_name(&self) -> Option<&[u8]> { + pub(crate) fn get_name_utf8(&self) -> Option { match self { - Any::Blob(b) => b.get_file_name(), + Any::Blob(b) => b.get_name_utf8(), Any::WTFStringImpl(_) | Any::InternalBlob(_) => None, } } diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index fc9b4500bcfc..fefb0ddc6f91 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -307,6 +307,163 @@ test("#12894", () => { expect(new File([bunFile], "bar.txt").name).toBe("bar.txt"); }); +describe("new File([blob], name) names only the new File", () => { + // A single-Blob `bits` shares the source's byte store instead of copying it. + // The name used to be written into that shared store, renaming the source + // (and every other Blob sharing the store) along with the new File. + // + // `.name` is cached on first read, so the source's name is always read after + // wrapping unless the test is about reading it before. + test.each([ + ["a File", () => new File(["xyz"], "source.txt"), "source.txt"], + ["an empty File", () => new File([], "source.txt"), "source.txt"], + ["a Blob", () => new Blob(["xyz"]), undefined], + ["an empty Blob", () => new Blob([]), undefined], + ])("wrapping %s", async (_, makeSource, sourceName) => { + const source = makeSource(); + const wrapped = new File([source], "wrapped.txt"); + expect({ + source: (source as File).name, + wrapped: wrapped.name, + wrappedBytes: await wrapped.text(), + }).toEqual({ + source: sourceName, + wrapped: "wrapped.txt", + wrappedBytes: await source.text(), + }); + }); + + test("wrapping a slice of a File", async () => { + const source = new File(["hello world"], "source.txt"); + const wrapped = new File([source.slice(0, 5)], "wrapped.txt"); + expect({ source: source.name, wrapped: wrapped.name, wrappedBytes: await wrapped.text() }).toEqual({ + source: "source.txt", + wrapped: "wrapped.txt", + wrappedBytes: "hello", + }); + }); + + test("every File in a chain of wrappers keeps its own name", () => { + const a = new File(["xyz"], "a.txt"); + const b = new File([a], "b.txt"); + const c = new File([b], "c.txt"); + expect([a.name, b.name, c.name]).toEqual(["a.txt", "b.txt", "c.txt"]); + }); + + test("reading the source's name first does not hand it to the wrapper", () => { + const source = new File(["xyz"], "source.txt"); + expect(source.name).toBe("source.txt"); + const wrapped = new File([source], "wrapped.txt"); + expect({ source: source.name, wrapped: wrapped.name }).toEqual({ source: "source.txt", wrapped: "wrapped.txt" }); + }); + + test("structuredClone copies each File's own name", () => { + const source = new File(["xyz"], "source.txt"); + const wrapped = new File([source], "wrapped.txt"); + expect([structuredClone(source).name, structuredClone(wrapped).name]).toEqual(["source.txt", "wrapped.txt"]); + }); + + test("an empty name is reported as an empty string", () => { + expect([new File(["xyz"], "").name, new File([], "").name, new File([new Blob(["xyz"])], "").name]).toEqual([ + "", + "", + "", + ]); + }); + + test("the name is converted as a USVString", async () => { + using dir = tempDir("file-name-usvstring", { "on-disk.txt": "xyz" }); + const bunFile = Bun.file(path.join(String(dir), "on-disk.txt")); + expect([ + new File(["xyz"], "a\uD800b").name, + new File([new Blob(["xyz"])], "a\uD800b").name, + new File([bunFile], "a\uD800b").name, + new File(["xyz"], "caf\u00e9 \u{1F600}").name, + ]).toEqual(["a\uFFFDb", "a\uFFFDb", "a\uFFFDb", "caf\u00e9 \u{1F600}"]); + }); + + test("blob: imports pick the loader from each File's own name", async () => { + const source = new File(['{"a":1}'], "source.json"); + const wrapped = new File([source], "wrapped.txt"); + const sourceURL = URL.createObjectURL(source); + const wrappedURL = URL.createObjectURL(wrapped); + try { + const [sourceModule, wrappedModule] = await Promise.all([import(sourceURL), import(wrappedURL)]); + expect([sourceModule.default, wrappedModule.default]).toEqual([{ a: 1 }, '{"a":1}']); + } finally { + URL.revokeObjectURL(sourceURL); + URL.revokeObjectURL(wrappedURL); + } + }); + + test("Bun.serve derives Content-Disposition from each File's own name", async () => { + using dir = tempDir("file-name-content-disposition", { "on-disk.bin": "xyz" }); + const source = new File(["xyz"], "source.zip", { type: "application/zip" }); + const bodies: Record = { + source, + wrapped: new File([source], "wrapped.zip", { type: "application/zip" }), + wrappedBunFile: new File([Bun.file(path.join(String(dir), "on-disk.bin"))], "display.zip", { + type: "application/zip", + }), + }; + await using server = Bun.serve({ + port: 0, + fetch: req => new Response(bodies[new URL(req.url).pathname.slice(1)]), + }); + const headers: Record = {}; + for (const key of Object.keys(bodies)) { + const res = await fetch(new URL(key, server.url)); + headers[key] = res.headers.get("content-disposition"); + expect(await res.text()).toBe("xyz"); + } + expect(headers).toEqual({ + source: 'filename="source.zip"', + wrapped: 'filename="wrapped.zip"', + wrappedBunFile: 'filename="display.zip"', + }); + }); + + // FormData reads the entry's default filename from the blob at append() time + // as a zero-copy view. Renaming through the shared store freed the bytes that + // view pointed at, so the multipart body read freed memory. Spawned so that + // the ASAN report for that read fails this test instead of taking down the + // test run; symbolizing that report in a debug build takes the child several + // seconds (the passing run is well under a second), hence the timeout. + test("wrapping a File does not disturb a FormData entry made from it", async () => { + const name = "source-" + Buffer.alloc(64, "s").toString() + ".txt"; + const script = ` + const source = new File(["xyz"], ${JSON.stringify(name)}); + const formData = new FormData(); + formData.append("file", source); + for (let i = 0; i < 8; i++) { + new File([source], "wrapped-" + Buffer.alloc(64, String(i)).toString() + ".txt"); + } + const body = await new Response(formData).text(); + console.log(JSON.stringify({ entry: formData.get("file").name, body: body.match(/filename="([^"]*)"/)[1] })); + `; + await using proc = Bun.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, + // ASAN builds may print a "WARNING: ASAN interferes ..." banner at startup. + stderr: stderr + .split("\n") + .filter(line => line && !line.startsWith("WARNING: ASAN interferes")) + .join("\n"), + exitCode, + }).toEqual({ + stdout: JSON.stringify({ entry: name, body: name }) + "\n", + stderr: "", + exitCode: 0, + }); + }, 30_000); +}); + test("dupeWithContentType does not alias the source's allocated content_type", async () => { // Regression: #23015 refactored Blob to be ref-counted and moved // `setNotHeapAllocated()` before the `isHeapAllocated()` guard in diff --git a/test/js/web/html/FormData.test.ts b/test/js/web/html/FormData.test.ts index 311852c31eaa..8527a6c76c4c 100644 --- a/test/js/web/html/FormData.test.ts +++ b/test/js/web/html/FormData.test.ts @@ -958,6 +958,75 @@ test("FormData.toJSON merges duplicate numeric field names into an array", async expect(exitCode).toBe(0); }); +describe("entry filenames belong to the entry, not to the byte store it shares with the appended blob", () => { + // A FormData entry holds a dupe of the appended blob that shares its byte + // store. Filenames used to be written into that store, so they showed up on + // the blob the caller appended (and on anything else sharing the store). + // + // `.name` is cached on first read, so the appended blob's name is read only + // after the entry has been converted back to JS. + it.each(["append", "set"] as const)("%s(name, blob, filename) leaves the appended Blob nameless", method => { + const blob = new Blob(["xyz"]); + const formData = new FormData(); + formData[method]("file", blob, "entry.txt"); + const entry = formData.get("file") as File; + expect({ entry: entry.name, blob: (blob as File).name }).toEqual({ entry: "entry.txt", blob: undefined }); + }); + + // A zero-byte Blob has no byte store at all, so a filename written into the + // store was simply dropped. + it("an empty Blob appended with a filename gets that filename", () => { + const formData = new FormData(); + formData.append("file", new Blob([]), "empty.txt"); + const entry = formData.get("file") as File; + expect({ name: entry.name, size: entry.size }).toEqual({ name: "empty.txt", size: 0 }); + }); + + it("a parsed zero-byte file part keeps its filename", async () => { + const response = new Response( + '--boundary\r\nContent-Disposition: form-data; name="file"; filename="empty.txt"\r\n\r\n\r\n--boundary--\r\n', + { headers: { "content-type": "multipart/form-data; boundary=boundary" } }, + ); + const entry = (await response.formData()).get("file") as File; + expect({ name: entry.name, size: entry.size }).toEqual({ name: "empty.txt", size: 0 }); + }); + + it("defaults the filename to the appended File's own name", async () => { + const source = new File(["xyz"], "source.txt"); + const wrapped = new File([source], "wrapped.txt"); + const formData = new FormData(); + formData.append("source", source); + formData.append("wrapped", wrapped); + const body = await new Response(formData).text(); + expect({ + entries: [(formData.get("source") as File).name, (formData.get("wrapped") as File).name], + body: body.match(/filename="[^"]*"/g), + }).toEqual({ + entries: ["source.txt", "wrapped.txt"], + body: ['filename="source.txt"', 'filename="wrapped.txt"'], + }); + }); + + it("wrapping a parsed entry in new File() does not rename the entry", async () => { + const response = new Response( + '--boundary\r\nContent-Disposition: form-data; name="file"; filename="parsed.txt"\r\n\r\nxyz\r\n--boundary--\r\n', + { headers: { "content-type": "multipart/form-data; boundary=boundary" } }, + ); + const formData = await response.formData(); + const entry = formData.get("file") as File; + const wrapped = new File([entry], "wrapped.txt"); + expect({ + entry: entry.name, + entryAgain: (formData.get("file") as File).name, + wrapped: wrapped.name, + }).toEqual({ + entry: "parsed.txt", + entryAgain: "parsed.txt", + wrapped: "wrapped.txt", + }); + }); +}); + describe("USVString conversion of lone surrogates", () => { const loneHigh = "a\uD800b"; const loneLow = "a\uDC00b"; From eb12199801408649ba0fcfc159d518e3807eac5a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:41:02 +0000 Subject: [PATCH 2/7] test: drop the per-test timeout on the spawned FormData filename test --- test/js/web/fetch/blob.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index fefb0ddc6f91..4e0cd92b9f4b 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -427,8 +427,7 @@ describe("new File([blob], name) names only the new File", () => { // as a zero-copy view. Renaming through the shared store freed the bytes that // view pointed at, so the multipart body read freed memory. Spawned so that // the ASAN report for that read fails this test instead of taking down the - // test run; symbolizing that report in a debug build takes the child several - // seconds (the passing run is well under a second), hence the timeout. + // test run. test("wrapping a File does not disturb a FormData entry made from it", async () => { const name = "source-" + Buffer.alloc(64, "s").toString() + ".txt"; const script = ` @@ -461,7 +460,7 @@ describe("new File([blob], name) names only the new File", () => { stderr: "", exitCode: 0, }); - }, 30_000); + }); }); test("dupeWithContentType does not alias the source's allocated content_type", async () => { From e813dabfaf675214f7a25c22c9d2118d6abd8313 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:51:35 +0000 Subject: [PATCH 3/7] ObjectURLRegistry: give the entry and each resolved copy a private name string A WTF::StringImpl becomes an atom of whichever thread first uses it as a property key, and dropping its last reference on another thread aborts in AtomStringImpl::remove. The registry shared the registered blob's name impl with every thread that resolved the URL. That was already reachable for file-backed Files and now, with names kept on the Blob, for every File, so copy the name when storing the entry and again on each resolve. --- src/runtime/webcore/ObjectURLRegistry.rs | 17 ++++- test/js/web/fetch/blob.test.ts | 81 ++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/runtime/webcore/ObjectURLRegistry.rs b/src/runtime/webcore/ObjectURLRegistry.rs index 7d28e518fd43..c7ad762c9d77 100644 --- a/src/runtime/webcore/ObjectURLRegistry.rs +++ b/src/runtime/webcore/ObjectURLRegistry.rs @@ -40,11 +40,24 @@ const _: fn() = || { impl Entry { pub(crate) fn init(blob: &Blob) -> Box { Box::new(Entry { - blob: blob.dupe_with_content_type(true), + blob: dupe_with_private_name(blob), }) } } +/// The registry is shared by every thread; a `WTF::StringImpl` must not be. +/// The first thread to use one as a property key turns it into an atom of its +/// own string table, and dropping the last reference from any other thread +/// then aborts in `AtomStringImpl::remove`. So the entry and every resolved +/// copy each get a `name` impl that no thread's JS can reach. +fn dupe_with_private_name(blob: &Blob) -> Blob { + let copy = blob.dupe_with_content_type(true); + let mut name = copy.name.replace(bun_core::String::dead()).into_inner(); + name.to_thread_safe(); + copy.name.set(name); + copy +} + impl Drop for Entry { fn drop(&mut self) { self.blob.deinit(); @@ -70,7 +83,7 @@ impl ObjectURLRegistry { let uuid = uuid_from_pathname(pathname)?; let map = self.map.lock(); map.get(&uuid.bytes) - .map(|e| e.blob.dupe_with_content_type(true)) + .map(|e| dupe_with_private_name(&e.blob)) } pub(crate) fn resolve_and_dupe_to_js( diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 4e0cd92b9f4b..40b0658cfbf9 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -463,6 +463,87 @@ describe("new File([blob], name) names only the new File", () => { }); }); +describe("a File's name reaches other threads through an object URL as a private copy", () => { + // A name that has been used as a property key is an atom of the thread that + // did so, and the process aborts if the last reference to it is dropped on a + // different thread. The registry used to hand the File's own name string to + // whichever thread resolved the URL; here the worker holds the resolved Blob + // until the main thread has dropped everything else that referenced the name. + test.concurrent.each(["bytes", "Bun.file"])("File backed by %s", async backing => { + using dir = tempDir("blob-url-name-thread", { "on-disk.txt": "xyz" }); + const bits = + backing === "bytes" ? `["xyz"]` : `[Bun.file(${JSON.stringify(path.join(String(dir), "on-disk.txt"))})]`; + const script = ` + const workerURL = URL.createObjectURL( + new Blob( + [ + \` + import { resolveObjectURL } from "node:buffer"; + let held; + self.onmessage = ({ data }) => { + if (data.url) { + held = resolveObjectURL(data.url); + postMessage({ holdsName: held.name === data.name }); + } else { + held = undefined; + Bun.gc(true); + postMessage({ released: true }); + } + }; + \`, + ], + { type: "text/javascript" }, + ), + ); + const worker = new Worker(workerURL); + const { promise, resolve, reject } = Promise.withResolvers(); + worker.onerror = event => reject(new Error(event.message)); + + let name = "name-" + process.pid; + ({})[name]; // used as a property key: the string is now an atom of this thread + let file = new File(${bits}, name); + let url = URL.createObjectURL(file); + worker.onmessage = ({ data }) => { + if ("holdsName" in data) { + console.log("worker holds the name:", data.holdsName); + // Drop every reference this thread has to the name (the structured + // clone sent to the worker is a separate string), so the worker's is + // the last one. + URL.revokeObjectURL(url); + file = url = name = undefined; + Bun.gc(true); + worker.postMessage({ release: true }); + } else { + resolve(); + } + }; + worker.postMessage({ url, name }); + await promise; + worker.terminate(); + console.log("released on the worker"); + `; + await using proc = Bun.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, + stderr: stderr + .split("\n") + .filter(line => line && !line.startsWith("WARNING: ASAN interferes")) + .join("\n"), + exitCode, + }).toEqual({ + stdout: "worker holds the name: true\nreleased on the worker\n", + stderr: "", + exitCode: 0, + }); + }); +}); + test("dupeWithContentType does not alias the source's allocated content_type", async () => { // Regression: #23015 refactored Blob to be ref-counted and moved // `setNotHeapAllocated()` before the `isHeapAllocated()` guard in From de3f1c9def9b6d6fac788f468c4ef7c9d3f23b95 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:00:12 +0000 Subject: [PATCH 4/7] Shorten the comments added around Blob names --- src/jsc/webcore_types.rs | 17 +++++------------ src/runtime/webcore/Blob.rs | 20 ++++++-------------- src/runtime/webcore/ObjectURLRegistry.rs | 7 ++----- 3 files changed, 13 insertions(+), 31 deletions(-) diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index 7f3aa947ba1f..8d622e446ea3 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -138,9 +138,7 @@ pub struct Blob { pub ref_count: bun_ptr::RawRefCount, pub global_this: Cell<*const JSGlobalObject>, pub last_modified: Cell, - /// Name given to this Blob in particular (`File` constructor, FormData - /// filename, `.name =`); `Dead` when none was, and the store's - /// [`Self::get_file_name`] applies. + /// This Blob's own name; `Dead` means the store's [`Self::get_file_name`] applies. pub name: bun_core::OwnedStringCell, } @@ -436,10 +434,8 @@ impl Blob { matches!(self.store.get().as_deref(), Some(s) if matches!(s.data, store::Data::File(_))) } - /// `Blob.getFileName()` — the name the *store* carries: `Bytes.stored_name`, - /// the file path, or the S3 key. `None` for fd-backed or unnamed stores. - /// A name given to this Blob itself is in `name`; `BlobExt::get_name_string` - /// combines the two. + /// `Blob.getFileName()` — the store's name (`Bytes.stored_name`, the file path, + /// or the S3 key), which [`Self::name`] overrides. `None` for fd-backed or unnamed stores. pub fn get_file_name(&self) -> Option<&[u8]> { match &self.store.get().as_deref()?.data { store::Data::Bytes(bytes) => { @@ -615,11 +611,8 @@ pub mod store { pub len: SizeType, pub cap: SizeType, pub allocator: bun_alloc::StdAllocator, - /// Set when the store is created (standalone module graph, structured - /// clone) and never afterwards: the store is shared by every Blob - /// viewing it, so a name given to one Blob (`new File([blob], name)`, - /// a FormData filename) lives in `Blob::name` instead. - /// Heap-owned (or empty); freed by `Bytes`'s `Drop`. + /// Set only when the store is created; a name given to one of the Blobs + /// sharing the store goes in `Blob::name`. Heap-owned (or empty); freed by `Drop`. pub stored_name: Box<[u8]>, } diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index c72c355c5d3e..222c0a9db2ce 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -2055,8 +2055,7 @@ impl BlobExt for Blob { None } - /// The name `.name` reports, for consumers that derive something from it - /// (loader, `filename=`). `None` when there is none or it is empty. + /// [`Self::get_name_string`] as UTF-8; `None` when there is no name or it is empty. fn get_name_utf8(&self) -> Option { let name = self.get_name_string()?; if name.is_empty() { @@ -4250,9 +4249,8 @@ pub(crate) extern "C" fn Blob__dupeFromJS(value: JSValue) -> Option *mut Blob { Blob::new(this.dupe_with_content_type(true)) } -/// JSDOMFormData.cpp: the default entry filename when `append`/`set` is given -/// none. Borrowed (`this` keeps it alive); the caller takes its own ref via +/// JSDOMFormData.cpp's default entry filename. Borrowed: the caller refs it via /// `toWTFString` and never derefs it. #[unsafe(no_mangle)] pub(crate) extern "C" fn Blob__getFileNameString(this: &Blob) -> BunString { @@ -5539,19 +5536,14 @@ pub(crate) fn jsdom_file_construct( ))); } - // +1 WTF ref; `OwnedString` releases it if `get` throws, otherwise - // `into_inner` hands it to `blob.name`. let mut name = OwnedString::new(BunString::from_js(args[1], global_this)?); if name.is_utf16() { - // `name` is a USVString: the UTF-8 round trip turns lone surrogates - // (only possible in a 16-bit string) into U+FFFD. + // USVString: the UTF-8 round trip replaces lone surrogates with U+FFFD. let utf8 = name.to_utf8(); name = OwnedString::new(BunString::clone_utf8(utf8.slice())); } let blob = Blob::get::(global_this, args[0])?; - // A single-Blob `bits` shares the source's store (and `dupe()` copied its - // name), so the name goes on this Blob only: writing it into the store - // would rename every Blob sharing it. + // Not into the store: a single-Blob `bits` shares the source's store. blob.name.set(name.into_inner()); let mut set_last_modified = false; diff --git a/src/runtime/webcore/ObjectURLRegistry.rs b/src/runtime/webcore/ObjectURLRegistry.rs index c7ad762c9d77..ec95467d74fc 100644 --- a/src/runtime/webcore/ObjectURLRegistry.rs +++ b/src/runtime/webcore/ObjectURLRegistry.rs @@ -45,11 +45,8 @@ impl Entry { } } -/// The registry is shared by every thread; a `WTF::StringImpl` must not be. -/// The first thread to use one as a property key turns it into an atom of its -/// own string table, and dropping the last reference from any other thread -/// then aborts in `AtomStringImpl::remove`. So the entry and every resolved -/// copy each get a `name` impl that no thread's JS can reach. +/// A name impl that some thread's JS can reach may become that thread's atom, and an +/// atom must not be released on another thread (`AtomStringImpl::remove`), so none is shared. fn dupe_with_private_name(blob: &Blob) -> Blob { let copy = blob.dupe_with_content_type(true); let mut name = copy.name.replace(bun_core::String::dead()).into_inner(); From ec05e1e8c2c116a489fd96a40526fd5f32e0f315 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:03:54 +0000 Subject: [PATCH 5/7] Make the remaining added comments single lines --- src/jsc/webcore_types.rs | 6 ++---- src/runtime/webcore/Blob.rs | 6 ++---- src/runtime/webcore/ObjectURLRegistry.rs | 3 +-- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index 8d622e446ea3..809b735c998a 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -434,8 +434,7 @@ impl Blob { matches!(self.store.get().as_deref(), Some(s) if matches!(s.data, store::Data::File(_))) } - /// `Blob.getFileName()` — the store's name (`Bytes.stored_name`, the file path, - /// or the S3 key), which [`Self::name`] overrides. `None` for fd-backed or unnamed stores. + /// The store's own name (`stored_name`, path, or S3 key); `None` when fd-backed or unnamed. pub fn get_file_name(&self) -> Option<&[u8]> { match &self.store.get().as_deref()?.data { store::Data::Bytes(bytes) => { @@ -611,8 +610,7 @@ pub mod store { pub len: SizeType, pub cap: SizeType, pub allocator: bun_alloc::StdAllocator, - /// Set only when the store is created; a name given to one of the Blobs - /// sharing the store goes in `Blob::name`. Heap-owned (or empty); freed by `Drop`. + /// Set only at store creation (names given later go in `Blob::name`); heap-owned or empty. pub stored_name: Box<[u8]>, } diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 222c0a9db2ce..d054d0a710eb 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -4249,8 +4249,7 @@ pub(crate) extern "C" fn Blob__dupeFromJS(value: JSValue) -> Option *mut Blob { Blob::new(this.dupe_with_content_type(true)) } -/// JSDOMFormData.cpp's default entry filename. Borrowed: the caller refs it via -/// `toWTFString` and never derefs it. +/// Borrowed: JSDOMFormData.cpp refs it via `toWTFString` and never derefs it. #[unsafe(no_mangle)] pub(crate) extern "C" fn Blob__getFileNameString(this: &Blob) -> BunString { this.get_name_string().unwrap_or_else(BunString::empty) diff --git a/src/runtime/webcore/ObjectURLRegistry.rs b/src/runtime/webcore/ObjectURLRegistry.rs index ec95467d74fc..ba010ea47a9c 100644 --- a/src/runtime/webcore/ObjectURLRegistry.rs +++ b/src/runtime/webcore/ObjectURLRegistry.rs @@ -45,8 +45,7 @@ impl Entry { } } -/// A name impl that some thread's JS can reach may become that thread's atom, and an -/// atom must not be released on another thread (`AtomStringImpl::remove`), so none is shared. +/// A name impl reachable from JS may become that thread's atom, which no other thread may release. fn dupe_with_private_name(blob: &Blob) -> Blob { let copy = blob.dupe_with_content_type(true); let mut name = copy.name.replace(bun_core::String::dead()).into_inner(); From ad64abb2bfc9ace0d6335a0d820052b62127d11f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:43:59 +0000 Subject: [PATCH 6/7] Blob: do not cache in get_name_utf8 The loader and Content-Disposition lookups borrow the store's name when the blob has none of its own, as they did before, instead of allocating a cached copy per response body. Pin both fallbacks with a Bun.file() case. --- src/runtime/webcore/Blob.rs | 9 +++++++-- test/js/web/fetch/blob.test.ts | 27 +++++++++++++++------------ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index d054d0a710eb..54dec1cadd94 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -2055,9 +2055,14 @@ impl BlobExt for Blob { None } - /// [`Self::get_name_string`] as UTF-8; `None` when there is no name or it is empty. + /// [`Self::get_name_string`]'s lookup as bytes, without caching; `None` when absent or empty. fn get_name_utf8(&self) -> Option { - let name = self.get_name_string()?; + let name = self.name.get(); + if name.tag() == bun_core::Tag::Dead { + return self + .get_file_name() + .map(ZigStringSlice::from_utf8_never_free); + } if name.is_empty() { return None; } diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 40b0658cfbf9..ecab7c4ea89f 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -382,29 +382,31 @@ describe("new File([blob], name) names only the new File", () => { ]).toEqual(["a\uFFFDb", "a\uFFFDb", "a\uFFFDb", "caf\u00e9 \u{1F600}"]); }); - test("blob: imports pick the loader from each File's own name", async () => { + test("blob: imports pick the loader from each File's own name, or from a Bun.file's path", async () => { + using dir = tempDir("blob-url-loader", { "on-disk.json": '{"a":1}' }); const source = new File(['{"a":1}'], "source.json"); - const wrapped = new File([source], "wrapped.txt"); - const sourceURL = URL.createObjectURL(source); - const wrappedURL = URL.createObjectURL(wrapped); + const urls = [ + URL.createObjectURL(source), + URL.createObjectURL(new File([source], "wrapped.txt")), + URL.createObjectURL(Bun.file(path.join(String(dir), "on-disk.json"))), + ]; try { - const [sourceModule, wrappedModule] = await Promise.all([import(sourceURL), import(wrappedURL)]); - expect([sourceModule.default, wrappedModule.default]).toEqual([{ a: 1 }, '{"a":1}']); + const modules = await Promise.all(urls.map(url => import(url))); + expect(modules.map(module => module.default)).toEqual([{ a: 1 }, '{"a":1}', { a: 1 }]); } finally { - URL.revokeObjectURL(sourceURL); - URL.revokeObjectURL(wrappedURL); + urls.forEach(url => URL.revokeObjectURL(url)); } }); - test("Bun.serve derives Content-Disposition from each File's own name", async () => { + test("Bun.serve derives Content-Disposition from each File's own name, or from a Bun.file's path", async () => { using dir = tempDir("file-name-content-disposition", { "on-disk.bin": "xyz" }); + const bunFile = Bun.file(path.join(String(dir), "on-disk.bin")); const source = new File(["xyz"], "source.zip", { type: "application/zip" }); const bodies: Record = { source, wrapped: new File([source], "wrapped.zip", { type: "application/zip" }), - wrappedBunFile: new File([Bun.file(path.join(String(dir), "on-disk.bin"))], "display.zip", { - type: "application/zip", - }), + wrappedBunFile: new File([bunFile], "display.zip", { type: "application/zip" }), + bunFile, }; await using server = Bun.serve({ port: 0, @@ -420,6 +422,7 @@ describe("new File([blob], name) names only the new File", () => { source: 'filename="source.zip"', wrapped: 'filename="wrapped.zip"', wrappedBunFile: 'filename="display.zip"', + bunFile: 'filename="on-disk.bin"', }); }); From 0aa2f22e1afc18b7eb151a16a8b5c22b59d1c349 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:38:07 +0000 Subject: [PATCH 7/7] Pin the name lookups that changed for file-backed and setter-named blobs The blob: loader and FormData's default filename now follow a File's own name for a File wrapping a Bun.file() and for a blob named through the setter, and a FormData filename override applies to an already named File. Add tests for each, and note at the two module-loading sites that the path they read from the store is intentionally not the File's name. --- src/runtime/jsc_hooks.rs | 2 ++ test/js/web/fetch/blob.test.ts | 10 +++++-- test/js/web/html/FormData.test.ts | 49 ++++++++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 60b81f6a0412..c12f8e51e795 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1502,6 +1502,7 @@ mod vm_loader_ctx { // Returned slices borrow blob heap storage that lives until // `blob_deinit`; erased to `'static` per the interface signature — // sound because the bundler caller drops them before `blob_deinit`. + // The store's path (what gets read), not the File's own name. blob_file_name(b) => blob(b) .get_file_name() .map(|s| core::slice::from_raw_parts(s.as_ptr(), s.len())), @@ -4241,6 +4242,7 @@ unsafe fn get_loader_and_virtual_source<'a>( loader = blob.get_loader(unsafe { &*jsc_vm }); // "file:" loader makes no sense for blobs, so default to tsx. + // The store's path (what gets read), not the File's own name. if let Some(filename) = blob.get_file_name() { // Only treat it as a file if it is a `Bun.file()`. if blob.needs_to_read_file() { diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index ecab7c4ea89f..e0483bda75d5 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -383,16 +383,20 @@ describe("new File([blob], name) names only the new File", () => { }); test("blob: imports pick the loader from each File's own name, or from a Bun.file's path", async () => { - using dir = tempDir("blob-url-loader", { "on-disk.json": '{"a":1}' }); + using dir = tempDir("blob-url-loader", { "on-disk.json": '{"onDisk":true}' }); + const onDisk = Bun.file(path.join(String(dir), "on-disk.json")); const source = new File(['{"a":1}'], "source.json"); const urls = [ URL.createObjectURL(source), URL.createObjectURL(new File([source], "wrapped.txt")), - URL.createObjectURL(Bun.file(path.join(String(dir), "on-disk.json"))), + URL.createObjectURL(onDisk), + // A File wrapping a Bun.file() is named like any other File: the loader + // follows the name it was given, the bytes still come from the disk. + URL.createObjectURL(new File([onDisk], "wrapped-on-disk.txt")), ]; try { const modules = await Promise.all(urls.map(url => import(url))); - expect(modules.map(module => module.default)).toEqual([{ a: 1 }, '{"a":1}', { a: 1 }]); + expect(modules.map(module => module.default)).toEqual([{ a: 1 }, '{"a":1}', { onDisk: true }, '{"onDisk":true}']); } finally { urls.forEach(url => URL.revokeObjectURL(url)); } diff --git a/test/js/web/html/FormData.test.ts b/test/js/web/html/FormData.test.ts index 8527a6c76c4c..3111846a7152 100644 --- a/test/js/web/html/FormData.test.ts +++ b/test/js/web/html/FormData.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness"; import { join } from "path"; describe("FormData", () => { @@ -1007,6 +1007,53 @@ describe("entry filenames belong to the entry, not to the byte store it shares w }); }); + // The default filename used to come from the byte store, which for these two + // is the on-disk path and nothing at all, while .name already said otherwise. + it("defaults the filename to the name a File wrapping a Bun.file() was given", async () => { + using dir = tempDir("formdata-wrapped-bun-file", { "on-disk.bin": "xyz" }); + const formData = new FormData(); + formData.append("file", new File([Bun.file(join(String(dir), "on-disk.bin"))], "display.bin")); + const body = await new Response(formData).text(); + expect({ + entry: (formData.get("file") as File).name, + body: body.match(/filename="[^"]*"/g), + }).toEqual({ + entry: "display.bin", + body: ['filename="display.bin"'], + }); + }); + + it("defaults the filename to a name assigned through the Blob name setter", async () => { + const blob = new Blob(["xyz"]) as Blob & { name: string }; + blob.name = "assigned.txt"; + const formData = new FormData(); + formData.append("file", blob); + const body = await new Response(formData).text(); + expect({ + entry: (formData.get("file") as File).name, + body: body.match(/filename="[^"]*"/g), + }).toEqual({ + entry: "assigned.txt", + body: ['filename="assigned.txt"'], + }); + }); + + it.each(["append", "set"] as const)("%s(name, file, filename) renames only the entry", async method => { + const file = new File(["xyz"], "original.txt"); + const formData = new FormData(); + formData[method]("file", file, "override.txt"); + const body = await new Response(formData).text(); + expect({ + entry: (formData.get("file") as File).name, + body: body.match(/filename="[^"]*"/g), + file: file.name, + }).toEqual({ + entry: "override.txt", + body: ['filename="override.txt"'], + file: "original.txt", + }); + }); + it("wrapping a parsed entry in new File() does not rename the entry", async () => { const response = new Response( '--boundary\r\nContent-Disposition: form-data; name="file"; filename="parsed.txt"\r\n\r\nxyz\r\n--boundary--\r\n',