Never hand one StringImpl to two threads' atom tables (object URLs, BroadcastChannel, --compile workers)

Merged
DY
2mo ago
+887-729

What does this PR do?

Supersedes #40408. WTF::StringImpl refcounts are atomic, but two things are per-thread: Identifier::fromString atomizes a non-atom impl in place into the current thread's atom table, and the final deref() of an atom removes it from the current thread's table (RELEASE_ASSERT if it isn't there). So an atomizable impl that two VMs can both reach is unsafe even if nobody "drops it off-thread": one VM atomizes it, another then uses a foreign atom as a property key (a lookup by the same text misses), and whichever thread drops the last ref aborts. Sharing is sound when the impl is prepared for it — pre-hashed and marked never-atomize (each VM's table then takes its own copy on key use), which postMessage's string fast path already did via toCrossThreadShareable. This PR adds the Rust spelling, String::make_thread_shareable() (BunString__makeThreadShareable: in place for a plain impl, isolated copy for an atom/symbol/substring), and applies it to the places that shared without it:

  • URL.createObjectURL — the process-global registry stored the Blob's name impl (a JS string from the creating worker) plus its global_this, and gave clones to every VM that resolved the URL. Repro (crashes 1.4.0 every run): worker A creates named Files and object URLs, worker B uses fetch(url).blob().name as a key and GCs, A revokes → panic: abort(). The entry now make_thread_shareable()s the name once and has a null global; each resolve stamps the resolving VM's global.
  • Structured clone, object fast paths — property-name impls were stored with plain isolatedCopy(); the dense-array receive path atomized them in place, and a BroadcastChannel message has many receivers. Repro (crashes/hangs 1.4.0 every run, 12 lines): bc.postMessage([{ ["key"+r]: r }]) to two workers that read the keys. Property keys are now stored with threadShareableCopy in both object fast paths (simple object and dense array of objects), and all three receive sites use the stored key directly (the simple-object path drops its per-receive re-copy).
  • bun build --compile + Workers — Bun.embeddedFiles / Bun.file(embedded) duped one cached Blob (name impl + first VM's global_this) into every Worker, and text modules / module source shared one cached impl per file, lazily initialized without synchronization. Repro (1.4.0): in a Worker, p[file.name] = 1; p[sameTextBuiltAtRuntime] is undefined (main: 1). Both caches are now OnceLocks holding a make_thread_shareable()d string; name/global are per VM.
  • fetch({ unix }) bit-copied a possibly JS-backed Utf8Bytes onto the HTTP thread (kept correct only by two ManuallyDrops); the tasklet now owns the bytes and the client borrows &[u8]. Worker Bun.$ argv copies the parent's option strings like process.argv does. Bundler plugin namespace strings are isolated before the bundle thread reads them.

Found while making the shared Store honest: new File([a], "b.txt") renamed a (a.name === "b.txt"; Node keeps "a.txt") because it wrote the name into the shared store. A File now carries its own name and never writes it into the store (new File([], "")/new File(["x"], "") both report "" like browsers), Blob::get_file_name is name-first — so a FormData entry's filename, the auto Content-Disposition filename and the loader picked for import(objectURL) follow file.name (the store path is still what gets opened), and Blob__getFileNameString returns an owned string instead of a view into the store's bytes — and Store.is_all_ascii is atomic since stores are shared across threads.

Naming: String::to_thread_safe() is now thread_isolated_copy() (WTF isolatedCopy: a private copy for one other owner — still what Bun.file(path)/S3 stores use for their path) with is_thread_isolated(); the work-pool hand-off is make_thread_isolated() / ThreadIsolated<T> / PathLike::ThreadIsolatedString (unchanged behaviour: keep a non-atom impl, else copy the bytes); the new many-holders primitive is String::make_thread_shareable() / is_thread_shareable() (C++ Bun::makeThreadShareable, threadShareableCopy, toCrossThreadShareable), used only at the object-URL registry and the --compile graph caches. StandaloneModuleGraph lookups that no longer mutate go through get_ref()/find_ref().

How did you verify your code works?

New tests, each failing on 1.4.0 and passing here: worker_blob.test.ts (object URL across workers — abort), broadcast-channel.test.ts (arrays of objects and objects to two workers — abort), bundler_compile.test.ts compile/EmbeddedFileNamesPerThread (embedded file name and text module as keys in a Worker — wrong value), blob.test.ts (new File([file], name) does not rename the source), bunstring-thread-shareable.test.ts (ref balance of thread_isolated_copy / make_thread_shareable). Debug+ASAN: worker, worker_blob, worker_threads, broadcast-channel, structured-clone, fetch, fetch.unix, blob, body, FormData, serve, shell, fs suites match main.