From edd45895054fb52c0e5727871abe8e98859fb01a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 29 May 2026 18:58:31 +0000 Subject: [PATCH 1/8] Delete PathString Replace the packed ptr+len `bun_core::PathString` with the existing byte-slice idioms and remove the type. - Arena / process-lifetime paths (resolver `Entry.abs_path` & `EntryCache.symlink`, router `Route` path columns, test-runner file lists) -> `bun_ptr::Interned`. This deletes the `path_string_static` and `arena_slice` lifetime-widening shims, which only existed because `PathString::slice()` tied the borrow to `&self`. - Bundler `EntryPoint.output_path`, dir_iterator entry name, and the `node::PathOrBuffer` path variant -> `bun_core::RawSlice` (borrowed, outlives-holder). - `bun_sys`/`bun_io` `PathOrFileDescriptor::Path` -> `&[u8]` (ephemeral borrow, compiler-checked). - Owned sites -> owning types, removing the manual `init_owned`/ `deinit_owned` contract: `Store.Bytes.stored_name` -> `Box<[u8]>` (freed by `Bytes`'s Drop), `PathLike::String` -> owned-or-borrowed `bun_ptr::cow_slice::CowSlice` (freed by `PathLike`'s Drop, so the blob `Store` no longer frees it explicitly), and the async readdir-recursive task's `root_path`/`basename` -> owned `Box<[u8]>` (drops the leak/reconstruct dance). Net change is behavior-preserving. --- Cargo.lock | 1 + src/bun_core/lib.rs | 2 +- src/bun_core/string/PathString.rs | 190 ------------------ src/bun_core/string/mod.rs | 3 - src/bundler/LinkerGraph.rs | 16 +- src/bundler/OutputFile.rs | 4 +- .../linker_context/writeOutputFilesToDisk.rs | 8 +- src/io/lib.rs | 4 +- src/io/openForWriting.rs | 4 +- src/jsc/RuntimeTranspilerCache.rs | 18 +- src/jsc/hot_reloader.rs | 10 +- src/jsc/node_path.rs | 23 ++- src/jsc/webcore_types.rs | 73 ++----- src/patch/lib.rs | 5 +- src/resolver/fs.rs | 33 +-- src/resolver/lib.rs | 27 +-- src/resolver/resolver.rs | 32 +-- src/router/Cargo.toml | 1 + src/router/lib.rs | 94 +++------ src/runtime/api/filesystem_router.rs | 2 +- src/runtime/api/js_bundle_completion_task.rs | 2 +- src/runtime/api/output_file_jsc.rs | 18 +- src/runtime/api/standalone_graph_jsc.rs | 4 +- src/runtime/cli/build_command.rs | 4 +- src/runtime/cli/test/ChangedFilesFilter.rs | 15 +- src/runtime/cli/test/Scanner.rs | 10 +- src/runtime/cli/test/parallel/Coordinator.rs | 13 +- src/runtime/cli/test/parallel/runner.rs | 8 +- src/runtime/cli/test_command.rs | 18 +- src/runtime/node/dir_iterator.rs | 33 ++- src/runtime/node/node_fs.rs | 122 +++++------ src/runtime/node/types.rs | 2 +- src/runtime/shell/builtin/cp.rs | 6 +- src/runtime/shell/builtin/mkdir.rs | 5 +- src/runtime/webcore/Blob.rs | 60 +++--- src/runtime/webcore/FileSink.rs | 9 +- src/runtime/webcore/blob/Store.rs | 4 +- src/sys/lib.rs | 11 +- test/js/web/fetch/blob.test.ts | 38 ++++ 39 files changed, 362 insertions(+), 570 deletions(-) delete mode 100644 src/bun_core/string/PathString.rs diff --git a/Cargo.lock b/Cargo.lock index 5cebe113107..f93ad4204d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1655,6 +1655,7 @@ dependencies = [ "bun_js_parser", "bun_options_types", "bun_paths", + "bun_ptr", "bun_resolver", "bun_sys", "bun_url", diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index 4744b5bd653..21202e3505b 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -62,7 +62,7 @@ pub use string::{ string_joiner, write, zig_string, }; pub use string::{ - HashedString, MutableString, NodeEncoding, OwnedString, OwnedStringCell, PathString, + HashedString, MutableString, NodeEncoding, OwnedString, OwnedStringCell, SliceWithUnderlyingString, SmolStr, String, StringBuilder, WTFStringImpl, WTFStringImplExt, WTFStringImplStruct, ZigString, ZigStringSlice, }; diff --git a/src/bun_core/string/PathString.rs b/src/bun_core/string/PathString.rs deleted file mode 100644 index 6191c79429f..00000000000 --- a/src/bun_core/string/PathString.rs +++ /dev/null @@ -1,190 +0,0 @@ -use core::fmt; - -use crate::MAX_PATH_BYTES; -use crate::string::ZStr; - -// const PathIntLen = std.math.IntFittingRange(0, bun.MAX_PATH_BYTES); -// Compute the number of bits needed to hold 0..=MAX_PATH_BYTES. -const PATH_INT_LEN_BITS: u32 = { - let mut n: usize = MAX_PATH_BYTES; - let mut bits: u32 = 0; - while n > 0 { - bits += 1; - n >>= 1; - } - bits -}; - -const USE_SMALL_PATH_STRING_: bool = (usize::BITS - PATH_INT_LEN_BITS) >= 53; - -// const PathStringBackingIntType = if (use_small_path_string_) u64 else u128; -// Zig picks the backing integer at comptime: u64 if 53 ptr bits + len bits fit -// (MAX_PATH_BYTES ≤ 2048 → ≤ 11 len bits); u128 otherwise (Linux/Android -// MAX_PATH_BYTES=4096 → 13 len bits → 64-13=51 < 53; Windows → way more). -// Stable Rust cannot select a type from a const bool, so cfg by OS — this list -// MUST track `MAX_PATH_BYTES` in `bun_core/util.rs`. The const-assert below -// verifies they agree. -#[cfg(any( - target_os = "linux", - target_os = "android", - windows, - target_arch = "wasm32" -))] -type PathStringBackingInt = u128; -#[cfg(not(any( - target_os = "linux", - target_os = "android", - windows, - target_arch = "wasm32" -)))] -type PathStringBackingInt = u64; // macOS / FreeBSD / OpenBSD / NetBSD / DragonFly / Solaris / iOS - -// Bit widths of the packed fields (Zig packed-struct order: ptr in low bits, len in high bits). -const POINTER_BITS: u32 = if USE_SMALL_PATH_STRING_ { - 53 -} else { - usize::BITS -}; - -// macOS sets file path limit to 1024 -// Since a pointer on x64 is 64 bits and only 46 bits are used -// We can safely store the entire path slice in a single u64. -#[repr(transparent)] -#[derive(Copy, Clone, Eq, PartialEq, Default)] -pub struct PathString(PathStringBackingInt); - -impl PathString { - const PTR_MASK: PathStringBackingInt = (1 as PathStringBackingInt) - .wrapping_shl(POINTER_BITS) - .wrapping_sub(1); - - #[inline(always)] - fn ptr(self) -> usize { - (self.0 & Self::PTR_MASK) as usize - } - - #[inline(always)] - fn len(self) -> usize { - (self.0 >> POINTER_BITS) as usize - } - - pub fn estimated_size(self) -> usize { - self.len() - } - - #[inline] - pub fn slice(&self) -> &[u8] { - // Zig: @setRuntimeSafety(false) — "cast causes pointer to be null" is - // fine here. if it is null, the len will be 0. - let ptr = self.ptr(); - if ptr == 0 { - // Rust forbids slice::from_raw_parts(null, 0); return a valid empty slice. - return &[]; - } - // SAFETY: PathString::init was given a live &[u8] of this len; caller - // guarantees the borrowed memory outlives this PathString. - unsafe { core::slice::from_raw_parts(ptr as *const u8, self.len()) } - } - - #[inline] - pub fn slice_assume_z(&self) -> &ZStr { - // Zig: @setRuntimeSafety(false) — "cast causes pointer to be null" is - // fine here. if it is null, the len will be 0. - let ptr = self.ptr(); - if ptr == 0 { - return ZStr::EMPTY; - } - // SAFETY: caller asserts the backing buffer has a NUL at [len]. - unsafe { ZStr::from_raw(ptr as *const u8, self.len()) } - } - - /// Create a PathString from a borrowed slice. No allocation occurs. - #[inline] - pub fn init(str: &[u8]) -> Self { - // Zig: @setRuntimeSafety(false) — "cast causes pointer to be null" is - // fine here. if it is null, the len will be 0. - let ptr = (str.as_ptr() as usize as PathStringBackingInt) & Self::PTR_MASK; // @truncate - let len = (str.len() as PathStringBackingInt) << POINTER_BITS; // @truncate into PathInt - Self(ptr | len) - } - - /// Take ownership of `bytes`, store its raw pointer/len, and forget the - /// allocation. The returned PathString must be paired with - /// [`deinit_owned`] (typically by the containing struct's `Drop`) to avoid - /// a leak — this mirrors Zig, where `Bytes.deinit` runs - /// `default_allocator.free(stored_name.slice())`. - /// - /// PathString itself stays `Copy` (it is a packed pointer), so ownership - /// is a contract on the *container*, not enforced by the type. - #[inline] - pub fn init_owned(bytes: Vec) -> Self { - if bytes.is_empty() { - return Self::EMPTY; - } - // Shed any unused capacity so the (ptr,len) pair fully describes the - // allocation and `deinit_owned` can reconstruct it without tracking - // capacity separately. `heap::alloc` (not `leak`) is the explicit - // ownership-transfer-to-raw API; the matching `heap::take` lives - // in `deinit_owned`. - let raw: *mut [u8] = crate::heap::into_raw(bytes.into_boxed_slice()); - // SAFETY: `raw` is a fresh non-null allocation; reborrow only to pack - // ptr+len into the backing int. - Self::init(unsafe { &*raw }) - } - - /// Free a heap allocation previously adopted by [`init_owned`]. No-op for - /// `EMPTY`/borrowed-static slices of length 0. - /// - /// # Safety - /// `self` must have been produced by [`init_owned`] (or be empty). Calling - /// this on a borrowed PathString is UB. - #[inline] - pub unsafe fn deinit_owned(&mut self) { - let ptr = self.ptr(); - let len = self.len(); - *self = Self::EMPTY; - if ptr == 0 || len == 0 { - return; - } - // SAFETY: caller contract — (ptr,len) is exactly the `Box<[u8]>` that - // `init_owned` released via `into_raw`. - drop(unsafe { crate::heap::take(std::ptr::slice_from_raw_parts_mut(ptr as *mut u8, len)) }); - } - - #[inline] - pub fn is_empty(self) -> bool { - self.len() == 0 - } - - pub const EMPTY: Self = Self(0); - - /// Zig: `pub const empty: PathString = PathString{};` — value form of - /// [`EMPTY`] for call sites that read better as a constructor. - #[inline(always)] - pub const fn empty() -> Self { - Self::EMPTY - } -} - -impl fmt::Display for PathString { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Display::fmt(bstr::BStr::new(self.slice()), f) - } -} - -#[cfg(not(target_arch = "wasm32"))] -const _: () = { - if USE_SMALL_PATH_STRING_ { - assert!( - core::mem::size_of::() * 8 == 64, - "PathString must be 64 bits" - ); - } else { - assert!( - core::mem::size_of::() * 8 == 128, - "PathString must be 128 bits" - ); - } -}; - -// ported from: src/string/PathString.zig diff --git a/src/bun_core/string/mod.rs b/src/bun_core/string/mod.rs index 048590ab038..acfb82a0429 100644 --- a/src/bun_core/string/mod.rs +++ b/src/bun_core/string/mod.rs @@ -18,8 +18,6 @@ pub use crate::{exact_case, literal, to_utf16_literal, w}; pub mod escape_reg_exp; #[path = "HashedString.rs"] pub mod hashed_string; -#[path = "PathString.rs"] -pub mod path_string; #[path = "SmolStr.rs"] pub mod smol_str; #[path = "StringBuilder.rs"] @@ -2309,7 +2307,6 @@ pub use crate::StringPointer; pub use hashed_string::HashedString; pub use mutable_string::MutableString; -pub use path_string::PathString; pub use smol_str::SmolStr; pub use string_builder::StringBuilder; diff --git a/src/bundler/LinkerGraph.rs b/src/bundler/LinkerGraph.rs index a03b43881c8..6ab2d8502cd 100644 --- a/src/bundler/LinkerGraph.rs +++ b/src/bundler/LinkerGraph.rs @@ -7,7 +7,7 @@ use bun_ast::server_component_boundary; use bun_ast::symbol; use bun_ast::{DeclaredSymbol, DeclaredSymbolList, Dependency, Symbol}; use bun_collections::{AutoBitSet, DynamicBitSetUnmanaged as BitSet, MultiArrayList, VecExt}; -use bun_core::PathString; +use bun_core::RawSlice; use crate::IndexStringMap::IndexStringMap; use crate::{ImportTracker, Index, JSAst, Part, Ref, UseDirective, import_record, index, part}; @@ -20,11 +20,11 @@ bun_core::declare_scope!(LinkerGraph, visible); pub mod entry_point { use bun_collections::MultiArrayList; - use bun_core::PathString; + use bun_core::RawSlice; #[derive(Default)] pub struct EntryPoint { - pub output_path: PathString, + pub output_path: RawSlice, pub source_index: crate::IndexInt, pub output_path_was_auto_generated: bool, } @@ -33,7 +33,7 @@ pub mod entry_point { bun_collections::multi_array_columns! { pub trait EntryPointColumns for EntryPoint { - output_path: PathString, + output_path: RawSlice, source_index: crate::IndexInt, output_path_was_auto_generated: bool, } @@ -700,7 +700,7 @@ impl<'a> LinkerGraph<'a> { let mut ep_slice = self.entry_points.slice(); let ep_cols = ep_slice.split_mut(); let source_indices: &mut [index::Int] = ep_cols.source_index; - let path_strings: &mut [PathString] = ep_cols.output_path; + let path_strings: &mut [RawSlice] = ep_cols.output_path; ep_cols.output_path_was_auto_generated.fill(false); debug_assert_eq!(entry_points.len(), path_strings.len()); @@ -718,9 +718,9 @@ impl<'a> LinkerGraph<'a> { // Check if this entry point has an original name (from virtual entry resolution) if let Some(original_name) = entry_point_original_names.get(i.get()) { - *path_string = PathString::init(original_name); + *path_string = RawSlice::new(original_name); } else { - *path_string = PathString::init(source.path.text); + *path_string = RawSlice::new(source.path.text); } *source_index = source.index.0; @@ -739,7 +739,7 @@ impl<'a> LinkerGraph<'a> { self.entry_points.append_assume_capacity(EntryPoint { source_index: id, - output_path: PathString::init(source.path.text), + output_path: RawSlice::new(source.path.text), output_path_was_auto_generated: true, }); } diff --git a/src/bundler/OutputFile.rs b/src/bundler/OutputFile.rs index 5a14a5bacc5..784d7d7a280 100644 --- a/src/bundler/OutputFile.rs +++ b/src/bundler/OutputFile.rs @@ -5,7 +5,7 @@ use crate::options::Loader; // the `options` module already defines them locally. use crate::options::{OutputKind, Side}; use bun_core::Error; -use bun_core::{PathString, String as BunString}; +use bun_core::String as BunString; use bun_paths::PathBuffer; use bun_paths::fs; use bun_paths::resolve_path::{self, platform}; @@ -478,7 +478,7 @@ impl OutputFile { encoding: bun_sys::WriteFileEncoding::Buffer, mode: if self.is_executable { 0o755 } else { 0o644 }, dirfd: root_dir, - file: bun_sys::PathOrFileDescriptor::Path(PathString::init(rel_path)), + file: bun_sys::PathOrFileDescriptor::Path(rel_path), }, )?; } diff --git a/src/bundler/linker_context/writeOutputFilesToDisk.rs b/src/bundler/linker_context/writeOutputFilesToDisk.rs index dbc202557f2..7e14e735c53 100644 --- a/src/bundler/linker_context/writeOutputFilesToDisk.rs +++ b/src/bundler/linker_context/writeOutputFilesToDisk.rs @@ -5,7 +5,7 @@ use bun_alloc::MaxHeapAllocator; use bun_ast::Loc; use bun_core::fmt::quote; use bun_core::{Error, err}; -use bun_core::{PathString, String as BunString, immutable as strings}; +use bun_core::{String as BunString, immutable as strings}; use bun_paths::{self as paths, PathBuffer}; use bun_wyhash::hash; @@ -371,9 +371,9 @@ pub fn write_output_files_to_disk( 0o644 }, dirfd: bun_sys::Fd::from_std_dir(&root_dir), - file: PathOrFileDescriptor::Path(PathString::init( + file: PathOrFileDescriptor::Path( &fdpath[..frp.len() + BYTECODE_EXTENSION.len()], - )), + ), }, ) { Ok(_) => {} @@ -444,7 +444,7 @@ pub fn write_output_files_to_disk( 0o644 }, dirfd: bun_sys::Fd::from_std_dir(&root_dir), - file: PathOrFileDescriptor::Path(PathString::init(&chunk.final_rel_path)), + file: PathOrFileDescriptor::Path(&chunk.final_rel_path), }, ) { Err(e) => { diff --git a/src/io/lib.rs b/src/io/lib.rs index eff34e85673..56e73acef38 100644 --- a/src/io/lib.rs +++ b/src/io/lib.rs @@ -2008,8 +2008,8 @@ impl FilePollRef { /// Moved from `bun_runtime::webcore::PathOrFileDescriptor`. /// Owned here so `open_for_writing` has no upward dep; runtime re-exports it. -pub enum PathOrFileDescriptor { - Path(bun_core::PathString), +pub enum PathOrFileDescriptor<'a> { + Path(&'a [u8]), Fd(Fd), } diff --git a/src/io/openForWriting.rs b/src/io/openForWriting.rs index 6534f8557d1..d905e590c18 100644 --- a/src/io/openForWriting.rs +++ b/src/io/openForWriting.rs @@ -19,7 +19,7 @@ pub trait OpenForWritingInput { ) -> bun_sys::Result; } -impl OpenForWritingInput for crate::PathOrFileDescriptor { +impl OpenForWritingInput for crate::PathOrFileDescriptor<'_> { fn open_for_writing_result( &self, dir: Fd, @@ -32,7 +32,7 @@ impl OpenForWritingInput for crate::PathOrFileDescriptor { match self { Path(path) => { *is_nonblocking = true; - bun_sys::openat_a(dir, path.slice(), input_flags, mode) + bun_sys::openat_a(dir, path, input_flags, mode) } Fd(fd_) => bun_sys::dup_with_flags(*fd_, 0), } diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 8b8e6304cba..47e8006358d 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -6,7 +6,7 @@ use core::sync::atomic::{AtomicBool, Ordering}; use bun_ast::ExportsKind; use bun_ast::Source; use bun_core::{FeatureFlags, env_var}; -use bun_core::{PathString, String as BunString, ZStr}; +use bun_core::{String as BunString, ZStr}; use bun_js_parser::ParserOptions; use bun_paths::resolve_path::{self as path_handler, platform}; use bun_paths::{self as paths, MAX_PATH_BYTES, PathBuffer, SEP}; @@ -263,7 +263,7 @@ impl Entry { pub fn save( destination_dir: Fd, - destination_path: PathString, + destination_path: &ZStr, input_byte_length: u64, input_hash: u64, features_hash: u64, @@ -277,7 +277,7 @@ impl Entry { // atomically write to a tmpfile and then move it to the final destination let mut tmpname_buf = PathBuffer::uninit(); let tmpfilename = FileSystem::tmpname( - paths::extension(destination_path.slice()), + paths::extension(destination_path.as_bytes()), &mut tmpname_buf[..], input_hash, )?; @@ -420,7 +420,7 @@ impl Entry { // Zig: `@ptrCast(std.fs.path.basename(...))` — basename of a NUL-terminated // path is itself NUL-terminated (it's a suffix), so we can hand it to // `Tmpfile::finish` as a `&ZStr` without copying. - let dest_slice = destination_path.slice(); + let dest_slice = destination_path.as_bytes(); let base = paths::basename(dest_slice); // SAFETY: `base` is a suffix of `destination_path`, which the caller // built via `get_cache_file_path` and is NUL-terminated at `dest_slice.len()`. @@ -800,7 +800,7 @@ impl RuntimeTranspilerCache { let cache_file_path = Self::get_cache_file_path(&mut cache_file_path_buf, input_hash)?; debug_assert!(!cache_file_path.is_empty()); Self::from_file_with_cache_file_path( - PathString::init(cache_file_path.as_bytes()), + cache_file_path, input_hash, feature_hash, input_stat_size, @@ -808,18 +808,18 @@ impl RuntimeTranspilerCache { } pub fn from_file_with_cache_file_path( - cache_file_path: PathString, + cache_file_path: &ZStr, input_hash: u64, feature_hash: u64, input_stat_size: u64, ) -> Result { let mut metadata_bytes_buf = [0u8; Metadata::SIZE * 2]; - let cache_fd = sys::open(cache_file_path.slice_assume_z(), sys::O::RDONLY, 0)?; + let cache_fd = sys::open(cache_file_path, sys::O::RDONLY, 0)?; let file = sys::File::from_fd(cache_fd); // Zig: `errdefer { _ = bun.sys.unlink(...) }` — on any error, delete the // cache file. let unlink_guard = scopeguard::guard(cache_file_path, |p| { - let _ = sys::unlink(p.slice_assume_z()); + let _ = sys::unlink(p); }); let metadata_bytes = file.pread_all(&mut metadata_bytes_buf, 0)?; #[cfg(windows)] @@ -921,7 +921,7 @@ impl RuntimeTranspilerCache { Entry::save( cache_dir_fd, - PathString::init(cache_file_path.as_bytes()), + cache_file_path, input_byte_length, input_hash, features_hash, diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index d33c9898be5..d358dabdc99 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -1247,7 +1247,7 @@ where if loader != bun_ast::Loader::File { // Zig leaves these `undefined` / overwritten; both arms // of `'brk` assign before any read. - let path_string: bun_core::PathString; + let path_string: bun_ptr::Interned; let file_hash: bun_watcher::HashType; let abs_path: &[u8] = 'brk: { if let Some(file_ent) = dir_ent.entries().get(changed_name) @@ -1257,20 +1257,20 @@ where ent.set_cache_fd(Fd::INVALID); ent.need_stat.set(true); path_string = ent.abs_path; - file_hash = Watcher::get_hash(path_string.slice()); + file_hash = Watcher::get_hash(path_string.as_bytes()); for (entry_id, hash) in hashes.iter().enumerate() { if *hash == file_hash { if file_descriptors[entry_id].is_valid() { if prev_entry_id != entry_id { record_changed_path( - path_string.slice(), + path_string.as_bytes(), ); current_task.append(hashes[entry_id]); if self.verbose { Self::debug(format_args!( "Removing file: {}", bstr::BStr::new( - path_string.slice() + path_string.as_bytes() ) )); } @@ -1289,7 +1289,7 @@ where } } - break 'brk path_string.slice(); + break 'brk path_string.as_bytes(); } else { let file_path_without_trailing_slash = strings::trim_right(file_path, &[SEP]); diff --git a/src/jsc/node_path.rs b/src/jsc/node_path.rs index 46035b8d1cc..f6346ade482 100644 --- a/src/jsc/node_path.rs +++ b/src/jsc/node_path.rs @@ -1,13 +1,14 @@ //! `node.PathLike` / `node.PathOrFileDescriptor` — single nominal definitions. //! //! LAYERING: ported from `src/runtime/node/types.zig:532-910`. Defined at the -//! `bun_jsc` tier because every variant payload (`PathString`, `Buffer` = +//! `bun_jsc` tier because every variant payload (`CowSlice`, `Buffer` = //! `MarkedArrayBuffer`, `SliceWithUnderlyingString`, `ZigStringSlice`, `Fd`) //! is already reachable from this crate. `bun_runtime::node::types` //! `pub use`s these and layers the JS-argument-parsing helpers (`from_js`, //! `from_js_with_allocator`) on top via inherent impls in that crate. -use bun_core::{PathString, SliceWithUnderlyingString, ZigStringSlice}; +use bun_core::{SliceWithUnderlyingString, ZigStringSlice}; +use bun_ptr::cow_slice::CowSlice; use bun_sys::Fd; use crate::array_buffer::MarkedArrayBuffer; @@ -87,7 +88,7 @@ impl Default for ThreadSafe { /// `node.PathLike` (types.zig:532) — `union(enum)`. pub enum PathLike { - String(PathString), + String(CowSlice), Buffer(MarkedArrayBuffer), SliceWithUnderlyingString(SliceWithUnderlyingString), ThreadsafeString(SliceWithUnderlyingString), @@ -97,7 +98,7 @@ pub enum PathLike { impl Default for PathLike { #[inline] fn default() -> Self { - PathLike::String(PathString::empty()) + PathLike::String(CowSlice::EMPTY) } } @@ -107,7 +108,13 @@ impl Clone for PathLike { /// the same bytes as the original. fn clone(&self) -> Self { match self { - Self::String(s) => Self::String(*s), + // An owned path must be duped so the clone is independently + // droppable; a borrowed path shares the backing (non-owning). + Self::String(s) => Self::String(if s.is_owned() { + bun_core::handle_oom(CowSlice::init_dupe(s.slice())) + } else { + s.borrow() + }), Self::Buffer(b) => Self::Buffer(MarkedArrayBuffer { buffer: b.buffer, // The clone borrows the JS-owned backing store; only the @@ -141,8 +148,8 @@ impl Clone for PathLike { impl Drop for PathLike { fn drop(&mut self) { match self { - // `PathString` is a borrowed (ptr,len) pair; `MarkedArrayBuffer` - // is JS-GC-owned. Neither needs an explicit release here. + // `CowSlice` frees its backing in its own `Drop` iff it owns it; + // a borrowed path is a no-op. `MarkedArrayBuffer` is JS-GC-owned. Self::String(_) | Self::Buffer(_) => {} Self::SliceWithUnderlyingString(s) | Self::ThreadsafeString(s) => { core::mem::take(s).deinit(); @@ -173,7 +180,7 @@ impl PathLike { pub fn estimated_size(&self) -> usize { match self { - Self::String(s) => s.estimated_size(), + Self::String(s) => s.length(), Self::Buffer(b) => b.slice().len(), Self::SliceWithUnderlyingString(_) | Self::ThreadsafeString(_) => 0, Self::EncodedSlice(s) => s.slice().len(), diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index 8e7ab209e56..32742667795 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -23,7 +23,7 @@ use core::ptr::NonNull; // (atomic refcounting now via `bun_ptr::ThreadSafeRefCount`) use std::rc::Rc; -use bun_core::{PathString, immutable::AsciiStatus}; +use bun_core::immutable::AsciiStatus; use bun_http_types::MimeType::MimeType; use crate::JsCell; @@ -424,7 +424,7 @@ impl Blob { pub fn get_file_name(&self) -> Option<&[u8]> { match &self.store.get().as_deref()?.data { store::Data::Bytes(bytes) => { - let n = bytes.stored_name.slice(); + let n = &bytes.stored_name[..]; if n.is_empty() { None } else { Some(n) } } store::Data::File(file) => match &file.pathlike { @@ -580,7 +580,8 @@ pub mod store { pub cap: SizeType, pub allocator: bun_alloc::StdAllocator, /// Used by standalone module graph and the `File` constructor. - pub stored_name: PathString, + /// Heap-owned (or empty); freed by `Bytes`'s `Drop`. + pub stored_name: Box<[u8]>, } // SAFETY: `Bytes` is morally `Vec`-with-custom-free. The raw @@ -598,7 +599,7 @@ pub mod store { len: 0, cap: 0, allocator: bun_alloc::basic::C_ALLOCATOR, - stored_name: PathString::default(), + stored_name: Box::default(), } } } @@ -617,7 +618,7 @@ pub mod store { len: len as SizeType, cap: cap as SizeType, allocator: bun_alloc::basic::C_ALLOCATOR, - stored_name: PathString::default(), + stored_name: Box::default(), } } @@ -632,7 +633,7 @@ pub mod store { len: len as SizeType, cap: len as SizeType, allocator: bun_alloc::basic::C_ALLOCATOR, - stored_name: PathString::default(), + stored_name: Box::default(), } } @@ -642,10 +643,9 @@ pub mod store { /// `init_owned` paths) — asserts on a custom allocator (mmap/memfd). pub fn into_boxed_slice(self) -> Box<[u8]> { let mut this = core::mem::ManuallyDrop::new(self); - // SAFETY: `stored_name` ownership is consumed exactly once here; - // `ManuallyDrop` suppresses the `Drop` impl that would otherwise - // free it again. - unsafe { this.stored_name.deinit_owned() }; + // `ManuallyDrop` suppresses the `Drop` impl, so free `stored_name` + // here explicitly (the buffer itself is reclaimed below). + drop(core::mem::take(&mut this.stored_name)); let Some(ptr) = this.ptr else { return Box::new([]); }; @@ -677,12 +677,12 @@ pub mod store { len, cap, allocator, - stored_name: PathString::default(), + stored_name: Box::default(), } } #[inline] - pub fn init_empty_with_name(name: PathString) -> Bytes { + pub fn init_empty_with_name(name: Box<[u8]>) -> Bytes { Bytes { stored_name: name, ..Default::default() @@ -735,10 +735,9 @@ pub mod store { impl Drop for Bytes { fn drop(&mut self) { // Zig `deinit`: `default_allocator.free(stored_name.slice())` then - // `this.allocator.free(ptr[0..cap])`. - // SAFETY: every writer of `stored_name` adopts a heap allocation via - // `PathString::init_owned`, or leaves it `EMPTY`. - unsafe { self.stored_name.deinit_owned() }; + // `this.allocator.free(ptr[0..cap])`. `stored_name` is a `Box<[u8]>`, + // so its field `Drop` frees it; only the custom-allocator buffer + // needs an explicit free here. // Route through the existing accessor instead of re-deriving the // slice from raw parts here: `allocated_slice` already encapsulates // the `(ptr, cap)` → `&[u8]` invariant (and the `None` ⇒ `&[]` @@ -916,7 +915,7 @@ pub mod store { pub fn get_path(&self) -> Option<&[u8]> { match &self.data { Data::Bytes(bytes) => { - let n = bytes.stored_name.slice(); + let n = &bytes.stored_name[..]; if n.is_empty() { None } else { Some(n) } } Data::File(file) => { @@ -1010,40 +1009,12 @@ pub mod store { impl Drop for Store { /// `Store.deinit()` (Store.zig:179) sans the trailing `bun.destroy` — - /// `Box` handles the allocation. - fn drop(&mut self) { - match &mut self.data { - // `Bytes::drop` frees buffer + stored_name. - Data::Bytes(_) => {} - Data::File(file) => { - // Zig: - // if (path == .string) allocator.free(@constCast(path.slice())); - // else file.pathlike.path.deinit(); - // - // The `PathLike::String` payload is a *borrowed* - // `(ptr,len)` pair whose backing buffer was duped for this - // `Store` — `PathLike::drop` does NOT free it (it has no - // way to know the buffer is owned), so free it explicitly - // here. All other variants own their storage and release it - // in `PathLike::drop`. - if let PathOrFileDescriptor::Path(PathLike::String(s)) = &mut file.pathlike { - // SAFETY: duped via mimalloc by the constructing call - // site (e.g. `dupe_path`); `deinit_owned` no-ops on - // empty. - unsafe { s.deinit_owned() }; - } - // `file.pathlike` (and its `PathLike` payload) drops at the - // end of `Data`'s drop — that covers the - // `else file.pathlike.path.deinit()` arm for the - // ref-counted/owned variants. - } - Data::S3(_) => { - // `s3.deinit(allocator)` released `credentials` and freed - // `pathlike` — both handled by `Option>::drop` and - // `PathLike::drop` when `Data` drops. - } - } - } + /// `Box` handles the allocation. Every `Data` variant self-frees on + /// field drop: `Bytes::drop` frees its buffer + `stored_name`; the + /// `File.pathlike` / `S3` payloads (including an owned + /// `PathLike::String`, which owns its buffer via `CowSlice`) release in + /// `PathLike::drop`. + fn drop(&mut self) {} } // ──────────────────────────────────────────────────────────────────── diff --git a/src/patch/lib.rs b/src/patch/lib.rs index 6122f3a4607..8b76e8b6ff5 100644 --- a/src/patch/lib.rs +++ b/src/patch/lib.rs @@ -7,7 +7,7 @@ use core::mem; use bun_collections::bit_set::ArrayBitSet; -use bun_core::{PathString, strings}; +use bun_core::strings; use bun_core::{ZBox, ZStr}; use bun_paths::{self as paths, PathBuffer}; use bun_sys::{self as sys, Fd, FdExt}; @@ -135,8 +135,7 @@ impl<'a> PatchFile<'a> { return Some(sys::Error::from_code(sys::E::EINVAL, sys::Tag::open)); } let filepath_z = ZBox::from_vec_with_nul(file_creation.path.to_vec()); - let filepath = PathString::init(filepath_z.as_bytes()); - let filedir = paths::dirname_simple(filepath.slice()); + let filedir = paths::dirname_simple(filepath_z.as_bytes()); let mode = file_creation.mode; if !filedir.is_empty() { diff --git a/src/resolver/fs.rs b/src/resolver/fs.rs index 0af0fcc5d42..2394db528e7 100644 --- a/src/resolver/fs.rs +++ b/src/resolver/fs.rs @@ -7,11 +7,12 @@ use bstr::BStr; use bun_alloc::{AllocError, allocators}; use bun_collections::VecExt as _; +use bun_core::MutableString; use bun_core::{FeatureFlags, Generation, ZStr, env_var}; -use bun_core::{MutableString, PathString}; use bun_paths::resolve_path::platform; use bun_paths::strings; use bun_paths::{MAX_PATH_BYTES, PathBuffer, SEP, resolve_path as path_handler}; +use bun_ptr::Interned; use bun_sys::{self, Fd}; use bun_threading::Mutex; @@ -370,7 +371,7 @@ pub enum EntryKind { /// Port of `FileSystem.Entry.Cache` in `fs.zig`. #[derive(Clone, Copy)] pub struct EntryCache { - pub symlink: PathString, + pub symlink: Interned, /// Too much code expects this to be 0 /// don't make it bun.invalid_fd pub fd: Fd, @@ -380,7 +381,7 @@ pub struct EntryCache { impl Default for EntryCache { fn default() -> Self { Self { - symlink: PathString::EMPTY, + symlink: Interned::EMPTY, fd: Fd::INVALID, kind: EntryKind::File, } @@ -408,7 +409,7 @@ pub struct Entry { pub mutex: Mutex, pub need_stat: core::cell::Cell, - pub abs_path: PathString, + pub abs_path: Interned, } impl Entry { @@ -443,7 +444,7 @@ impl Entry { } #[inline(always)] - pub fn set_cache_symlink(&self, symlink: PathString) { + pub fn set_cache_symlink(&self, symlink: Interned) { let mut c = self.cache.get(); c.symlink = symlink; self.cache.set(c); @@ -465,15 +466,15 @@ impl Entry { self.dir } - /// Zig: `entry.abs_path` field. `PathString` is `Copy`. + /// Zig: `entry.abs_path` field. `Interned` is `Copy`. #[inline] - pub fn abs_path(&self) -> PathString { + pub fn abs_path(&self) -> Interned { self.abs_path } /// Zig: `entry.abs_path = PathString.init(...)`. #[inline] - pub fn set_abs_path(&mut self, p: PathString) { + pub fn set_abs_path(&mut self, p: Interned) { self.abs_path = p; } @@ -530,7 +531,7 @@ impl Entry { Err(_) => return b"", } } - crate::path_string_static(&self.cache().symlink) + self.cache().symlink.as_bytes() } } @@ -561,7 +562,7 @@ impl Default for Entry { base_lowercase_: strings::StringOrTinyString::init(b""), mutex: Mutex::default(), need_stat: core::cell::Cell::new(true), - abs_path: PathString::EMPTY, + abs_path: Interned::EMPTY, } } } @@ -803,7 +804,7 @@ impl DirEntry { // if found_kind is null, we have set need_stat above, so we // store an arbitrary kind existing.set_cache_kind(found_kind.unwrap_or(EntryKind::File)); - existing.set_cache_symlink(PathString::EMPTY); + existing.set_cache_symlink(Interned::EMPTY); } break 'brk existing_ptr; } @@ -852,13 +853,13 @@ impl DirEntry { // for each entry was a big performance issue for that package. addr_of_mut!((*p).need_stat).write(core::cell::Cell::new(found_kind.is_none())); addr_of_mut!((*p).cache).write(core::cell::Cell::new(EntryCache { - symlink: PathString::EMPTY, + symlink: Interned::EMPTY, // if found_kind is null, we have set need_stat above, so we // store an arbitrary kind kind: found_kind.unwrap_or(EntryKind::File), fd: Fd::INVALID, })); - addr_of_mut!((*p).abs_path).write(PathString::EMPTY); + addr_of_mut!((*p).abs_path).write(Interned::EMPTY); p } }; @@ -2311,7 +2312,7 @@ impl RealFS { let _ = (existing_fd, store_fd); let mut cache = EntryCache { kind: EntryKind::File, - symlink: PathString::EMPTY, + symlink: Interned::EMPTY, fd: Fd::INVALID, }; @@ -2412,7 +2413,7 @@ impl RealFS { // round-trip via `usize` (HANDLE is pointer-sized). match bun_sys::get_fd_path(Fd::from_native(handle as usize as u64), &mut *buf2) { bun_sys::Result::Ok(real) => { - cache.symlink = PathString::init(FilenameStore::instance().append(real)?); + cache.symlink = Interned::from_static(FilenameStore::instance().append(real)?); } bun_sys::Result::Err(_) => {} } @@ -2471,7 +2472,7 @@ impl RealFS { cache.kind = EntryKind::File; } if !symlink.is_empty() { - cache.symlink = PathString::init(FilenameStore::instance().append(symlink)?); + cache.symlink = Interned::from_static(FilenameStore::instance().append(symlink)?); } Ok(cache) diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 7b1a37ebe40..4f80367327f 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -44,24 +44,6 @@ pub use package_json::PackageJSON; /// Re-export real `TSConfigJSON`. pub use tsconfig_json::TSConfigJSON; -/// Expose the process-lifetime backing of a `PathString` as `&'static [u8]`. -/// -/// Every `PathString::init` in this crate is fed a slice returned from -/// `FilenameStore::append_*` / `DirnameStore::append_*`, both of which are -/// `'static` BSS singletons that never free (LIFETIMES.tsv: -/// `resolver/fs.zig:Entry.abs_path → STATIC`). Centralizing the lifetime -/// extension here removes the per-call-site erasure. -/// -/// TODO(port): once `bun_core::PathString::slice` is changed to return -/// `&'static [u8]` directly, this helper becomes a no-op forwarder. -#[inline(always)] -pub(crate) fn path_string_static(ps: &bun_core::PathString) -> &'static [u8] { - // SAFETY: see fn doc — `PathString` always points into a process-lifetime - // BSS append-only store (`FilenameStore`/`DirnameStore`); the bytes outlive - // the program. `Interned` is the canonical proof type for this widen. - unsafe { bun_ptr::Interned::assume(ps.slice()) }.as_bytes() -} - // Re-export the resolver implementation. `Resolver`, `Result`, `MatchResult`, // `PathPair`, `DebugLogs`, `SideEffects`, etc. are defined in the `resolver` / // `result` / `standalone_module_graph` sibling modules. @@ -675,8 +657,8 @@ pub mod fs { }; use bun_core::Generation; - use bun_core::PathString; use bun_paths::strings; + use bun_ptr::Interned; use bun_sys::Fd; use bun_threading::Mutex; @@ -1336,7 +1318,7 @@ pub mod fs { let mut cache = EntryCache { kind: EntryKind::File, - symlink: PathString::EMPTY, + symlink: Interned::EMPTY, fd: Fd::INVALID, }; @@ -1431,7 +1413,8 @@ pub mod fs { let mut buf2 = bun_paths::path_buffer_pool::get(); if let Ok(real) = bun_sys::get_fd_path(Fd::from_system(handle), &mut buf2) { - cache.symlink = PathString::init(FilenameStore::instance().append_slice(real)?); + cache.symlink = + Interned::from_static(FilenameStore::instance().append_slice(real)?); } return Ok(cache); } @@ -1493,7 +1476,7 @@ pub mod fs { }; if !symlink.is_empty() { cache.symlink = - PathString::init(FilenameStore::instance().append_slice(symlink)?); + Interned::from_static(FilenameStore::instance().append_slice(symlink)?); } Ok(cache) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 65c04872fd9..fcf5a11eb6b 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -270,10 +270,10 @@ use ::bun_core::Output; use ::bun_core::{FeatureFlags, Generation}; use bun_ast::Msg; use bun_collections::BoundedArray; -use bun_core::PathString; use bun_dotenv::env_loader as DotEnv; use bun_paths::{MAX_PATH_BYTES, PathBuffer, SEP, SEP_STR}; use bun_perf::system_timer::Timer; +use bun_ptr::Interned; use bun_sys::Fd as FD; use bun_threading::Mutex; @@ -1796,7 +1796,9 @@ impl<'a> Resolver<'a> { bstr::BStr::new(path.text()) )); } - query.entry().set_cache_symlink(PathString::init(symlink)); + query + .entry() + .set_cache_symlink(Interned::from_static(symlink)); if !result.file_fd.is_valid() && store_fd { result.file_fd = query.entry().cache().fd; } @@ -3862,14 +3864,14 @@ impl<'a> Resolver<'a> { if entry_query.entry().abs_path.is_empty() { // SAFETY: EntryStore-owned slot; resolver mutex held. RHS fully // evaluated before LHS `&mut Entry` is materialized. - unsafe { &mut *entry_query.entry }.abs_path = PathString::init( + unsafe { &mut *entry_query.entry }.abs_path = Interned::from_static( self.fs_ref() .dirname_store .append_slice(abs_esm_path) .expect("unreachable"), ); } - entry_query.entry().abs_path.slice() + entry_query.entry().abs_path.as_bytes() }; let module_type = if let Some(pkg) = resolved_dir_info.package_json() { pkg.module_type @@ -5250,14 +5252,14 @@ impl<'a> Resolver<'a> { let out_buf_ = self.fs_ref().abs_buf(&parts, bufs!(index)); // SAFETY: EntryStore-owned slot; resolver mutex held. RHS fully // evaluated before LHS `&mut Entry` is materialized. - unsafe { &mut *lookup.entry }.abs_path = PathString::init( + unsafe { &mut *lookup.entry }.abs_path = Interned::from_static( self.fs_ref() .dirname_store .append_slice(out_buf_) .expect("unreachable"), ); } - lookup.entry().abs_path.slice() + lookup.entry().abs_path.as_bytes() }; if let Some(debug) = self.debug_logs.as_mut() { @@ -5752,14 +5754,14 @@ impl<'a> Resolver<'a> { let joined = self.fs_ref().abs_buf(&abs_path_parts, bufs!(load_as_file)); // SAFETY: EntryStore-owned slot; resolver mutex held. RHS fully // evaluated before LHS `&mut Entry` is materialized. - unsafe { &mut *query.entry }.abs_path = PathString::init( + unsafe { &mut *query.entry }.abs_path = Interned::from_static( self.fs_ref() .dirname_store .append_slice(joined) .expect("unreachable"), ); } - crate::path_string_static(&query.entry().abs_path) + query.entry().abs_path.as_bytes() }; dec_ret!(Some(LoadResult { @@ -5863,7 +5865,7 @@ impl<'a> Resolver<'a> { && entry_dir[entry_dir.len() - 1] == SEP { let parts: [&[u8]; 2] = [entry_dir, &buffer[..]]; - PathString::init( + Interned::from_static( self.fs_ref() .filename_store .append_parts(&parts) @@ -5873,7 +5875,7 @@ impl<'a> Resolver<'a> { } else { let parts: [&[u8]; 3] = [entry_dir, SEP_STR.as_bytes(), &buffer[..]]; - PathString::init( + Interned::from_static( self.fs_ref() .filename_store .append_parts(&parts) @@ -5884,7 +5886,7 @@ impl<'a> Resolver<'a> { // fully evaluated above — sole `&mut Entry` for this write. unsafe { &mut *query.entry }.abs_path = new_abs; } - crate::path_string_static(&query.entry().abs_path) + query.entry().abs_path.as_bytes() }, diff_case: query.diff_case, dirname_fd: entries!().fd, @@ -5966,7 +5968,7 @@ impl<'a> Resolver<'a> { // materialized for the write — no overlapping unique borrow. unsafe { &mut *query.entry }.abs_path = if query.entry().abs_path.is_empty() { - PathString::init( + Interned::from_static( self.fs_ref() .dirname_store .append_slice(&buffer[..]) @@ -5975,7 +5977,7 @@ impl<'a> Resolver<'a> { } else { query.entry().abs_path }; - crate::path_string_static(&query.entry().abs_path) + query.entry().abs_path.as_bytes() }, diff_case: query.diff_case, dirname_fd: entries.fd, @@ -6250,7 +6252,9 @@ impl<'a> Resolver<'a> { .ok(); logs.add_note(buf); } - lookup.entry().set_cache_symlink(PathString::init(symlink)); + lookup + .entry() + .set_cache_symlink(Interned::from_static(symlink)); info.abs_real_path = symlink; } } diff --git a/src/router/Cargo.toml b/src/router/Cargo.toml index b7c5a2b36d2..9ad10865242 100644 --- a/src/router/Cargo.toml +++ b/src/router/Cargo.toml @@ -25,6 +25,7 @@ bun_ast.workspace = true bun_options_types.workspace = true bun_http_types.workspace = true bun_paths.workspace = true +bun_ptr.workspace = true # `bun_resolver` is acyclic from this crate (resolver does not depend on # router); router consumes the real `bun_resolver::fs::{FileSystem,DirEntry}` # now that the `bun_sys::fs::DirEntry` opaque-seam workaround is gone. diff --git a/src/router/lib.rs b/src/router/lib.rs index 38c5b6c0a48..ced9ab435c8 100644 --- a/src/router/lib.rs +++ b/src/router/lib.rs @@ -43,28 +43,8 @@ mod api { type CoreError = bun_core::Error; -use bun_core::{HashedString, PathString}; - -/// Every `PathString` stored on a [`Route`] wraps bytes interned in -/// `FileSystem::dirname_store()` (process-lifetime arena — `append` returns -/// `&'static [u8]`). `PathString::slice()` conservatively ties the borrow to -/// `&self`; this re-widens it to the true `'static` lifetime so the slice can -/// outlive the (Copy) `PathString` carrier and be stored in the SoA columns of -/// [`RouteIndexList`] / `dedupe_dynamic`. -/// -/// # Safety -/// `ps` MUST have been constructed via `PathString::init(s)` where `s` was -/// returned by `DirnameStore::append`/`append_lower_case` (or is a `'static` -/// literal). All `Route` path fields satisfy this by construction in -/// [`Route::parse`]. -#[inline] -unsafe fn arena_slice(ps: PathString) -> &'static [u8] { - let s = ps.slice(); - // SAFETY: caller contract — backing storage is the process-lifetime - // DirnameStore singleton; the `&'_ self` lifetime on `slice()` is an - // artificially-short reborrow. - unsafe { core::slice::from_raw_parts(s.as_ptr(), s.len()) } -} +use bun_core::HashedString; +use bun_ptr::Interned; // ────────────────────────────────────────────────────────────────────────── // cross-tier decoupling @@ -521,11 +501,11 @@ impl Routes { return Some(Match { params: std::ptr::from_mut(params), name: index.name, - path: index.abs_path.slice(), + path: index.abs_path.as_bytes(), pathname: url_path.pathname, basename: index.basename, hash: index_route_hash(), - file_path: index.abs_path.slice(), + file_path: index.abs_path.as_bytes(), query_string: url_path.query_string, client_framework_enabled: self.client_framework_enabled, redirect_path: None, @@ -544,11 +524,11 @@ impl Routes { return Some(Match { params: std::ptr::from_mut(params), name: route.name, - path: route.abs_path.slice(), + path: route.abs_path.as_bytes(), pathname: url_path.pathname, basename: route.basename, hash: route.full_hash, - file_path: route.abs_path.slice(), + file_path: route.abs_path.as_bytes(), query_string: url_path.query_string, client_framework_enabled: self.client_framework_enabled, redirect_path: None, @@ -643,8 +623,8 @@ impl<'a> RouteLoader<'a> { // static route if route.param_count == 0 { // PORT NOTE: Zig getOrPut → std Entry API (StringHashMap = std HashMap). - if let Some(existing) = self.static_list.get(route.match_name.slice()) { - let source = bun_ast::Source::init_empty_file(route.abs_path.slice()); + if let Some(existing) = self.static_list.get(route.match_name.as_bytes()) { + let source = bun_ast::Source::init_empty_file(route.abs_path.as_bytes()); self.log.add_error_fmt( Some(&source), bun_ast::Loc::EMPTY, @@ -652,7 +632,7 @@ impl<'a> RouteLoader<'a> { "Route \"{}\" is already defined by {}", bstr::BStr::new(route.name), // SAFETY: *existing aliases a Box in self.all_routes - bstr::BStr::new(unsafe { &**existing }.abs_path.slice()), + bstr::BStr::new(unsafe { &**existing }.abs_path.as_bytes()), ), ); return; @@ -670,7 +650,7 @@ impl<'a> RouteLoader<'a> { // It will cause unexpected behavior. if new_route.has_uppercase { if let Some(existing) = self.static_list.get(&new_route.name[1..]) { - let source = bun_ast::Source::init_empty_file(new_route.abs_path.slice()); + let source = bun_ast::Source::init_empty_file(new_route.abs_path.as_bytes()); self.log.add_error_fmt( Some(&source), bun_ast::Loc::EMPTY, @@ -678,7 +658,7 @@ impl<'a> RouteLoader<'a> { "Route \"{}\" is already defined by {}", bstr::BStr::new(new_route.name), // SAFETY: *existing aliases a Box in self.all_routes - bstr::BStr::new(unsafe { &**existing }.abs_path.slice()), + bstr::BStr::new(unsafe { &**existing }.abs_path.as_bytes()), ), ); @@ -690,7 +670,7 @@ impl<'a> RouteLoader<'a> { } self.static_list - .put_assume_capacity(new_route.match_name.slice(), new_route_ptr); + .put_assume_capacity(new_route.match_name.as_bytes(), new_route_ptr); self.all_routes.push(new_route); return; @@ -699,7 +679,7 @@ impl<'a> RouteLoader<'a> { { match self.dedupe_dynamic.entry(route.full_hash) { Entry::Occupied(e) => { - let source = bun_ast::Source::init_empty_file(route.abs_path.slice()); + let source = bun_ast::Source::init_empty_file(route.abs_path.as_bytes()); self.log.add_error_fmt( Some(&source), bun_ast::Loc::EMPTY, @@ -712,8 +692,7 @@ impl<'a> RouteLoader<'a> { return; } Entry::Vacant(v) => { - // SAFETY: `Route::parse` interned `abs_path` via DirnameStore. - v.insert(unsafe { arena_slice(route.abs_path) }); + v.insert(route.abs_path.as_bytes()); } } } @@ -782,15 +761,11 @@ impl<'a> RouteLoader<'a> { } // PERF(port): was appendAssumeCapacity — profile if hot - // SAFETY: `Route::parse` interned every PathString field via - // `DirnameStore::append{,_lower_case}` (process-lifetime arena). - let (filepath, match_name, public_path) = unsafe { - ( - arena_slice(route.abs_path), - arena_slice(route.match_name), - arena_slice(route.public_path), - ) - }; + let (filepath, match_name, public_path) = ( + route.abs_path.as_bytes(), + route.match_name.as_bytes(), + route.public_path.as_bytes(), + ); route_list.push(RouteIndex { name: route.name, filepath, @@ -993,10 +968,10 @@ impl TinyPtr { // Zig heap-allocates a separate buffer (`allocator.dupe`) so it doesn't mutate // memory it doesn't own (router.zig:537-547). The Rust port interns the // normalized path into `DirnameStore` (process-lifetime arena) instead, so -// `abs_path` is uniformly a `PathString` over `'static` bytes on every +// `abs_path` is uniformly an `Interned` over `'static` bytes on every // platform — keeping `RouteIndexList.filepath: &'static [u8]` sound and // avoiding the borrow-then-move at `RouteLoader::load_all`. -pub type AbsPath = PathString; +pub type AbsPath = Interned; pub struct Route { /// Public display name for the route. @@ -1009,7 +984,7 @@ pub struct Route { /// - Omits leading slash /// - Lowercased /// This is [inconsistent with Next.js](https://github.com/vercel/next.js/issues/21498) - pub match_name: PathString, + pub match_name: Interned, pub basename: &'static [u8], pub full_hash: u32, @@ -1020,7 +995,7 @@ pub struct Route { /// URL-safe path for the route's transpiled script relative to project's top level directory /// - It might not share a prefix with the absolute path due to symlinks. /// - It has a leading slash - pub public_path: PathString, + pub public_path: Interned, pub kind: pattern::Tag, @@ -1051,12 +1026,9 @@ impl Route { // Reads go through `unsafe { &*entry }`; the single mutation // (`set_abs_path`) goes through `unsafe { &mut *entry }` after // `base_`/`extname` are no longer used. - // PORT NOTE: reshaped for borrowck — bind the `PathString` so the - // `.slice()` borrow lives across the closure below. // SAFETY: caller passes an EntryStore-owned pointer valid for the // process lifetime; no other live `&mut` to it during this call. - let entry_abs_path_ps = unsafe { &*entry }.abs_path(); - let entry_abs_path = entry_abs_path_ps.slice(); + let entry_abs_path = unsafe { &*entry }.abs_path().as_bytes(); let mut abs_path_str: &[u8] = if entry_abs_path.is_empty() { b"" } else { @@ -1259,14 +1231,14 @@ impl Route { // Zig: `entry.abs_path = PathString.init(abs_path_str)`. // SAFETY: sole mutation; `base_`/`extname` (which may borrow // `(*entry).base_.remainder_buf`) are not used after this. - unsafe { &mut *entry }.set_abs_path(bun_core::PathString::init(abs_path_str)); + unsafe { &mut *entry }.set_abs_path(Interned::from_static(abs_path_str)); } #[cfg(windows)] let abs_path: AbsPath = { // Zig: `allocator.dupe(u8, platformToPosixBuf(...))` — process- // lifetime heap dup. Intern into DirnameStore so the slice is - // genuinely `'static` and `arena_slice()` is sound on Windows. + // genuinely `'static` and the `Interned` widen is sound on Windows. let normalized = bun_paths::resolve_path::platform_to_posix_buf( abs_path_str, &mut bufs.normalized_abs_path_buf, @@ -1275,17 +1247,17 @@ impl Route { .dirname_store() .append(normalized) .expect("unreachable"); - PathString::init(interned) + Interned::from_static(interned) }; #[cfg(not(windows))] - let abs_path = PathString::init(abs_path_str); + let abs_path = Interned::from_static(abs_path_str); #[cfg(all(debug_assertions, windows))] { debug_assert!(!strings::index_of_char(name, b'\\').is_some()); debug_assert!(!strings::index_of_char(public_path, b'\\').is_some()); debug_assert!(!strings::index_of_char(match_name, b'\\').is_some()); - debug_assert!(!strings::index_of_char(abs_path.slice(), b'\\').is_some()); + debug_assert!(!strings::index_of_char(abs_path.as_bytes(), b'\\').is_some()); // SAFETY: read-only reborrow; the `&mut` write above is dead. debug_assert!(!strings::index_of_char(unsafe { &*entry }.base(), b'\\').is_some()); } @@ -1303,8 +1275,8 @@ impl Route { Some(Route { name, basename, - public_path: PathString::init(public_path), - match_name: PathString::init(match_name), + public_path: Interned::from_static(public_path), + match_name: Interned::from_static(match_name), full_hash: if is_index { index_route_hash() } else { @@ -1352,8 +1324,8 @@ pub mod sorter { } pub fn sort_by_name(a: &Route, b: &Route) -> bool { - let a_name = a.match_name.slice(); - let b_name = b.match_name.slice(); + let a_name = a.match_name.as_bytes(); + let b_name = b.match_name.as_bytes(); // route order determines route match order // - static routes go first because we match those first diff --git a/src/runtime/api/filesystem_router.rs b/src/runtime/api/filesystem_router.rs index 24a875bf5a9..eed6a530d14 100644 --- a/src/runtime/api/filesystem_router.rs +++ b/src/runtime/api/filesystem_router.rs @@ -789,7 +789,7 @@ impl MatchedRoute { // SAFETY: self-referential lifetime erasure — `RouterMatch<'_>` borrows two // backing stores — // (a) `name`/`file_path`/`basename`/`path` slice the resolver's DirnameStore - // (process-lifetime arena, see `bun_router::PathString::slice`), so are + // (process-lifetime arena — `bun_router` paths are `Interned`), so are // genuinely `'static`; // (b) `pathname`/`query_string` and the param `value`s slice `pathname_backing`, // which we move into the same heap-stable Box below. The Box is never moved diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 2b236412208..54318402d90 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -498,7 +498,7 @@ impl JSBundleCompletionTask { flag: FileSystemFlags::W, mode: node_fs::DEFAULT_PERMISSION, file: PathOrFileDescriptor::Path(PathLike::String( - bun_core::PathString::init(write_path), + bun_ptr::cow_slice::CowSlice::init_unchecked(write_path, false), )), flush: false, data: StringOrBuffer::EncodedSlice( diff --git a/src/runtime/api/output_file_jsc.rs b/src/runtime/api/output_file_jsc.rs index 606448326f0..76f0d7eebb5 100644 --- a/src/runtime/api/output_file_jsc.rs +++ b/src/runtime/api/output_file_jsc.rs @@ -11,7 +11,7 @@ use bun_jsc::{JSGlobalObject, JSValue, StrongOptional}; use bun_bundler::options_impl::LoaderExt as _; use bun_bundler::output_file::{OutputFile, Value as OutputFileValue}; use bun_core::Output; -use bun_core::{PathString, ZigStringSlice}; +use bun_core::ZigStringSlice; use bun_http_types::MimeType::MimeType; use crate::api::js_bundler::BuildArtifact; @@ -71,7 +71,9 @@ impl SavedFile { // `Store::drop` frees `PathLike::String` via `deinit_owned`, so the // backing buffer must be owned by the store, not borrowed from `path`. let store = BlobStore::init_file( - PathOrFileDescriptor::Path(PathLike::String(PathString::init_owned(path.to_vec()))), + PathOrFileDescriptor::Path(PathLike::String(bun_ptr::cow_slice::CowSlice::init_owned( + path.to_vec().into_boxed_slice(), + ))), mime_type, ) .expect("unreachable"); @@ -154,12 +156,14 @@ impl OutputFileJsc for OutputFile { OutputFileValue::Saved(_) => { let path_to_use: &[u8] = owned_pathname.unwrap_or(self.src_path.text); - // `Store::drop` frees a `PathLike::String` payload via - // `PathString::deinit_owned`, so the backing buffer must be - // owned by the store. `owned_pathname` is a borrow here (the - // caller drops its `Box<[u8]>` after this returns), so dupe it. + // An owned `PathLike::String` (a `CowSlice`) frees its buffer in + // `PathLike::drop`, so the backing buffer must be owned by the + // store. `owned_pathname` is a borrow here (the caller drops its + // `Box<[u8]>` after this returns), so dupe it. let store_path = match owned_pathname { - Some(p) => PathLike::String(PathString::init_owned(p.to_vec())), + Some(p) => PathLike::String(bun_ptr::cow_slice::CowSlice::init_owned( + p.to_vec().into_boxed_slice(), + )), None => dupe_path_like(self.src_path.text), }; let file_blob = match BlobStore::init_file( diff --git a/src/runtime/api/standalone_graph_jsc.rs b/src/runtime/api/standalone_graph_jsc.rs index e454226753d..b2859394ce9 100644 --- a/src/runtime/api/standalone_graph_jsc.rs +++ b/src/runtime/api/standalone_graph_jsc.rs @@ -4,7 +4,7 @@ use core::ptr::NonNull; -use bun_core::{self as bstring, PathString, strings}; +use bun_core::{self as bstring, strings}; use bun_http::MimeType; use bun_jsc::JSGlobalObject; @@ -80,7 +80,7 @@ impl FileJsc for File { // `Bytes::Drop` and `jsdom_file_construct_` both require // `stored_name` to be heap-owned (or empty); a borrowed // `'static` slice would be invalid-freed there. - bytes.stored_name = PathString::init_owned(self.name.to_vec()); + bytes.stored_name = self.name.to_vec().into_boxed_slice(); } // The pretty name goes here: diff --git a/src/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index 039a9432445..0bd71ca124a 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -941,9 +941,7 @@ impl BuildCommand { }, encoding: bun_sys::WriteFileEncoding::Buffer, dirfd: root_dir.fd, - file: bun_sys::PathOrFileDescriptor::Path( - bun_core::PathString::init(map_basename), - ), + file: bun_sys::PathOrFileDescriptor::Path(map_basename), ..Default::default() }, ) { diff --git a/src/runtime/cli/test/ChangedFilesFilter.rs b/src/runtime/cli/test/ChangedFilesFilter.rs index 4e23e8b6807..e11ff566feb 100644 --- a/src/runtime/cli/test/ChangedFilesFilter.rs +++ b/src/runtime/cli/test/ChangedFilesFilter.rs @@ -20,8 +20,8 @@ use bun_ast::Index; use bun_bundler::{BundleV2, Transpiler}; use bun_collections::{DynamicBitSet, StringHashMap, StringSet}; use bun_core::PathBuffer as CorePathBuffer; +use bun_core::strings; use bun_core::{self, Global, Output, env_var, fmt as bun_fmt}; -use bun_core::{PathString, strings}; #[cfg(not(windows))] use bun_core::{ZBox, ZStr, getenv_z}; #[cfg(not(windows))] @@ -32,6 +32,7 @@ use bun_jsc::virtual_machine::VirtualMachine; #[cfg(not(windows))] use bun_paths::SEP; use bun_paths::{self, PathBuffer, platform, resolve_path}; +use bun_ptr::Interned; #[cfg(not(windows))] use bun_resolver::fs::RealFS; use bun_sys as sys; @@ -46,7 +47,7 @@ use crate::api::bun_process::sync as spawn_sync; pub struct Result<'a> { /// The filtered list of test files. Slice of the original `test_files` /// allocation, owned by the caller. - pub test_files: &'a mut [PathString], + pub test_files: &'a mut [Interned], /// Number of files git reported as changed. pub changed_count: usize, /// Number of test files before filtering. @@ -66,7 +67,7 @@ pub struct Result<'a> { pub(crate) fn filter<'a>( ctx: &Command::Context, vm: &mut VirtualMachine, - test_files: &'a mut [PathString], + test_files: &'a mut [Interned], changed_since: &[u8], ) -> core::result::Result, bun_core::Error> { let top_level_dir: &[u8] = bun_resolver::fs::FileSystem::get().top_level_dir; @@ -120,8 +121,8 @@ pub(crate) fn filter<'a>( }); } - // Convert PathString list to []const []const u8 for the bundler. - let entry_points: Vec<&[u8]> = test_files.iter().map(|p| p.slice()).collect(); + // Convert the interned-path list to []const []const u8 for the bundler. + let entry_points: Vec<&[u8]> = test_files.iter().map(|p| p.as_bytes()).collect(); // Build a dedicated transpiler for scanning. We do not reuse the VM's // transpiler because BundleV2.init takes ownership of the allocator and @@ -255,7 +256,7 @@ pub(crate) fn filter<'a>( let mut slot_to_source: Vec> = vec![None; test_files.len()]; debug_assert_eq!(test_files.len(), slot_to_source.len()); for (tf, out) in test_files.iter().zip(slot_to_source.iter_mut()) { - *out = path_to_index.get(tf.slice()).copied(); + *out = path_to_index.get(tf.as_bytes()).copied(); } // BFS backward from every changed file that participates in the graph. @@ -294,7 +295,7 @@ pub(crate) fn filter<'a>( for i in 0..total { let tf = test_files[i]; let maybe_source = slot_to_source[i]; - let keep = changed_files.contains(tf.slice()) + let keep = changed_files.contains(tf.as_bytes()) || maybe_source.is_some_and(|src| affected.is_set(src as usize)); if keep { diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index 45791083163..48bdc4b57ca 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -3,13 +3,13 @@ use std::collections::VecDeque; use bun_alloc::AllocError; use bun_bundler::Transpiler; use bun_bundler::options::BundleOptions; -use bun_core::PathString; #[cfg(not(windows))] use bun_core::ZStr; use bun_core::err; use bun_core::{StringOrTinyString, strings}; use bun_output::{declare_scope, scoped_log}; use bun_paths::{self, PathBuffer}; +use bun_ptr::Interned; use bun_resolver::fs::{self as fs, DirEntryIterator, EntriesOption, FileSystem}; use bun_sys::{self, Fd}; @@ -26,7 +26,7 @@ pub struct Scanner<'a> { pub path_ignore_patterns: &'a [&'a [u8]], pub dirs_to_scan: Fifo, /// Paths to test files found while scanning. - pub test_files: Vec, + pub test_files: Vec, // TODO(port): LIFETIMES.tsv classifies as &'a FileSystem, but several call // sites (dirname_store.append, readDirectoryWithIterator) mutate. May need // interior mutability on FileSystem or &'a mut. @@ -105,7 +105,7 @@ impl<'a> Scanner<'a> { /// Take the list of test files out of this scanner. Caller owns the returned /// allocation. - pub fn take_found_test_files(&mut self) -> Result, AllocError> { + pub fn take_found_test_files(&mut self) -> Result, AllocError> { Ok(core::mem::take(&mut self.test_files).into_boxed_slice()) } @@ -134,7 +134,7 @@ impl<'a> Scanner<'a> { .filename_store .append_slice(path) .map_err(|_| ScanError::OutOfMemory)?; - let rel_path = PathString::init(stored); + let rel_path = Interned::from_static(stored); self.test_files.push(rel_path); } } else if e == err!("ENOENT") { @@ -432,7 +432,7 @@ impl<'a> Scanner<'a> { Ok(s) => s, Err(_) => bun_core::out_of_memory(), }; - entry.abs_path = PathString::init(stored); + entry.abs_path = Interned::from_static(stored); self.test_files.push(entry.abs_path); } } diff --git a/src/runtime/cli/test/parallel/Coordinator.rs b/src/runtime/cli/test/parallel/Coordinator.rs index 2cad00d4b3c..f11a801799f 100644 --- a/src/runtime/cli/test/parallel/Coordinator.rs +++ b/src/runtime/cli/test/parallel/Coordinator.rs @@ -10,9 +10,10 @@ use core::mem::MaybeUninit; use core::sync::atomic::{AtomicBool, Ordering}; use std::io::Write as _; +use bun_core::strings; use bun_core::{Global, Output}; -use bun_core::{PathString, strings}; use bun_jsc::virtual_machine::VirtualMachine; +use bun_ptr::Interned; use bun_sys::FdExt as _; use super::frame::{self, Frame}; @@ -30,7 +31,7 @@ pub struct Coordinator<'a> { /// (`bun_io::EventLoopHandle` wraps `*const EventLoopHandle`). pub event_loop_handle: bun_jsc::EventLoopHandle, pub reporter: &'a mut CommandLineReporter, - pub files: Vec, + pub files: Vec, pub cwd: &'a [u8], // [:null]?[*:0]const u8 — null-sentinel-terminated slice of C strings; // backing storage has a null at [len] for execve-style consumers. @@ -233,7 +234,7 @@ impl<'a> Coordinator<'a> { return w.shutdown(); } if let Some(idx) = w.range.pop_front() { - return w.dispatch(idx, self.files[idx as usize].slice()); + return w.dispatch(idx, self.files[idx as usize].as_bytes()); } // Steal the back half of the largest remaining range as a contiguous // block. The thief walks it forward via popFront, so both workers keep @@ -252,7 +253,7 @@ impl<'a> Coordinator<'a> { if let Some(stolen) = v.range.steal_back_half() { w.range = stolen; if let Some(idx) = w.range.pop_front() { - return w.dispatch(idx, self.files[idx as usize].slice()); + return w.dispatch(idx, self.files[idx as usize].as_bytes()); } } } @@ -292,7 +293,7 @@ impl<'a> Coordinator<'a> { pub(crate) fn rel_path(&self, file_idx: u32) -> &[u8] { bun_paths::resolve_path::relative( bun_paths::fs::FileSystem::instance().top_level_dir(), - self.files[file_idx as usize].slice(), + self.files[file_idx as usize].as_bytes(), ) } @@ -635,7 +636,7 @@ impl<'a> Coordinator<'a> { // since `self.workers` is mutably borrowed. bstr::BStr::new(bun_paths::resolve_path::relative( bun_paths::fs::FileSystem::instance().top_level_dir(), - self.files[idx as usize].slice(), + self.files[idx as usize].as_bytes(), )), bstr::BStr::new(reason), )); diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 0e637ac0373..e6f99e9f1ed 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -7,11 +7,11 @@ use core::ffi::c_char; use core::ptr::NonNull; use std::io::Write as _; -use bun_core::PathString; use bun_core::ZBox; use bun_core::{Global, Output}; use bun_jsc::virtual_machine::VirtualMachine; use bun_options_types::context::MacroOptions; +use bun_ptr::Interned; use bun_resolver::fs::{FileSystem, RealFS}; use bun_sys::{Fd, FdDirExt, FdExt}; @@ -75,7 +75,7 @@ impl Drop for WorkerTmpdir { pub fn run_as_coordinator( reporter: &mut CommandLineReporter, vm: *mut VirtualMachine, - files: &[PathString], + files: &[Interned], ctx: Command::Context, coverage_opts: &mut CodeCoverageOptions, ) -> Result { @@ -167,9 +167,9 @@ pub fn run_as_coordinator( // Each worker owns a contiguous chunk; co-located files share imports, so // this keeps each worker's isolation SourceProvider cache hot. --randomize // explicitly opts out of locality (the caller already shuffled). - let mut sorted: Vec = files.to_vec(); + let mut sorted: Vec = files.to_vec(); if !ctx.test_options.randomize { - sorted.sort_by(|a, b| bun_core::order(a.slice(), b.slice())); + sorted.sort_by(|a, b| bun_core::order(a.as_bytes(), b.as_bytes())); } let mut workers: Vec = Vec::with_capacity(k as usize); diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index e8b079798b5..e9a7c07baf7 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -14,12 +14,13 @@ use bun_jsc::{self as jsc}; // `ZigString` (repr(C)-identical to `bun_core::ZigString`, but with the // JSGlobalObject FFI methods); import that one so the call sites type-check. use bun_core::ZigStringSlice; -use bun_core::{PathString, strings}; +use bun_core::strings; use bun_jsc::zig_string::ZigString; use bun_options_types::code_coverage_options::CodeCoverageOptions; use bun_paths::resolve_path; use bun_paths::string_paths::without_leading_path_separator; use bun_paths::{self as bun_path, PathBuffer}; +use bun_ptr::Interned; use bun_resolver::fs::FileSystem; use bun_sys::{self, Fd, File}; @@ -2482,8 +2483,7 @@ impl TestCommand { let mut pass_with_no_tests_from_filter = false; let mut changed_module_graph_files: Vec> = Vec::new(); // PORT NOTE: defer free handled by Drop. - let mut test_files: &mut [PathString] = if let Some(changed_since) = - &ctx.test_options.changed + let mut test_files: &mut [Interned] = if let Some(changed_since) = &ctx.test_options.changed { 'brk: { // If the Scanner found nothing, fall through to the existing @@ -2534,7 +2534,7 @@ impl TestCommand { &mut all_test_files[..] }; // TODO(port): test_files type — Zig is `[]PathString` slice into all_test_files or - // result.test_files; ownership in Rust needs reshaping. Using &mut [PathString] here. + // result.test_files; ownership in Rust needs reshaping. Using &mut [Interned] here. // --shard=M/N: sort the test files for determinism, then keep only // every Nth file starting at M-1. This round-robin distribution @@ -2548,7 +2548,7 @@ impl TestCommand { // printing a confusing "running 0/0 test files". if let Some(shard) = &ctx.test_options.shard { if !test_files.is_empty() { - test_files.sort_by(|a, b| strings::order(a.slice(), b.slice())); + test_files.sort_by(|a, b| strings::order(a.as_bytes(), b.as_bytes())); let mut write: usize = 0; let total = test_files.len(); @@ -3041,12 +3041,12 @@ impl TestCommand { pub(crate) fn run_all_tests( reporter_: &mut CommandLineReporter, vm_: &mut VirtualMachine, - files_: &[PathString], + files_: &[Interned], ) { struct Context<'a> { reporter: &'a mut CommandLineReporter, vm: &'a mut VirtualMachine, - files: &'a [PathString], + files: &'a [Interned], } impl<'a> Context<'a> { pub(crate) fn begin(&mut self) { @@ -3062,7 +3062,7 @@ impl TestCommand { if let Err(err) = TestCommand::run( reporter, vm, - file_name.slice(), + file_name.as_bytes(), bun_test::FirstLast { first: isolate || i == 0, last: isolate, @@ -3085,7 +3085,7 @@ impl TestCommand { if let Err(err) = TestCommand::run( reporter, vm, - files[files.len() - 1].slice(), + files[files.len() - 1].as_bytes(), bun_test::FirstLast { first: isolate || files.len() == 1, last: true, diff --git a/src/runtime/node/dir_iterator.rs b/src/runtime/node/dir_iterator.rs index 600bad94b5a..3b489c63ede 100644 --- a/src/runtime/node/dir_iterator.rs +++ b/src/runtime/node/dir_iterator.rs @@ -2,14 +2,14 @@ // The differences are: // - it returns errors in the expected format // - doesn't mark BADF as unreachable -// - It uses PathString instead of []const u8 +// - It borrows the entry name (`RawSlice`) into the iterator buffer // - Windows can be configured to return []const u16 #![warn(unused_must_use)] use core::mem::offset_of; -use bun_core::{PathString, RawSlice, WStr}; +use bun_core::{RawSlice, WStr}; use bun_sys::{self as sys, Fd, Tag}; // `Entry.Kind` in Zig is `jsc.Node.Dirent.Kind` == `std.fs.Dir.Entry.Kind`. @@ -34,9 +34,26 @@ impl From for bun_core::Error { } pub struct IteratorResult { - pub name: PathString, + /// `RawSlice` invariant: borrows the iterator's `getdents` buffer + /// (streaming-iterator contract — invalidated on next `next()` call). + /// The kernel writes `d_name` NUL-terminated, so the backing has a NUL at + /// `[name.len()]` (see `name_assume_z`). + pub name: RawSlice, pub kind: EntryKind, } + +impl IteratorResult { + /// The entry name as a NUL-terminated `&ZStr` — the POSIX `d_name` is always + /// NUL-terminated in the `getdents` buffer (Zig parity: `PathString`'s + /// `slice_assume_z`). + #[inline] + pub fn name_assume_z(&self) -> &bun_core::ZStr { + let s = self.name.slice(); + // SAFETY: `d_name` is NUL-terminated by the kernel; `name` points at it + // with len excluding the NUL, so `[len] == 0`. + unsafe { bun_core::ZStr::from_raw(s.as_ptr(), s.len()) } + } +} pub type Result = sys::Result>; /// Fake PathString to have less `if (Environment.isWindows) ...` @@ -222,7 +239,7 @@ mod platform { _ => EntryKind::Unknown, }; return Ok(Some(IteratorResult { - name: PathString::init(name), + name: RawSlice::new(name), kind: entry_kind, })); } @@ -322,7 +339,7 @@ mod platform { _ => EntryKind::Unknown, }; return Ok(Some(IteratorResult { - name: PathString::init(name), + name: RawSlice::new(name), kind: entry_kind, })); } @@ -429,7 +446,7 @@ mod platform { _ => EntryKind::Unknown, }; return Ok(Some(IteratorResult { - name: PathString::init(name), + name: RawSlice::new(name), kind: entry_kind, })); } @@ -494,7 +511,7 @@ mod platform { // Trust that Windows gives us valid UTF-16LE let name_utf8 = strings::paths::from_w_path(&mut name_data[..], dir_info_name); IteratorResult { - name: PathString::init(name_utf8.as_bytes()), + name: RawSlice::new(name_utf8.as_bytes()), kind, } } @@ -847,7 +864,7 @@ mod platform { _ => EntryKind::Unknown, }; return Ok(Some(IteratorResult { - name: PathString::init(name), + name: RawSlice::new(name), kind: entry_kind, })); } diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index f4e1625f790..9fd46c18c9f 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -10,7 +10,7 @@ use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use crate::api::bun::process::event_loop_handle_to_ctx; use crate::webcore; use bun_core::Environment; -use bun_core::{PathString, String as BunString, ZStr, ZigString}; +use bun_core::{String as BunString, ZStr, ZigString}; use bun_event_loop::AnyTaskWithExtraContext::AnyTaskWithExtraContext; use bun_event_loop::MiniEventLoop::MiniEventLoop; use bun_io::KeepAlive; @@ -435,8 +435,7 @@ fn err_from_static(name: &'static str) -> bun_core::Error { const PREALLOCATE_SUPPORTED: bool = cfg!(any(target_os = "linux", target_os = "android")); const PREALLOCATE_LENGTH: usize = 2048 * 1024; -/// `PathString.PathInt` — Zig packed-struct field width. `bun_core::PathString` -/// stores it as `u32` on the Rust side (see `PathString.rs` POINTER_BITS). +/// Zig `PathString.PathInt` — path-length field width; `u32` on the Rust side. type PathInt = u32; /// `Syscall.mkdirOSPath` / `Syscall.openatOSPath` — on POSIX `OSPathSliceZ` is @@ -615,7 +614,9 @@ mod _async_tasks { // SAFETY: caller keeps `path` alive until completion let path = unsafe { &*this.path }; let result = node_fs.mkdir_recursive(&args::Mkdir { - path: PathLike::String(PathString::init(path)), + path: PathLike::String(bun_ptr::cow_slice::CowSlice::init_unchecked( + path, false, + )), recursive: true, ..Default::default() }); @@ -2231,8 +2232,9 @@ mod _async_tasks { /// All the subtasks will use this fd to open files pub root_fd: FD, - /// This isued when joining the file paths for error messages - pub root_path: PathString, + /// This isued when joining the file paths for error messages. + /// Heap-owned, NUL-terminated (`[path.., 0]`); freed on drop. + pub root_path: Box<[u8]>, pub pending_err: Option, pub pending_err_mutex: bun_threading::Mutex, @@ -2294,7 +2296,8 @@ mod _async_tasks { pub(super) struct ReaddirSubtask { pub readdir_task: bun_ptr::ParentRef, - pub basename: PathString, + /// Heap-owned, NUL-terminated (`[basename.., 0]`); freed on drop. + pub basename: Box<[u8]>, pub task: WorkPoolTask, } @@ -2310,33 +2313,17 @@ mod _async_tasks { basename, task: _, } = *self; - // basename was allocated as `Box<[u8]>` of len+1 (NUL included) in - // enqueue(); reconstruct that exact layout for drop on scope exit. - let basename = scopeguard::guard(basename, |basename| { - let z = basename.slice_assume_z(); - let len_with_nul = z.len() + 1; - let ptr = z.as_bytes().as_ptr().cast_mut(); - // SAFETY: paired with the `Box::leak(owned.into_boxed_slice())` in - // `AsyncReaddirRecursiveTask::enqueue`; same (ptr, len) layout, - // reconstructed exactly once. Build the `*mut [u8]` fat pointer - // safely — no need to materialize an intermediate `&mut` reference. - unsafe { - drop(Box::<[u8]>::from_raw(core::ptr::slice_from_raw_parts_mut( - ptr, - len_with_nul, - ))); - } - }); + // `basename` is a NUL-terminated `Box<[u8]>` (`[bytes.., 0]`) from + // `enqueue()`; it frees on scope exit. + // SAFETY: `enqueue()` built `basename` with a trailing NUL at + // `[len]`, so `ZStr::from_buf` is valid. + let basename_z = ZStr::from_buf(&basename, basename.len() - 1); let mut buf = PathBuffer::uninit(); // SAFETY: readdir_task (ParentRef) outlives subtask via subtask_count // refcount. `from_raw_mut` was used at enqueue, so write provenance is // present; this work-pool callback is the sole holder of `&mut` to the // parent's per-result fields (it pushes to a lock-free queue). - unsafe { readdir_task.assume_mut() }.perform_work( - basename.slice_assume_z(), - &mut buf, - false, - ); + unsafe { readdir_task.assume_mut() }.perform_work(basename_z, &mut buf, false); } } @@ -2359,22 +2346,9 @@ mod _async_tasks { } /// `bun.default_allocator.free(this.root_path.slice())` — paired with the - /// `dupeZ` in `create()`. Idempotent (`PathString::EMPTY` after first call). + /// `dupeZ` in `create()`. Idempotent (empty `Box` after first call). fn free_root_path(&mut self) { - let rp = core::mem::replace(&mut self.root_path, PathString::EMPTY); - let bytes = rp.slice(); - if bytes.is_empty() { - return; - } - // SAFETY: `bytes.as_ptr()` is the start of a `Box<[u8]>` allocation of - // `bytes.len() + 1` (NUL) made in `create()`; reconstructed exactly once. - // Build the `*mut [u8]` fat pointer safely — no intermediate `&mut` ref. - unsafe { - drop(Box::<[u8]>::from_raw(core::ptr::slice_from_raw_parts_mut( - bytes.as_ptr().cast_mut(), - bytes.len() + 1, - ))); - } + drop(core::mem::take(&mut self.root_path)); } pub fn enqueue(&mut self, basename: &ZStr) { @@ -2385,12 +2359,9 @@ mod _async_tasks { let mut owned = Vec::with_capacity(basename.len() + 1); owned.extend_from_slice(basename.as_bytes()); owned.push(0); - let owned: Box<[u8]> = owned.into_boxed_slice(); - let len = owned.len() - 1; // exclude NUL - // Leak the boxed `[bytes.., 0]` allocation; the Box<[u8]> backing is - // reconstructed and freed in `ReaddirSubtask::run_owned`. - let leaked: &'static mut [u8] = Box::leak(owned); - let basename_ps = PathString::init(&leaked[..len]); + // NUL-terminated `[bytes.., 0]`; moved into the subtask and freed + // when `ReaddirSubtask` drops. + let basename_owned: Box<[u8]> = owned.into_boxed_slice(); // Spec (node_fs.zig:1061) `bun.assert(subtask_count.fetchAdd(1, .monotonic) > 0)` // — the fetch_add is load-bearing (refcounts the in-flight subtask). It // MUST run in release builds; only the `> 0` invariant check is debug-only. @@ -2403,7 +2374,7 @@ mod _async_tasks { readdir_task: unsafe { bun_ptr::ParentRef::from_raw_mut(core::ptr::from_mut(self)) }, - basename: basename_ps, + basename: basename_owned, task: WorkPoolTask::default(), }); } @@ -2419,7 +2390,7 @@ mod _async_tasks { ret::ReaddirTag::Buffers => ResultListEntryValue::Buffers(Vec::new()), }; // Zig: `bun.default_allocator.dupeZ(u8, args.path.slice())`. The - // subtasks call `root_path.slice_assume_z()` from the work pool after + // subtasks read `root_path` (NUL-terminated) from the work pool after // `args.to_thread_safe()` may have rehomed the original slice, so we // must own a NUL-terminated copy. Freed in `finish_concurrently()` or // `destroy()` via `free_root_path()`. @@ -2428,11 +2399,8 @@ mod _async_tasks { let mut owned = Vec::with_capacity(src.len() + 1); owned.extend_from_slice(src); owned.push(0); - let len = src.len(); - // Leak the boxed `[bytes.., 0]` allocation; reconstructed and freed - // in `free_root_path()`. - let leaked: &'static mut [u8] = Box::leak(owned.into_boxed_slice()); - PathString::init(&leaked[..len]) + // NUL-terminated `[bytes.., 0]`; freed on drop / `free_root_path()`. + owned.into_boxed_slice() }; let mut task = Self::new(AsyncReaddirRecursiveTask { promise: JSPromiseStrong::init(global_object), @@ -2527,8 +2495,17 @@ mod _async_tasks { // SAFETY: task points to Self.task let this = unsafe { &mut *Self::from_task_ptr(task) }; let mut buf = PathBuffer::uninit(); - let root_path = this.root_path; - this.perform_work(root_path.slice_assume_z(), &mut buf, true); + // `root_path` backing is fixed for the task's lifetime and only + // `perform_work`'s callee reads it (it mutates other fields), so + // detach the field borrow to satisfy borrowck (mirrors the + // `perform_work` body's own `args_ptr` erase, and line ~6623). + // SAFETY: `root_path` is a NUL-terminated `Box<[u8]>` set in + // `create()` and not reallocated for the task's lifetime. + let root_path_z = { + let bytes: &'static [u8] = unsafe { bun_ptr::detach_lifetime(&this.root_path[..]) }; + ZStr::from_buf(bytes, bytes.len() - 1) + }; + this.perform_work(root_path_z, &mut buf, true); } pub fn write_results(&mut self, result: &mut Vec) { @@ -4533,7 +4510,7 @@ pub mod args { ThreadSafe::adopt(self) } // Zig `deinit()` was gated on `flags.deinit_paths`; in Rust the - // `PathLike::String` arm's `Drop` is a no-op for borrowed `PathString` + // `PathLike::String` arm's `Drop` is a no-op for borrowed `CowSlice` // payloads (the only `deinit_paths: false` caller — shell `cp`), so the // flag is vestigial and the explicit hook is gone. pub fn from_js(ctx: &JSGlobalObject, arguments: &mut ArgumentsSlice) -> JsResult { @@ -6532,7 +6509,7 @@ impl NodeFS { // On filesystems that return DT_UNKNOWN (e.g. FUSE, bind mounts), // fall back to lstat to determine the real file kind. let kind = if T::IS_DIRENT && current.kind == sys::FileKind::Unknown { - match sys::lstatat(fd, current.name.slice_assume_z()) { + match sys::lstatat(fd, current.name_assume_z()) { Ok(st) => sys::kind_from_mode(st.st_mode as Mode), Err(_) => current.kind, } @@ -6619,8 +6596,12 @@ impl NodeFS { // the slice via raw-pointer round-trip — same bytes Zig's `[]const u8` saw. // SAFETY: `async_task.root_path`'s backing storage is fixed at `create()` and // outlives every `enqueue` call below. - let root_basename: &[u8] = - unsafe { bun_ptr::detach_lifetime(async_task.root_path.slice()) }; + let root_basename: &[u8] = { + // `root_path` is NUL-terminated (`[path.., 0]`); the basename + // excludes the trailing NUL. + let path = &async_task.root_path; + unsafe { bun_ptr::detach_lifetime(&path[..path.len() - 1]) } + }; #[cfg(not(windows))] let flags = sys::O::DIRECTORY | sys::O::RDONLY; let atfd = if is_root { @@ -6720,7 +6701,7 @@ impl NodeFS { .as_bytes() }; // SAFETY: both branches yield NUL-terminated storage — `utf8_name` is a - // `PathString` slice over the iterator's NUL-terminated dirent name, and + // slice over the iterator's NUL-terminated dirent name, and // `join_z_buf` writes a sentinel. let name_to_copy_z = unsafe { ZStr::from_raw(name_to_copy.as_ptr(), name_to_copy.len()) }; @@ -6749,7 +6730,7 @@ impl NodeFS { sys::FileKind::Unknown => { if utf8_name.len() + 1 + name_to_copy.len() > paths::MAX_PATH_BYTES { break 'enqueue; } // Lazy stat to determine the actual kind (lstatat to not follow symlinks) - match sys::lstatat(fd, current.name.slice_assume_z()) { + match sys::lstatat(fd, current.name_assume_z()) { Ok(st) => { let real_kind = sys::kind_from_mode(st.st_mode as Mode); effective_kind = real_kind; @@ -6934,7 +6915,7 @@ impl NodeFS { // DT_UNKNOWN for d_type. Use lstatat to determine the actual type. sys::FileKind::Unknown => { if utf8_name.len() + 1 + name_to_copy.len() > paths::MAX_PATH_BYTES { break 'enqueue; } - match sys::lstatat(fd, current.name.slice_assume_z()) { + match sys::lstatat(fd, current.name_assume_z()) { Ok(st) => { let real_kind = sys::kind_from_mode(st.st_mode as Mode); effective_kind = real_kind; @@ -9203,7 +9184,10 @@ impl NodeFS { len -= 1; } let mkdir_result = self.mkdir_recursive(&args::Mkdir { - path: PathLike::String(PathString::init(&bytes[..len])), + path: PathLike::String(bun_ptr::cow_slice::CowSlice::init_unchecked( + &bytes[..len], + false, + )), recursive: true, ..Default::default() }); @@ -9701,7 +9685,9 @@ pub unsafe extern "C" fn Bun__mkdirp(global_this: &JSGlobalObject, path: *const unsafe { &mut *global_this.bun_vm().as_mut().node_fs().cast::() }; node_fs .mkdir_recursive(&args::Mkdir { - path: PathLike::String(PathString::init(path_bytes)), + path: PathLike::String(bun_ptr::cow_slice::CowSlice::init_unchecked( + path_bytes, false, + )), recursive: true, ..Default::default() }) diff --git a/src/runtime/node/types.rs b/src/runtime/node/types.rs index feb75079726..96a723a2b81 100644 --- a/src/runtime/node/types.rs +++ b/src/runtime/node/types.rs @@ -913,7 +913,7 @@ pub fn js_assert_encoding_valid( // ────────────────────────────────────────────────────────────────────────── pub enum PathOrBuffer { - Path(bun_core::PathString), + Path(bun_core::RawSlice), Buffer(Buffer), } diff --git a/src/runtime/shell/builtin/cp.rs b/src/runtime/shell/builtin/cp.rs index 8969fdd884a..631b855248f 100644 --- a/src/runtime/shell/builtin/cp.rs +++ b/src/runtime/shell/builtin/cp.rs @@ -701,11 +701,13 @@ impl ShellCpTask { self.tgt_absolute = Some(tgt.as_bytes().to_vec()); let args = crate::node::fs::args::Cp { - src: bun_jsc::node::PathLike::String(bun_core::PathString::init( + src: bun_jsc::node::PathLike::String(bun_ptr::cow_slice::CowSlice::init_unchecked( self.src_absolute.as_deref().unwrap(), + false, )), - dest: bun_jsc::node::PathLike::String(bun_core::PathString::init( + dest: bun_jsc::node::PathLike::String(bun_ptr::cow_slice::CowSlice::init_unchecked( self.tgt_absolute.as_deref().unwrap(), + false, )), flags: crate::node::fs::args::CpFlags { mode: crate::node::fs::constants::Copyfile::from_raw(0), diff --git a/src/runtime/shell/builtin/mkdir.rs b/src/runtime/shell/builtin/mkdir.rs index d2f31222dad..66965d07b64 100644 --- a/src/runtime/shell/builtin/mkdir.rs +++ b/src/runtime/shell/builtin/mkdir.rs @@ -316,7 +316,10 @@ impl ShellMkdirTask { let mut node_fs = NodeFS::default(); let args = fs_args::Mkdir { - path: PathLike::String(bun_core::PathString::init(filepath.as_bytes())), + path: PathLike::String(bun_ptr::cow_slice::CowSlice::init_unchecked( + filepath.as_bytes(), + false, + )), recursive: this.opts.parents, mode: fs_args::Mkdir::DEFAULT_MODE, always_return_none: true, diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 70d9d1f677b..59c917f9d7d 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -760,7 +760,7 @@ impl BlobExt for Blob { writer.write_int_le::(view.len() as u32)?; writer.write_all(view)?; - let stored_name = bytes.stored_name.slice(); + let stored_name = &bytes.stored_name[..]; writer.write_int_le::(stored_name.len() as u32)?; writer.write_all(stored_name)?; } else { @@ -3722,7 +3722,7 @@ impl BlobExt for Blob { size += core::mem::size_of::(); match &store.data { store::Data::Bytes(bytes) => { - size += bytes.stored_name.estimated_size(); + size += bytes.stored_name.len(); size += if self.size.get() != MAX_SIZE { self.size.get() as usize } else { @@ -3785,7 +3785,7 @@ impl BlobExt for Blob { let copy = core::mem::replace( path_or_fd, PathOrFileDescriptor::Path(crate::webcore::node_types::PathLike::String( - bun_core::PathString::default(), + bun_ptr::cow_slice::CowSlice::EMPTY, )), ); let PathOrFileDescriptor::Path(path) = copy else { @@ -3810,7 +3810,9 @@ impl BlobExt for Blob { *path_or_fd = PathOrFileDescriptor::Path(crate::webcore::node_types::PathLike::String( // Heap-dupe: this buffer is freed by `Blob.Store.deinit`. - bun_core::PathString::init_owned(b"\\\\.\\NUL".to_vec()), + bun_ptr::cow_slice::CowSlice::init_owned( + b"\\\\.\\NUL".to_vec().into_boxed_slice(), + ), )); } @@ -3839,7 +3841,7 @@ impl BlobExt for Blob { if !path_or_fd.path().is_string() { *path_or_fd = PathOrFileDescriptor::Path( crate::webcore::node_types::PathLike::String( - bun_core::PathString::default(), + bun_ptr::cow_slice::CowSlice::EMPTY, ), ); } @@ -3851,7 +3853,7 @@ impl BlobExt for Blob { core::mem::replace( path_or_fd, PathOrFileDescriptor::Path(crate::webcore::node_types::PathLike::String( - bun_core::PathString::default(), + bun_ptr::cow_slice::CowSlice::EMPTY, )), ) } @@ -4238,11 +4240,9 @@ fn _on_structured_clone_deserialize>( // ScopeGuard derefs to its inner Blob. if let Some(store) = (*guard).store() { if let store::Data::Bytes(bytes_store) = &mut store.data_mut() { - // `PathString::init` only borrows ptr+len; the local - // `name: Vec` would drop at the end of this block - // and leave `stored_name` dangling. Transfer ownership - // into the packed pointer; freed by `Bytes::Drop`. - bytes_store.stored_name = bun_core::PathString::init_owned(name); + // Transfer ownership of the local `name: Vec` into + // `stored_name` (a `Box<[u8]>`); freed by `Bytes::Drop`. + bytes_store.stored_name = name.into_boxed_slice(); } } // else: `name` drops here (Zig: `if (!consumed) free(name)`). @@ -4276,12 +4276,12 @@ fn _on_structured_clone_deserialize>( let path_len = reader.read_int_le::()?; let path = read_slice(reader, path_len as usize)?; // Zig heap-allocates `path` and hands the allocation to - // the store via `PathString.init(path)` (freed by - // `Store.deinit`). `init_owned` consumes the Vec so the - // store adopts the same allocation; borrowing here would - // drop `path` at scope end and leave the store dangling. + // the store (freed by `Store.deinit`). The owned `CowSlice` + // adopts the `Box<[u8]>` so the store frees it in + // `PathLike::drop`; borrowing here would drop `path` at scope + // end and leave the store dangling. let mut dest = PathOrFileDescriptor::Path(node::PathLike::String( - bun_core::PathString::init_owned(path), + bun_ptr::cow_slice::CowSlice::init_owned(path.into_boxed_slice()), )); break 'file Blob::new(Blob::find_or_create_file_from_path( &mut dest, @@ -4398,8 +4398,8 @@ pub extern "C" fn Blob__setAsFile(this: &mut Blob, path_str: &mut BunString) { if let store::Data::Bytes(bytes) = &mut store.data_mut() { if bytes.stored_name.is_empty() { // Zig: `path_str.toUTF8Bytes(allocator)` → owned heap slice - // adopted by PathString and freed by `Bytes.deinit`. - bytes.stored_name = bun_core::PathString::init_owned(path_str.to_owned_slice()); + // owned by `stored_name` (`Box<[u8]>`) and freed by `Bytes::Drop`. + bytes.stored_name = path_str.to_owned_slice().into_boxed_slice(); } } } @@ -4465,7 +4465,9 @@ pub fn mkdir_if_not_exists( if let Some(dirname) = bun_core::dirname(path_string.as_bytes()) { let mut node_fs = node::fs::NodeFS::default(); match node_fs.mkdir_recursive(&node::fs::args::Mkdir { - path: node::PathLike::String(bun_core::PathString::init(dirname)), + path: node::PathLike::String(bun_ptr::cow_slice::CowSlice::init_unchecked( + dirname, false, + )), recursive: true, always_return_none: true, ..Default::default() @@ -4600,9 +4602,11 @@ fn write_file_with_empty_source_to_destination( }; let mkdir_result = node_fs.mkdir_recursive(&node::fs::args::Mkdir { - path: node::PathLike::String(bun_core::PathString::init( - dirpath, - )), + path: node::PathLike::String( + bun_ptr::cow_slice::CowSlice::init_unchecked( + dirpath, false, + ), + ), recursive: true, always_return_none: true, ..Default::default() @@ -5669,13 +5673,9 @@ pub fn jsdom_file_construct_( 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. - // `PathString` is `Copy` — assignment alone would leak it. - // SAFETY: every writer of `stored_name` adopts via - // `PathString::init_owned` or leaves it `EMPTY`. - unsafe { bytes.stored_name.deinit_owned() }; - bytes.stored_name = - bun_core::PathString::init_owned(name_value_str.to_owned_slice()); + // 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()); @@ -5685,7 +5685,7 @@ pub fn jsdom_file_construct_( // 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( - bun_core::PathString::init_owned(name_value_str.to_owned_slice()), + name_value_str.to_owned_slice().into_boxed_slice(), )), ref_count: bun_ptr::ThreadSafeRefCount::init(), mime_type: bun_http_types::MimeType::NONE, diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 2a7b011d145..8dd6c601f6d 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -692,14 +692,11 @@ impl FileSink { let mut nonblocking_out = self.nonblocking.get(); // `OpenForWritingInput` is impl'd for // `bun_io::PathOrFileDescriptor`, not `webcore::PathOrFileDescriptor`; - // bridge by-value here. `PathString::init` borrows `slice.slice()` for - // the duration of `open_for_writing` (the call only needs it for - // `openat_a`). + // bridge by-value here. The borrowed slice is valid for the duration of + // `open_for_writing` (the call only needs it for `openat_a`). let io_path = match &options.input_path { PathOrFileDescriptor::Fd(fd) => bun_io::PathOrFileDescriptor::Fd(*fd), - PathOrFileDescriptor::Path(slice) => { - bun_io::PathOrFileDescriptor::Path(bun_core::PathString::init(slice.slice())) - } + PathOrFileDescriptor::Path(slice) => bun_io::PathOrFileDescriptor::Path(slice.slice()), }; let result = bun_io::open_for_writing( Fd::cwd(), diff --git a/src/runtime/webcore/blob/Store.rs b/src/runtime/webcore/blob/Store.rs index 79d9d245fc5..6b7c2fa6f15 100644 --- a/src/runtime/webcore/blob/Store.rs +++ b/src/runtime/webcore/blob/Store.rs @@ -264,8 +264,8 @@ impl StoreExt for Store { writer.write_int_le::(slice.len() as u32)?; writer.write_all(slice)?; - writer.write_int_le::(bytes.stored_name.slice().len() as u32)?; - writer.write_all(bytes.stored_name.slice())?; + writer.write_int_le::(bytes.stored_name.len() as u32)?; + writer.write_all(&bytes.stored_name)?; } } Ok(()) diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 99c92d605ff..cb675426aad 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9222,11 +9222,11 @@ pub enum WriteFileEncoding { Buffer, } /// Target — path (relative to `dirfd`) or an already-open fd. -pub enum PathOrFileDescriptor { - Path(bun_core::PathString), +pub enum PathOrFileDescriptor<'a> { + Path(&'a [u8]), Fd(Fd), } -impl Default for PathOrFileDescriptor { +impl Default for PathOrFileDescriptor<'_> { fn default() -> Self { PathOrFileDescriptor::Fd(Fd::INVALID) } @@ -9236,7 +9236,7 @@ pub struct WriteFileArgs<'a> { pub data: WriteFileData<'a>, pub encoding: WriteFileEncoding, pub dirfd: Fd, - pub file: PathOrFileDescriptor, + pub file: PathOrFileDescriptor<'a>, pub mode: Mode, } impl<'a> Default for WriteFileArgs<'a> { @@ -9259,8 +9259,7 @@ pub fn write_file_with_path_buffer( let WriteFileData::Buffer { buffer } = args.data; let fd = match args.file { PathOrFileDescriptor::Fd(fd) => fd, - PathOrFileDescriptor::Path(ref p) => { - let bytes = p.slice(); + PathOrFileDescriptor::Path(bytes) => { if bytes.len() >= path_buf.0.len() { return Err(Error::from_code_int(libc::ENAMETOOLONG, Tag::open).with_path(bytes)); } diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index d113a799071..7715db847b0 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -265,6 +265,44 @@ test("#12894", () => { expect(new File([bunFile], "bar.txt").name).toBe("bar.txt"); }); +test("structuredClone of a named File round-trips its name without leaking or double-freeing", async () => { + // `File.name` is stored on the blob's backing store as an owned, heap-allocated + // buffer (Store.Bytes.stored_name). structuredClone serializes that buffer and + // deserializes it into a fresh store whose path payload owns its own copy. + // Both the source and clone free that buffer on teardown, so an ownership bug + // (missing free -> leak, or double free) is only caught by repeating the + // round-trip under GC pressure. This exercises the owned-buffer path directly; + // under ASAN a double free is a hard crash. + const script = ` + const NAME = "owned-name-" + Buffer.alloc(512, "x").toString() + ".bin"; + for (let i = 0; i < 2000; i++) { + const f = new File(["payload-" + i], NAME, { type: "application/octet-stream" }); + const c = structuredClone(f); + if (c.name !== NAME) throw new Error("name mismatch: " + c.name.slice(0, 16)); + if (c.size !== f.size) throw new Error("size mismatch"); + } + Bun.gc(true); + // A second named clone we keep alive, then read, to hit the deserialized + // store's owned path payload after GC. + const f = new File(["final"], NAME, { type: "text/plain" }); + const c = structuredClone(f); + process.stdout.write(JSON.stringify({ name: c.name, text: await c.text() })); + `; + 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(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + name: "owned-name-" + Buffer.alloc(512, "x").toString() + ".bin", + text: "final", + }); + expect(exitCode).toBe(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 94e2e722991bb74330170103d2cbc08692f29879 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 29 May 2026 19:11:06 +0000 Subject: [PATCH 2/8] Move the File-name ownership test to its own file The structuredClone ownership test shares blob.test.ts with a pre-existing RSS-threshold leak test that flakes on the debug build (its `isASAN` threshold check keys on the binary being named `bun-asan`, but `bun bd` produces `bun-debug`, so the strict non-ASAN threshold is applied under ASAN's inflated RSS). Isolating the ownership test keeps it runnable without that unrelated flake. --- .../fetch/blob-file-name-ownership.test.ts | 41 +++++++++++++++++++ test/js/web/fetch/blob.test.ts | 38 ----------------- 2 files changed, 41 insertions(+), 38 deletions(-) create mode 100644 test/js/web/fetch/blob-file-name-ownership.test.ts diff --git a/test/js/web/fetch/blob-file-name-ownership.test.ts b/test/js/web/fetch/blob-file-name-ownership.test.ts new file mode 100644 index 00000000000..eac73b3e997 --- /dev/null +++ b/test/js/web/fetch/blob-file-name-ownership.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// A `File.name` is stored on the blob's backing store as an owned, +// heap-allocated buffer (`Store.Bytes.stored_name`). `structuredClone` +// serializes that buffer and deserializes it into a fresh store whose path +// payload (`PathLike::String`) owns its own copy. Both the source and the clone +// free that buffer on teardown, so an ownership bug — a missing free (leak) or a +// double free — only surfaces when the round-trip is repeated under GC pressure. +// This exercises the owned-buffer path directly; under a debug (ASAN) build a +// double free is a hard crash. +test("structuredClone of a named File round-trips its name without leaking or double-freeing", async () => { + const script = ` + const NAME = "owned-name-" + Buffer.alloc(512, "x").toString() + ".bin"; + for (let i = 0; i < 2000; i++) { + const f = new File(["payload-" + i], NAME, { type: "application/octet-stream" }); + const c = structuredClone(f); + if (c.name !== NAME) throw new Error("name mismatch: " + c.name.slice(0, 16)); + if (c.size !== f.size) throw new Error("size mismatch"); + } + Bun.gc(true); + // A second named clone we keep alive, then read, to hit the deserialized + // store's owned path payload after GC. + const f = new File(["final"], NAME, { type: "text/plain" }); + const c = structuredClone(f); + process.stdout.write(JSON.stringify({ name: c.name, text: await c.text() })); + `; + 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(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + name: "owned-name-" + Buffer.alloc(512, "x").toString() + ".bin", + text: "final", + }); + expect(exitCode).toBe(0); +}); diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 7715db847b0..d113a799071 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -265,44 +265,6 @@ test("#12894", () => { expect(new File([bunFile], "bar.txt").name).toBe("bar.txt"); }); -test("structuredClone of a named File round-trips its name without leaking or double-freeing", async () => { - // `File.name` is stored on the blob's backing store as an owned, heap-allocated - // buffer (Store.Bytes.stored_name). structuredClone serializes that buffer and - // deserializes it into a fresh store whose path payload owns its own copy. - // Both the source and clone free that buffer on teardown, so an ownership bug - // (missing free -> leak, or double free) is only caught by repeating the - // round-trip under GC pressure. This exercises the owned-buffer path directly; - // under ASAN a double free is a hard crash. - const script = ` - const NAME = "owned-name-" + Buffer.alloc(512, "x").toString() + ".bin"; - for (let i = 0; i < 2000; i++) { - const f = new File(["payload-" + i], NAME, { type: "application/octet-stream" }); - const c = structuredClone(f); - if (c.name !== NAME) throw new Error("name mismatch: " + c.name.slice(0, 16)); - if (c.size !== f.size) throw new Error("size mismatch"); - } - Bun.gc(true); - // A second named clone we keep alive, then read, to hit the deserialized - // store's owned path payload after GC. - const f = new File(["final"], NAME, { type: "text/plain" }); - const c = structuredClone(f); - process.stdout.write(JSON.stringify({ name: c.name, text: await c.text() })); - `; - 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(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual({ - name: "owned-name-" + Buffer.alloc(512, "x").toString() + ".bin", - text: "final", - }); - expect(exitCode).toBe(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 c2af3d6ca9cf84846b91da69d24e242aeacbf08b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 29 May 2026 19:16:50 +0000 Subject: [PATCH 3/8] Fix stale comment referencing deleted deinit_owned The SavedFile::to_js comment still described the old PathString ownership (Store::drop freeing via deinit_owned); update it to match the Saved arm: the owned PathLike::String (a CowSlice) frees itself in PathLike::drop. --- src/runtime/api/output_file_jsc.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/runtime/api/output_file_jsc.rs b/src/runtime/api/output_file_jsc.rs index 76f0d7eebb5..59eb7ecd802 100644 --- a/src/runtime/api/output_file_jsc.rs +++ b/src/runtime/api/output_file_jsc.rs @@ -68,8 +68,9 @@ impl SavedFile { // SAFETY: `bun_vm()` returns the live `*mut VirtualMachine` for a // Bun-owned global; we hold a unique `&mut` only for this call. let mime_type = global_this.bun_vm().as_mut().mime_type(path); - // `Store::drop` frees `PathLike::String` via `deinit_owned`, so the - // backing buffer must be owned by the store, not borrowed from `path`. + // An owned `PathLike::String` (a `CowSlice`) frees its buffer in + // `PathLike::drop`, so the backing buffer must be owned by the store, + // not borrowed from `path`. let store = BlobStore::init_file( PathOrFileDescriptor::Path(PathLike::String(bun_ptr::cow_slice::CowSlice::init_owned( path.to_vec().into_boxed_slice(), From 60b49d41d91fbea9c4b2008bc26afd4310e16c09 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 29 May 2026 20:05:29 +0000 Subject: [PATCH 4/8] Fix typo in root_path doc comment (isued -> is used) --- src/runtime/node/node_fs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index de92b1d0700..f5e7d3ec30d 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -2232,7 +2232,7 @@ mod _async_tasks { /// All the subtasks will use this fd to open files pub root_fd: FD, - /// This isued when joining the file paths for error messages. + /// This is used when joining the file paths for error messages. /// Heap-owned, NUL-terminated (`[path.., 0]`); freed on drop. pub root_path: Box<[u8]>, From 226b62367854fa9264a857551d7248791cecf020 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 29 May 2026 20:19:59 +0000 Subject: [PATCH 5/8] test: filter ASAN startup warning from stderr assertion The owned-buffer ownership test runs a subprocess and asserts empty stderr. On ASAN debug builds the subprocess emits a startup 'WARNING: ASAN interferes ...' line; filter it out so the assertion still catches a real ASAN double-free report without flaking. --- test/js/web/fetch/blob-file-name-ownership.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/js/web/fetch/blob-file-name-ownership.test.ts b/test/js/web/fetch/blob-file-name-ownership.test.ts index eac73b3e997..18ef08d3e10 100644 --- a/test/js/web/fetch/blob-file-name-ownership.test.ts +++ b/test/js/web/fetch/blob-file-name-ownership.test.ts @@ -32,7 +32,11 @@ test("structuredClone of a named File round-trips its name without leaking or do stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); + // ASAN debug builds emit a startup "WARNING: ASAN interferes ..." line; drop + // it before asserting the subprocess produced no other stderr (e.g. an ASAN + // double-free report). + const stderrLines = stderr.split("\n").filter(line => !line.startsWith("WARNING: ASAN interferes")); + expect(stderrLines.join("\n")).toBe(""); expect(JSON.parse(stdout)).toEqual({ name: "owned-name-" + Buffer.alloc(512, "x").toString() + ".bin", text: "final", From 6d79efa6f791ef3dab0ee28904146a3e8947e5ff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 29 May 2026 20:56:54 +0000 Subject: [PATCH 6/8] Place SAFETY comments adjacent to their unsafe blocks in node_fs clippy::undocumented-unsafe-blocks requires the // SAFETY: comment on the line immediately preceding the unsafe block. Two detach_lifetime blocks had their comment separated by an intervening let binding. --- src/runtime/node/node_fs.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index f5e7d3ec30d..93aec5c82b3 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -2499,9 +2499,9 @@ mod _async_tasks { // `perform_work`'s callee reads it (it mutates other fields), so // detach the field borrow to satisfy borrowck (mirrors the // `perform_work` body's own `args_ptr` erase, and line ~6623). - // SAFETY: `root_path` is a NUL-terminated `Box<[u8]>` set in - // `create()` and not reallocated for the task's lifetime. let root_path_z = { + // SAFETY: `root_path` is a NUL-terminated `Box<[u8]>` set in + // `create()` and not reallocated for the task's lifetime. let bytes: &'static [u8] = unsafe { bun_ptr::detach_lifetime(&this.root_path[..]) }; ZStr::from_buf(bytes, bytes.len() - 1) }; @@ -6594,12 +6594,12 @@ impl NodeFS { // PORT NOTE: `root_path` is never mutated for the lifetime of the task, but // borrowck can't see that across `async_task.enqueue(&mut self, …)`. Detach // the slice via raw-pointer round-trip — same bytes Zig's `[]const u8` saw. - // SAFETY: `async_task.root_path`'s backing storage is fixed at `create()` and - // outlives every `enqueue` call below. let root_basename: &[u8] = { // `root_path` is NUL-terminated (`[path.., 0]`); the basename // excludes the trailing NUL. let path = &async_task.root_path; + // SAFETY: `async_task.root_path`'s backing storage is fixed at + // `create()` and outlives every `enqueue` call below. unsafe { bun_ptr::detach_lifetime(&path[..path.len() - 1]) } }; #[cfg(not(windows))] From 3e625f79505f770bb3fe4a20f6d8a85da79c6110 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 29 May 2026 21:21:46 +0000 Subject: [PATCH 7/8] Fix stale PathString doc ref; cover the Bun.file CowSlice clone arm - dir_iterator.rs: IteratorResultWName's doc still called it a 'Fake PathString'; it now mirrors IteratorResult.name (RawSlice + slice_assume_z), matching the file header. - blob-file-name-ownership test: the File([bytes], name) round-trip only exercises the Bytes.stored_name (Box<[u8]>) path. Added a structuredClone(Bun.file(path)) case so the owned PathLike::String (CowSlice) deserialize arm is covered too, and corrected the comment. --- src/runtime/node/dir_iterator.rs | 3 +- .../fetch/blob-file-name-ownership.test.ts | 64 +++++++++++++------ 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/runtime/node/dir_iterator.rs b/src/runtime/node/dir_iterator.rs index 3b489c63ede..37c8417511c 100644 --- a/src/runtime/node/dir_iterator.rs +++ b/src/runtime/node/dir_iterator.rs @@ -56,7 +56,8 @@ impl IteratorResult { } pub type Result = sys::Result>; -/// Fake PathString to have less `if (Environment.isWindows) ...` +/// The `u16` twin of `IteratorResult.name` (`RawSlice` + `slice_assume_z()`), +/// kept separate so callers avoid an `if (Environment.isWindows) ...` split. // TODO(port): lifetime — borrows iterator's internal `name_data` buffer; invalidated on next() pub struct IteratorResultWName { // `RawSlice` invariant: the iterator's `name_data` outlives this result diff --git a/test/js/web/fetch/blob-file-name-ownership.test.ts b/test/js/web/fetch/blob-file-name-ownership.test.ts index 18ef08d3e10..4336e2ca1d4 100644 --- a/test/js/web/fetch/blob-file-name-ownership.test.ts +++ b/test/js/web/fetch/blob-file-name-ownership.test.ts @@ -1,29 +1,53 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; -// A `File.name` is stored on the blob's backing store as an owned, -// heap-allocated buffer (`Store.Bytes.stored_name`). `structuredClone` -// serializes that buffer and deserializes it into a fresh store whose path -// payload (`PathLike::String`) owns its own copy. Both the source and the clone -// free that buffer on teardown, so an ownership bug — a missing free (leak) or a -// double free — only surfaces when the round-trip is repeated under GC pressure. -// This exercises the owned-buffer path directly; under a debug (ASAN) build a -// double free is a hard crash. -test("structuredClone of a named File round-trips its name without leaking or double-freeing", async () => { +// `structuredClone` of a blob-backed object serializes its path/name bytes and +// deserializes them into a fresh store that owns its own copy, so a bad free or +// a missing free only surfaces when the round-trip is repeated under GC — and a +// double free is a hard crash under a debug (ASAN) build. The two cases below +// cover the two distinct owned buffers the (de)serializer allocates: +// +// - `new File([bytes], name)` is a `Data::Bytes` store; the round-trip goes +// through `SerializeTag::Bytes` and adopts the name into `Bytes.stored_name` +// (`Box<[u8]>`). +// - `Bun.file(path)` is a `Data::File` store; the round-trip goes through +// `SerializeTag::File` and adopts the path into `PathLike::String` +// (`CowSlice`, owned arm). +test("structuredClone round-trips File (Bytes) and Bun.file (File) names without leaking or double-freeing", async () => { + using dir = tempDir("blob-name-ownership", { + "payload.bin": "the file contents", + }); + const filePath = `${String(dir)}/payload.bin`; const script = ` const NAME = "owned-name-" + Buffer.alloc(512, "x").toString() + ".bin"; + const FILE_PATH = ${JSON.stringify(filePath)}; + + // Data::Bytes store -> SerializeTag::Bytes -> Bytes.stored_name (Box<[u8]>). for (let i = 0; i < 2000; i++) { const f = new File(["payload-" + i], NAME, { type: "application/octet-stream" }); const c = structuredClone(f); - if (c.name !== NAME) throw new Error("name mismatch: " + c.name.slice(0, 16)); - if (c.size !== f.size) throw new Error("size mismatch"); + if (c.name !== NAME) throw new Error("bytes name mismatch: " + c.name.slice(0, 16)); + if (c.size !== f.size) throw new Error("bytes size mismatch"); } + + // Data::File store -> SerializeTag::File -> PathLike::String (CowSlice owned). + for (let i = 0; i < 2000; i++) { + const f = Bun.file(FILE_PATH); + const c = structuredClone(f); + if (c.name !== FILE_PATH) throw new Error("file name mismatch: " + c.name); + } + Bun.gc(true); - // A second named clone we keep alive, then read, to hit the deserialized - // store's owned path payload after GC. - const f = new File(["final"], NAME, { type: "text/plain" }); - const c = structuredClone(f); - process.stdout.write(JSON.stringify({ name: c.name, text: await c.text() })); + // Keep one clone of each kind alive past GC, then read through it to touch + // the deserialized owned buffers after a collection. + const bytesClone = structuredClone(new File(["final"], NAME, { type: "text/plain" })); + const fileClone = structuredClone(Bun.file(FILE_PATH)); + process.stdout.write(JSON.stringify({ + bytesName: bytesClone.name, + bytesText: await bytesClone.text(), + fileName: fileClone.name, + fileText: await fileClone.text(), + })); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], @@ -38,8 +62,10 @@ test("structuredClone of a named File round-trips its name without leaking or do const stderrLines = stderr.split("\n").filter(line => !line.startsWith("WARNING: ASAN interferes")); expect(stderrLines.join("\n")).toBe(""); expect(JSON.parse(stdout)).toEqual({ - name: "owned-name-" + Buffer.alloc(512, "x").toString() + ".bin", - text: "final", + bytesName: "owned-name-" + Buffer.alloc(512, "x").toString() + ".bin", + bytesText: "final", + fileName: filePath, + fileText: "the file contents", }); expect(exitCode).toBe(0); }); From 1c484f288d96fdd615bd6d0b22ea244bdec69eb0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 29 May 2026 23:06:03 +0000 Subject: [PATCH 8/8] ci: retrigger