Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b5d1e41
Never hand one StringImpl to two threads' atom tables
dylan-conway Aug 25, 2026
015ae99
review: drop the AsyncHTTP borrower before the buffers it borrows; co…
dylan-conway Aug 25, 2026
3304f3b
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 25, 2026
2bec779
docs: cross-thread rule is about atomizable impls
dylan-conway Aug 25, 2026
2ba05bd
Merge remote-tracking branch 'origin/claude/cross-thread-strings-2' i…
dylan-conway Aug 25, 2026
5444d80
ObjectURLRegistry: keep an isolated copy of the name and give each re…
dylan-conway Aug 25, 2026
4c7b18f
use StringImpl::isSubString()
dylan-conway Aug 25, 2026
0181b24
tests: pipe and assert stderr
dylan-conway Aug 25, 2026
3a472a5
One primitive for cross-VM strings: String::share() (isolate if neede…
dylan-conway Aug 25, 2026
dbd332c
Merge branch 'main' into claude/cross-thread-strings-2
Jarred-Sumner Aug 25, 2026
4ed92e7
Merge remote-tracking branch 'origin/main' into claude/cross-thread-s…
dylan-conway Aug 25, 2026
72f1a43
Merge remote-tracking branch 'origin/main' into claude/cross-thread-s…
dylan-conway Aug 25, 2026
5c84363
Rename: String::thread_isolated_copy() (was to_thread_safe) and make_…
dylan-conway Aug 25, 2026
8c215f3
source-lints: ThreadSafe<T> -> ThreadShareable<T> in the thread-door …
dylan-conway Aug 25, 2026
92ac381
is_thread_isolated() and is_thread_shareable() check exactly what thr…
dylan-conway Aug 25, 2026
1d9c6d0
makeThreadShareable: don't rewrite the never-atomize flag on an impl …
dylan-conway Aug 25, 2026
fbc53f9
review: hand-off family is ThreadIsolated/make_thread_isolated (one o…
dylan-conway Aug 25, 2026
150fe27
Readable::init: drop the Stdio it consumes (only the memfd is moved o…
dylan-conway Aug 25, 2026
2b81d4a
Readable::init: silence unused memfd on Windows
dylan-conway Aug 25, 2026
2b11402
Merge branch 'main' into claude/cross-thread-strings-2
dylan-conway Aug 26, 2026
eea22c7
get_file_name: an empty name is not a filename (loader/Content-Dispos…
dylan-conway Aug 26, 2026
d17704b
Merge remote-tracking branch 'origin/claude/cross-thread-strings-2' i…
dylan-conway Aug 26, 2026
3c98f82
Bun.file(path)/S3 store paths: back to an isolated copy (main's behav…
dylan-conway Aug 26, 2026
13bf71d
bake module_keys: back to main's isolated copy (one VM today)
dylan-conway Aug 26, 2026
0d976d1
Utf8WithString::thread_isolated_copy: build the copy instead of mutat…
dylan-conway Aug 26, 2026
55b4156
Rename Bun::isolatedCopyForSharing -> Bun::threadShareableCopy
dylan-conway Aug 26, 2026
c0dc9c1
Utf8WithString::thread_isolated_copy: exactly main's to_thread_safe (…
dylan-conway Aug 26, 2026
4cb843b
Utf8WithString::thread_isolated_copy: when the bytes were already tra…
dylan-conway Aug 26, 2026
f5f11b8
Readable::init: handle Memfd inside the match (no pre-pass, no unreac…
dylan-conway Aug 26, 2026
e04c24e
PathLike/PathOrFileDescriptor::thread_isolated_copy: by value, like S…
dylan-conway Aug 26, 2026
2f2c069
docs: PathLike::thread_isolated_copy name in CLAUDE.md
dylan-conway Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions src/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,13 +384,21 @@ non-transferring path UAFs at GC.

### Cross-thread string hazards

`AtomString`s live in a per-thread table. Never deref one from another thread —
it trips `wasRemoved` in `AtomStringImpl::remove()`. If a `bun_core::String`
may be dropped from a non-JS thread (HTTP worker, threadpool, dying VM), build
it via `String::clone_utf8` (a plain `WTFStringImpl` with an atomic refcount),
not from an interned/atomized JS string. See the comment in
`src/runtime/webcore/fetch/FetchTasklet.rs` near `Response::init` for the
canonical example of this bug class and its fix.
`StringImpl` refcounts are atomic, but atom tables are per thread: using a
string as a property key (`Identifier::fromString`) atomizes its impl _in
place_ into the current thread's table, and the last `deref()` of an atom
removes it from the current thread's table (`RELEASE_ASSERT(wasRemoved)` if it
is not there). So an atomizable impl must never be reachable from two VMs —
not via a process-global registry handing out `String::clone()`s, not via one
`SerializedScriptValue` deserialized by several receivers. Hand another thread
its own bytes (`Box<[u8]>` / `clone_utf8` on arrival), or share a
`String::share()`d / `Bun::isolatedCopyForSharing` / `toCrossThreadShareable`
string (pre-hashed and marked never-atomize, so a receiver's atom table copies
it instead; see `src/jsc/bindings/BunString.cpp`) or a static string — make it
shared once, then hand it out with plain `clone()`. `String::to_thread_safe()`
is a plain isolated copy for handing a value to _one_ other owner.
`ObjectURLRegistry`, `StandaloneModuleGraph::File` and the structured-clone
object fast paths are the worked examples.

## Common Patterns

Expand Down
46 changes: 26 additions & 20 deletions src/bun_core/string/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ unsafe extern "C" {
// fields in place; the type encodes the sole pointer-validity precondition,
// so `safe fn` discharges the link-time proof here.
safe fn BunString__toThreadSafe(this: &mut String);
safe fn BunString__share(this: &mut String);
fn BunString__createAtom(bytes: *const u8, len: usize) -> String;
fn BunString__tryCreateAtom(bytes: *const u8, len: usize) -> String;
fn BunString__createStaticExternal(bytes: *const u8, len: usize, isLatin1: bool) -> String;
Expand Down Expand Up @@ -507,16 +508,27 @@ impl String {
core::ptr::null_mut()
}
}
/// Replace a WTF-backed impl with an isolated copy, for handing the value
/// to one other thread; not for sharing one impl between VMs.
pub fn to_thread_safe(&mut self) {
if self.tag == Tag::WTFStringImpl {
BunString__toThreadSafe(self)
}
debug_assert!(self.is_thread_safe());
}
/// True iff this `String` may be sent to / shared with another thread
/// without racing the WTF `StringImpl`'s non-atomic refcount: every tag
/// except `WTFStringImpl` is inert (raw slice / static / dead), and a
/// WTF-backed string is safe iff its impl reports `isThreadSafe()`.
/// Make a WTF-backed impl safe to hand to any number of threads/VMs:
/// isolated if it was an atom/symbol/substring, pre-hashed, and never
/// atomized in place; hand it out with `clone()`.
pub fn share(&mut self) {
if self.tag == Tag::WTFStringImpl {
BunString__share(self)
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
}
debug_assert!(self.is_thread_safe());
}
/// True iff this `String` may be handed to another thread as-is: every
/// tag except `WTFStringImpl` is inert (raw slice / static / dead), and a
/// WTF-backed string qualifies iff its impl is not an atom, symbol or
/// substring (`isThreadSafe()`).
///
/// Call sites that move a `String` across a thread boundary must ensure
/// this holds (typically by calling [`to_thread_safe`] first); see the
Expand Down Expand Up @@ -916,13 +928,11 @@ impl String {
let mut out = self.into_utf8_with_string();
if out.string.tag == Tag::WTFStringImpl {
if out.utf8.is_none() {
// 8-bit all-ASCII: a thread-safe `string` keeps backing the bytes.
if out.string.is_thread_safe() {
return out;
}
out.utf8 = Some(out.slice().to_vec());
// 8-bit all-ASCII: a shared `string` keeps backing the bytes.
out.string.share();
return out;
}
// Transcoded or copied; drop the WTF backing to release memory.
// Transcoded; drop the WTF backing to release memory.
out.string = String::EMPTY;
}
out
Expand Down Expand Up @@ -973,20 +983,16 @@ impl Default for String {
}
// SAFETY: `String` is a tag + raw ptr to a `WTF::StringImpl` (or a borrowed
// `EncodedSlice` slice / static / dead sentinel). All non-WTF tags are trivially
// `Send + Sync` (no interior mutability, no refcount). The WTF tag is the
// hazard: `WTF::StringImpl`'s refcount is non-atomic unless the impl was
// created thread-safe, so sending/sharing a non-thread-safe impl across
// threads and then `ref_()`/`deref()`ing it is a data race.
// `Send + Sync` (no interior mutability, no refcount). A `WTF::StringImpl`'s
// refcount is atomic; the hazards are per-thread atom tables and the lazily
// computed hash/flags (see "Cross-thread string hazards" in `src/CLAUDE.md`).
//
// We keep the blanket impls to match the C++ `BunString`
// FFI contract (the type must round-trip by value through `extern "C"` and sit
// in `Send + Sync` containers), and instead enforce the invariant at the
// boundary: any code that moves a `String` to another thread MUST first call
// [`String::to_thread_safe`] (or otherwise guarantee [`String::is_thread_safe`]
// returns `true`). [`String::debug_assert_thread_safe`] is the debug-build
// checkpoint for that hand-off; `to_thread_safe()` itself asserts its own
// postcondition. A `ThreadSafeString` newtype split would make this static,
// but is deferred until the FFI surface can be reshaped.
// boundary: code that moves a `String` to one other thread calls
// [`String::to_thread_safe`], code that shares one impl between threads/VMs
// calls [`String::share`] (both assert [`String::is_thread_safe`]).
unsafe impl Send for String {}
// SAFETY: same contract as the `Send` impl above — sharing requires
// `is_thread_safe()`; non-WTF tags are inert and trivially `Sync`.
Expand Down
2 changes: 1 addition & 1 deletion src/bundler/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ bun_dispatch::link_interface! {
fn is_blob_url(specifier: &[u8]) -> bool;
fn resolve_blob(specifier: &[u8]) -> Option<options::OpaqueBlob>;
fn blob_loader(blob: options::OpaqueBlob) -> Option<bun_ast::Loader>;
fn blob_file_name(blob: options::OpaqueBlob) -> Option<&'static [u8]>;
fn blob_store_path(blob: options::OpaqueBlob) -> Option<&'static [u8]>;
fn blob_needs_read_file(blob: options::OpaqueBlob) -> bool;
fn blob_shared_view(blob: options::OpaqueBlob) -> &'static [u8];
fn blob_deinit(blob: options::OpaqueBlob);
Expand Down
2 changes: 1 addition & 1 deletion src/bundler/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ pub fn get_loader_and_virtual_source<'a>(

// "file:" loader makes no sense for blobs
// so let's default to tsx.
if let Some(filename) = jsc_vm.blob_file_name(blob) {
if let Some(filename) = jsc_vm.blob_store_path(blob) {
let current_path = Fs::Path::init(filename);

// Only treat it as a file if is a Bun.file()
Expand Down
24 changes: 11 additions & 13 deletions src/http/AsyncHTTP.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use core::sync::atomic::{AtomicUsize, Ordering};

use bun_ast::{Loc, Log};
use bun_core::FeatureFlags;
use bun_core::{MutableString, Utf8Bytes};
use bun_core::MutableString;
use bun_threading::IntrusiveWorkTask as _;
use bun_threading::thread_pool::{self, Batch, Task};
use bun_url::{PercentEncoding, URL};
Expand All @@ -24,9 +24,9 @@ bun_core::declare_scope!(AsyncHTTP, visible);

// Lifetime `'a` covers every borrowed input the caller hands in: `url`,
// `http_proxy`, `request_header_buf`, the borrowed `HTTPRequestBody::Bytes`
// payload, and `client.{header_buf,hostname,if_modified_since}`. Intrusive
// fields (`real`, `next`) are raw pointers and thus lifetime-erased; the
// HTTP-thread copy uses the same `'a` as the JS-thread original it mirrors.
// payload, and `client.{header_buf,hostname,unix_socket_path,if_modified_since}`.
// Intrusive fields (`real`, `next`) are raw pointers and thus lifetime-erased;
// the HTTP-thread copy uses the same `'a` as the JS-thread original it mirrors.
pub struct AsyncHTTP<'a> {
pub response: Option<picohttp::Response<'static>>,
pub request_headers: headers::EntryList,
Expand Down Expand Up @@ -191,7 +191,7 @@ fn make_client<'a>(
signals,
async_http_id,
hostname,
unix_socket_path: Utf8Bytes::EMPTY,
unix_socket_path: b"",
compress: None,
compressed_request_body: Vec::new(),
compressed_body_len: 0,
Expand Down Expand Up @@ -245,7 +245,7 @@ pub struct Options<'a> {
pub proxy_headers: Option<Headers>,
pub hostname: Option<&'a [u8]>,
pub signals: Option<Signals>,
pub unix_socket_path: Option<Utf8Bytes<'static>>,
pub unix_socket_path: Option<&'a [u8]>,
pub disable_timeout: Option<bool>,
/// Per-request idle timeout override in seconds; see
/// `HTTPClient::idle_timeout_seconds`.
Expand Down Expand Up @@ -315,7 +315,6 @@ impl<'a> AsyncHTTP<'a> {

pub fn clear_data(&mut self) {
self.response = None;
self.client.unix_socket_path = Utf8Bytes::EMPTY;
}
}

Expand Down Expand Up @@ -461,7 +460,6 @@ impl<'a> AsyncHTTP<'a> {
signals,
};
if let Some(val) = options.unix_socket_path {
debug_assert!(this.client.unix_socket_path.slice().is_empty());
this.client.unix_socket_path = val;
}
if let Some(val) = options.disable_timeout {
Expand Down Expand Up @@ -711,11 +709,11 @@ impl<'a> AsyncHTTP<'a> {
// (`start_queued_task`), so any owned field that was already
// populated at that point — `request_headers`,
// `client.header_entries`, `client.proxy_headers`,
// `client.proxy_settings`, `client.tls_props`,
// `client.unix_socket_path` — is *shared* with the original
// and must NOT be dropped here; the original drops them when
// its `Box<AsyncHTTP>` is reclaimed. Only the state the clone
// built up itself during request processing is torn down.
// `client.proxy_settings`, `client.tls_props` — is *shared*
// with the original and must NOT be dropped here; the original
// drops them when its `Box<AsyncHTTP>` is reclaimed. Only the
// state the clone built up itself during request processing is
// torn down.
{
// `handle_response_metadata` rewrites per-hop request state in
// place: `client.url`/`connected_url` become self-borrows into
Expand Down
11 changes: 2 additions & 9 deletions src/http/HTTPThread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,16 +409,9 @@ impl HttpThread {
// funnels through here before touching `https_context.{group,secure}`.
self.ensure_https_context_init();
}
// Note: borrowck — `slice()` borrows `client`; capture into a
// `bun_ptr::RawSlice` (encapsulated outlives-holder invariant) so the
// borrow of `client` ends before we hand `&mut client` to
// `connect_socket`. Backing storage is `client.unix_socket_path`, which
// `connect_socket` does not touch.
let unix_path = bun_ptr::RawSlice::new(client.unix_socket_path.slice());
let unix_path = client.unix_socket_path;
if !unix_path.is_empty() {
return self
.context::<IS_SSL>()
.connect_socket(client, unix_path.slice());
return self.context::<IS_SSL>().connect_socket(client, unix_path);
}

if IS_SSL {
Expand Down
34 changes: 12 additions & 22 deletions src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,6 @@ pub(crate) fn hash_header_name(name: &[u8]) -> u64 {
// `bun_uws::NewSocketHandler` methods (`ext`/`timeout`/`raw_write`/`flush`/
// `shutdown`/`connect_group`/…) land.

use bun_core::Utf8Bytes;
use bun_url::URL;
use core::ptr::NonNull;

Expand Down Expand Up @@ -791,8 +790,8 @@ fn no_proxy_matches(no_proxy_text: &[u8], hostname: &[u8], host: &[u8]) -> bool
// Many of these fields can be moved to a packed struct and use less space
//
// Lifetime `'a` ties every borrowed input — `url`, `http_proxy`, `header_buf`,
// `if_modified_since`, `hostname`, and the borrowed `HTTPRequestBody::Bytes`
// payload — to the caller's storage. The original port erased these to `'static`
// `if_modified_since`, `hostname`, `unix_socket_path`, and the borrowed
// `HTTPRequestBody::Bytes` payload — to the caller's storage. The original port erased these to `'static`
// and lifetime-erased at every call site; threading the lifetime removes that hazard.
// Intrusive raw-pointer backrefs (socket ext, h2/h3 streams) store the
// lifetime-erased `HTTPClient<'static>` form via [`HTTPClient::as_erased_ptr`].
Expand Down Expand Up @@ -870,7 +869,7 @@ pub struct HTTPClient<'a> {
pub(crate) signals: Signals,
pub(crate) async_http_id: u32,
pub(crate) hostname: Option<&'a [u8]>,
pub(crate) unix_socket_path: Utf8Bytes<'static>,
pub(crate) unix_socket_path: &'a [u8],
/// `fetch({ compress })` — when set, the body is compressed lazily at
/// write time (h1: `send_initial_request_payload`; h2/h3: at attach) so
/// the output can borrow `LibdeflateState::shared_buffer`. Persists across
Expand Down Expand Up @@ -1943,7 +1942,7 @@ impl<'a> HTTPClient<'a> {
// through a non-keepalive Agent or `agent: false` skip it, matching Node.
//
// TCP options do not apply to a unix socket.
if !self.flags.disable_keepalive && self.unix_socket_path.slice().is_empty() {
if !self.flags.disable_keepalive && self.unix_socket_path.is_empty() {
let _ = socket.set_keep_alive(true, 60);
}

Expand Down Expand Up @@ -1971,7 +1970,7 @@ impl<'a> HTTPClient<'a> {
if self.flags.is_preconnect_only {
return false;
}
if self.unix_socket_path.slice().len() > 0 {
if !self.unix_socket_path.is_empty() {
return false;
}
if matches!(
Expand Down Expand Up @@ -2020,7 +2019,7 @@ impl<'a> HTTPClient<'a> {
if self.flags.is_preconnect_only {
return false;
}
if self.unix_socket_path.slice().len() > 0 {
if !self.unix_socket_path.is_empty() {
return false;
}
if matches!(
Expand Down Expand Up @@ -2259,7 +2258,7 @@ impl<'a> HTTPClient<'a> {
pub(crate) fn is_keep_alive_possible(&self) -> bool {
if FeatureFlags::ENABLE_KEEPALIVE {
// TODO keepalive for unix sockets
if self.unix_socket_path.slice().len() > 0 {
if !self.unix_socket_path.is_empty() {
return false;
}
// check state
Expand Down Expand Up @@ -2633,15 +2632,9 @@ impl<'a> HTTPClient<'a> {
self.flags.is_streaming_request_body = false;
}

// Decided before unix_socket_path is forgotten below: a unix-socket connection must not be pooled.
// Decided before unix_socket_path is cleared below: a unix-socket connection must not be pooled.
let keep_alive_possible = self.is_keep_alive_possible();
// There is no struct copy-back
// (`sync_progress_from` skips owned fields) and the original retains
// its own `Owned(Vec)` aliasing the same allocation (the HTTP-thread
// clone was created via `ptr::read`). Dropping it here would
// double-free when the original later runs `clear_data()`. Forget the
// clone's view; the original is the sole owner.
let _ = core::mem::ManuallyDrop::new(core::mem::take(&mut self.unix_socket_path));
self.unix_socket_path = b"";
// TODO: what we do with stream body?
let request_body: &[u8] = if self.state.flags.resend_request_body_on_redirect
&& matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_))
Expand Down Expand Up @@ -2863,7 +2856,7 @@ impl<'a> HTTPClient<'a> {
self.complete_connecting_process();
return;
}
if self.http_proxy.is_some() || self.unix_socket_path.slice().len() > 0 {
if self.http_proxy.is_some() || !self.unix_socket_path.is_empty() {
self.fail(crate::Error::HTTP3Unsupported);
self.complete_connecting_process();
return;
Expand Down Expand Up @@ -4357,10 +4350,7 @@ impl<'a> HTTPClient<'a> {
if matches!(self.state.original_request_body, HTTPRequestBody::Stream(_)) {
self.flags.is_streaming_request_body = false;
}
// See `do_redirect`: the HTTP-thread clone shares this allocation
// with the JS-thread original (created via `ptr::read`); dropping it
// here double-frees once the original runs `clear_data()`.
let _ = core::mem::ManuallyDrop::new(core::mem::take(&mut self.unix_socket_path));
self.unix_socket_path = b"";
let request_body: &[u8] = if self.state.flags.resend_request_body_on_redirect
&& matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_))
{
Expand Down Expand Up @@ -4953,7 +4943,7 @@ impl<'a> HTTPClient<'a> {
// request to the same origin may be h3-eligible even if this
// one was pinned/proxied/sendfile.
if self.is_https()
&& self.unix_socket_path.slice().len() == 0
&& self.unix_socket_path.is_empty()
&& !(self.flags.proxy_tunneling && self.proxy_tunnel.is_none())
&& h3_alt_svc_enabled()
{
Expand Down
2 changes: 1 addition & 1 deletion src/http/session_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ unsafe extern "C" {
pub(crate) fn eligible(client: &crate::HTTPClient<'_>) -> bool {
client.flags.reject_unauthorized
&& !client.signals.get(signals::Field::CertErrors)
&& client.unix_socket_path.slice().is_empty()
&& client.unix_socket_path.is_empty()
&& !bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE
.get()
.unwrap_or(false)
Expand Down
Loading
Loading