Skip to content

worker_threads: copy argv/execArgv for the worker thread instead of sharing the parent's StringImpls - #37140

Closed
robobun wants to merge 1 commit into
mainfrom
farm/ed6bb19f/worker-argv-cross-thread-atomstring
Closed

robobun wants to merge 1 commit into
mainfrom
farm/ed6bb19f/worker-argv-cross-thread-atomstring

Conversation

@robobun

@robobun robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

bun test --parallel workers on the Windows CI lanes have been dying with a silent NTSTATUS 0xC0000409 (fastfail) since Aug 4, blamed on whichever test file was on CPU at the time, most often test/js/node/zlib/zlib-estimated-size-gc.test.ts (builds 88721 through 89988, both the 2019 x64 and 11 aarch64 lanes). Reproducing the crashed shard's parallel bucket on a Windows machine hit the same crash on the first attempt, and a debug build turned the silent fastfail into the real error:

ASSERTION FAILED: wasRemoved
vendor/WebKit/Source/WTF/wtf/text/AtomStringImpl.cpp(462)
  "The string being removed is an atom in the string table of an other thread!"
WTF::AtomStringImpl::remove
WTF::StringImpl::~StringImpl
...
WebCore::WorkerOptions::~WorkerOptions
WebCore::Worker::~Worker
...
JSC::MarkedSpace::sweepPreciseAllocations

Cause

Worker::create handed the WorkerOptions argv/execArgv strings to the worker as raw WTF::StringImpl pointers (web_worker.rs stored them as borrowed argv_ptr/exec_argv_ptr). The worker thread wrapped those impls in worker-heap JSStrings for process.argv/process.execArgv, and any property-key use of one (obj[arg], in the CI case node:tls scanning execArgv) atomized the shared impl in place, inserting it into the worker's thread-local AtomStringTable.

If the worker thread is still exiting when the parent GC sweeps the Worker wrapper, ~WorkerOptions performs the final deref on the parent thread and AtomStringImpl::remove hits its RELEASE_ASSERT (the atom belongs to another thread's table). On Windows release builds the UCRT abort surfaces as a silent __fastfail 0xC0000409, which killed the parallel test worker with no output. The race needs the sweep to land between the worker's exit event and its thread-local destruction, which is why it showed up on the slow 3-core CI boxes and not in local runs.

This is the same bug class fixed for options.name in #34140 (Worker.cpp now does jsString(vm, options.name.isolatedCopy())).

Fix

Copy argv/execArgv into worker-owned UTF-8 in WebWorker__create (on the parent thread, same treatment as name and preloadModules). The worker builds its JSStrings from the owned bytes, so no parent-owned impl can ever be atomized on the worker thread, and the borrowed-pointer coupling to the C++ WorkerOptions lifetime is gone. Consumers simplify:

  • process.argv/process.execArgv creation copies bytes into fresh impls (BunString::borrow_utf8(...).to_js)
  • the exec-argv bootstrap-flag scan and the --no-addons parse hook lose their unsafe per-element WTF derefs
  • the shell's $N lazily-populated UTF-8 cache (vm_args_utf8) is deleted: the bytes are already UTF-8

Verification

  • New regression test forces the exact interleaving deterministically on Linux: pin the fixture process to one CPU, put the dying worker thread in SCHED_IDLE (chrt -i), and run a full GC on the first microtask after the exit event. Without the fix it aborts in AtomStringImpl::remove every run (8/8); with the fix it passes (5/5). taskset/chrt are best-effort, so the fixture still exercises the path on other platforms.
  • bun bd test test/js/node/worker_threads/worker_threads.test.ts (92 pass), test/js/web/workers/worker.test.ts (25 pass), test/js/bun/shell/bunshell.test.ts (503 pass), test/js/node/no-addons.test.ts.
  • Behavior parity checked against the previous release for process.argv/process.execArgv values (unicode, empty strings) and shell $0..$N expansion inside workers.

Windows validation of the original parallel-bucket repro is running and will be posted below.

…haring the parent's StringImpls

Worker::create passed the WorkerOptions argv/execArgv Vector<String> contents
to the worker as raw WTF::StringImpl pointers. The worker thread wrapped them
in worker-heap JSStrings (process.argv / process.execArgv), and any
property-key use atomized the shared impl in the worker's thread-local
AtomStringTable. When the parent's GC swept the Worker wrapper while the
worker thread was still exiting, ~WorkerOptions did the final deref on the
parent thread and aborted in AtomStringImpl::remove:

  ASSERTION FAILED: wasRemoved
  The string being removed is an atom in the string table of an other thread!

In release builds on Windows that abort is a silent 0xC0000409 fastfail; it
took down bun test --parallel workers on the Windows CI lanes (whichever test
file was on-CPU got the blame, e.g. zlib-estimated-size-gc.test.ts).

Copy argv/execArgv into worker-owned UTF-8 at create() (parent thread), the
same treatment name and preloadModules already get. The worker now builds its
JSStrings from owned bytes, so no parent impl can be atomized on the worker
thread, and the borrowed-pointer lifetime coupling to the C++ WorkerOptions is
gone. The shell's $N cache and the exec-argv scan drop their per-element
WTF conversions since the bytes are already UTF-8.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ceb84887-dc96-4900-a43a-84fb83840cc0

📥 Commits

Reviewing files that changed from the base of the PR and between 45ee955 and 78e2f60.

📒 Files selected for processing (7)
  • src/jsc/VirtualMachine.rs
  • src/jsc/web_worker.rs
  • src/runtime/jsc_hooks.rs
  • src/runtime/node/node_process.rs
  • src/runtime/shell/interpreter.rs
  • src/runtime/shell/states/Expansion.rs
  • test/js/node/worker_threads/worker_threads.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Windows validation (Server 2019 x64, debug build of this branch):

  • Reconstructed shard0 parallel bucket from the crashing CI job (131 files, --parallel=3, CPU affinity limited to 4 cores to mimic the 3-core CI box). On the unfixed canary release this crashed a worker with exit code 9 (0xC0000409) on 4/4 attempts; an unfixed debug build aborted with ASSERTION FAILED: wasRemoved pointing at ~WorkerOptions.
  • Minimal subset (test/js/web/workers/message-event.test.ts + worker.test.ts, --parallel=1): unfixed debug build aborted 3/3; with this fix 3/3 clean runs (exit 0, no crashes).
  • 44-file middle slice of the bucket: unfixed debug aborted 2/2; with this fix 2/2 complete with no crashes (only the known node-sqlite GC-stress debug timeouts fail, same as before the fix).

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. worker: copy argv/execArgv into worker-local StringImpls #36393 - Fixes the same cross-thread WTF::StringImpl crash for worker argv/execArgv by copying them into worker-local strings, overlapping on src/runtime/node/node_process.rs.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #36393, which fixed the same bug first: the worker argv/execArgv StringImpls are shared by pointer with the worker thread, get atomized there, and the parent's final deref aborts in AtomStringImpl::remove (silent 0xC0000409 fastfail on Windows release). The parts of this investigation that were new, the current CI blame pattern and a deterministic regression test that fails on the debug+ASAN build, are posted on #36393 for adoption; the branch stays up for cherry-picking.

@robobun robobun closed this Aug 7, 2026
Comment thread src/jsc/web_worker.rs
Comment on lines +546 to +553
unsafe { bun_core::ffi::slice(ptr, len) }
.iter()
.map(|&s| {
// SAFETY: each element is a live `WTF::StringImpl*` (see above).
unsafe { &*s }.to_utf8().slice().to_vec().into_boxed_slice()
})
.collect()
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 copy_args does unsafe { &*s }.to_utf8() on each WTF::StringImpl* with no null check, but a null impl is reachable: coerceToIsolatedString returns String() for Symbol() (no description), so new Worker('x', {eval:true, argv:[Symbol()]}) (or execArgv) segfaults the parent thread inside WebWorker__create. The pre-PR consumers guarded this exact case (!arg.is_null() in VirtualMachine.rs, if arg.is_null() { continue; } in jsc_hooks.rs); both guards were removed and the copy site that replaces them has none — skip null entries (or push an empty Box<[u8]>) in copy_args.

Extended reasoning...

What the bug is

The new copy_args closure in WebWorker::create iterates the C++ Vector<WTF::String> (received as *const WTFStringImpl, i.e. *const *mut WTFStringImplStruct) and unconditionally dereferences each element:

.map(|&s| {
    // SAFETY: each element is a live `WTF::StringImpl*` (see above).
    unsafe { &*s }.to_utf8().slice().to_vec().into_boxed_slice()
})

WTFStringImpl is pub type WTFStringImpl = *mut WTFStringImplStruct (bun_alloc/lib.rs:984) — a nullable raw pointer. When s is null, unsafe { &*s } is immediate UB, and to_utf8() reads m_hash_and_flags through it → segfault.

How null reaches this code path

JSWorker.cpp:303-305 defines:

auto coerceToIsolatedString = [lexicalGlobalObject](JSValue v) -> String {
    String original = v.isSymbol() ? asSymbol(v)->tryGetDescriptiveString().value_or(String()) : v.toWTFString(lexicalGlobalObject);
    return original.isolatedCopy();
};

For Symbol() (no description), the uid is a null symbol, so tryGetDescriptiveString() returns std::nullopt (every other in-tree caller — BunProcess.cpp, ErrorCode.cpp — branches on has_value() with a fallback string, confirming this). .value_or(String()) yields a null-impl WTF::String, and .isolatedCopy() on a null String stays null. No exception is thrown, so RETURN_IF_EXCEPTION doesn't fire and options.argv.append(str) stores a null-impl entry.

validateArray only checks that the container is an array (min length 0); it does not validate element types. The existing test file even carries // TODO(@190n) get our handling of non-string array elements in line with Node's, acknowledging non-string elements are reachable.

Worker.cpp then reinterpret_casts Vector<WTF::String> to WTF::StringImpl** and passes it as argv_ptr/exec_argv_ptr to WebWorker__create. A WTF::String IS a RefPtr<StringImpl>, so a null-impl String becomes a null StringImpl* array element on the Rust side.

Why this is a regression

The pre-PR code guarded exactly this condition, and this diff removes both guards:

  • VirtualMachine.rs (removed): !arg.is_null() && is_bootstrap_flag(...)
  • jsc_hooks.rs (removed): if arg.is_null() { continue; }

Before this PR, the null was stored on the WebWorker and either skipped by these guarded scans, or only crashed lazily on the worker thread if process.argv was actually read. After this PR, the deref happens eagerly on the parent thread, inside the Worker constructor, before the worker thread is even spawned — strictly worse. Per REVIEW.md: "Delete defensive code only when you can show the condition cannot occur" and "user-reachable failures are recoverable errors, never panics".

Step-by-step proof

  1. User runs new Worker('1', { eval: true, execArgv: [Symbol()] }).
  2. JSWorker.cpp validateArray passes (it's an array of length 1 ≥ 0).
  3. forEachInIterable calls coerceToIsolatedString(Symbol()) → tryGetDescriptiveString() returns nullopt → String() (null impl) → .isolatedCopy() → still null. No exception. execArgv.append(String{null}).
  4. Worker::create calls WebWorker__create(..., reinterpret_cast<StringImpl**>(execArgv.begin()), 1, ...). Element 0 is a null StringImpl*.
  5. Rust copy_args(exec_argv_ptr, 1) builds a slice [null: *mut WTFStringImplStruct] and does unsafe { &*null }.to_utf8() → null-pointer deref → segfault on the parent thread.

Fix

Restore the null skip in copy_args, mirroring the removed guards:

unsafe { bun_core::ffi::slice(ptr, len) }
    .iter()
    .filter_map(|&s| {
        // A `WTF::String` may have a null impl (e.g. `Symbol()` in
        // argv/execArgv via `coerceToIsolatedString`); skip it, matching
        // the pre-existing consumer guards this replaces.
        core::ptr::NonNull::new(s).map(|nn| unsafe { nn.as_ref() }.to_utf8().slice().to_vec().into_boxed_slice())
    })
    .collect()

(or push an empty Box<[u8]> for null if positional alignment matters — for execArgv scanning it doesn't, and Node stringifies Symbol() to "Symbol()", so either is closer than a crash).

Comment thread src/jsc/web_worker.rs
Comment on lines +548 to +551
.map(|&s| {
// SAFETY: each element is a live `WTF::StringImpl*` (see above).
unsafe { &*s }.to_utf8().slice().to_vec().into_boxed_slice()
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit: the UTF-8 round-trip here replaces lone surrogates with U+FFFD — to_utf8() on a 16-bit impl routes through convert_utf16_to_utf8_append → decode_utf16_with_fffd, so new Worker(src, {argv:['\uD800foo']}) now sees process.argv[2].charCodeAt(0) === 0xFFFD where it was 0xD800 before (the old direct-wrap path preserved code units exactly). REVIEW.md's boundary-representation section calls out WTF-8 for lone surrogates specifically. Exotic trigger, so not blocking; consider storing Box<[u16]> for 16-bit impls (or a WTF-8-preserving encode) as a follow-up.

Extended reasoning...

What the bug is

The new copy_args closure converts each argv/execArgv WTF::StringImpl to owned bytes via WTFStringImplExt::to_utf8(), and the worker later reconstructs a JSString via BunString::borrow_utf8(arg).to_js(). For a 16-bit impl, to_utf8() (src/bun_core/string/wtf.rs:60) calls strings::to_utf8_alloc(self.utf16_slice()) → convert_utf16_to_utf8_append (src/bun_core/lib.rs:1603). When simdutf returns Status::SURROGATE, that falls back to append_wtf8_from_utf16 (line 1590), whose doc comment reads "Unpaired surrogates are replaced with U+FFFD" and whose body calls decode_utf16_with_fffd — returning (0xFFFD, 1) for a lone lead or trail surrogate (lines 1503-1511). The replacement happens before encoding, so the stored bytes are EF BF BD (UTF-8 for U+FFFD), not the WTF-8 ED A0 80 for U+D800. borrow_utf8().to_js() on the worker side then decodes standard UTF-8 and the original code unit is unrecoverable.

Why this is a regression

Before this PR, node_process.rs did BunString::init(wtf).to_js(), which wraps the parent's StringImpl* directly in a worker-heap JSString — the exact code-unit sequence was preserved byte-for-byte. That direct sharing is precisely the atomization bug this PR fixes, so the fidelity loss is a direct consequence of the change, not a pre-existing behavior. Node.js also preserves lone surrogates in worker argv (options are structured-cloned, which round-trips WTF-16 exactly).

Why nothing prevents it

The PR description says "Behavior parity checked … (unicode, empty strings)", but valid Unicode round-trips fine through this path — only unpaired surrogates hit the FFFD replacement branch, and no test in the diff exercises one. REVIEW.md's "Validate representation at every boundary" section explicitly names "WTF-8 helpers for lone surrogates (real Windows paths contain them)" as the class to guard.

Step-by-step proof

  1. Parent runs new Worker(src, { eval: true, argv: ['\uD800foo'] }).
  2. WebWorker::create → copy_args iterates the argv slice; the element is a 16-bit StringImpl with code units [0xD800, 0x66, 0x6F, 0x6F].
  3. to_utf8() sees !is_8bit() → to_utf8_alloc([0xD800, 0x66, 0x6F, 0x6F]).
  4. simdutf returns SURROGATE; append_wtf8_from_utf16 calls decode_utf16_with_fffd([0xD800, ...]) → (0xFFFD, 1), then encode_wtf8_rune writes EF BF BD. Remaining ASCII appends as 66 6F 6F. Stored Box<[u8]> = [0xEF, 0xBF, 0xBD, 0x66, 0x6F, 0x6F].
  5. Worker thread: create_argv → BunString::borrow_utf8(arg).to_js() decodes those bytes as UTF-8 → JS string "\uFFFDfoo".
  6. process.argv[2].charCodeAt(0) yields 0xFFFD; on the previous release it yields 0xD800.

Impact

Narrow: a JS string containing a lone surrogate passed through worker argv/execArgv is silently altered. The realistic case is a Windows path with an unpaired surrogate (real dirents can contain them) forwarded via argv. Exotic enough not to block a fix for a CI-killing memory-safety abort, but it is a genuine Node-compat / data-fidelity regression introduced by this change.

Suggested fix

Either keep 16-bit impls as Box<[u16]> and go through BunString::borrow_utf16() on the worker side, or use a WTF-8-preserving encode (encode the surrogate code point itself, not U+FFFD) paired with a WTF-8-aware to_js path. Both avoid the atomization bug (worker owns the bytes) while preserving code-unit fidelity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant