Skip to content

worker: copy argv/execArgv into worker-local StringImpls - #36393

Closed
robobun wants to merge 5 commits into
mainfrom
claude/df0ef012/worker-argv-cross-thread
Closed

worker: copy argv/execArgv into worker-local StringImpls#36393
robobun wants to merge 5 commits into
mainfrom
claude/df0ef012/worker-argv-cross-thread

Conversation

@robobun

@robobun robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

When a Worker is created with an explicit argv or execArgv option, the strings are stored in the parent thread's WorkerOptions vector and a raw pointer to that storage is handed to the Rust WebWorker. When the worker thread later builds process.argv/process.execArgv, it wrapped each parent-owned StringImpl* directly in a BunString and handed it to JSC via to_js_array. That lets JSC on the worker thread take further refs on a StringImpl that is not thread-safe.

This change copies each entry's bytes into a fresh worker-local StringImpl instead, mirroring the isolatedCopy() that the C++ side already does for options.name in createNodeWorkerThreadsBinding.

Why

On Windows release this reliably crashed with STATUS_STACK_BUFFER_OVERRUN (0xC0000409) in the following sequence, under bun test:

const w1 = new Worker(url, { argv: ["abc"] });  // any string of length >= 2
// worker reads process.argv
w1.terminate();
new wt.Worker(url, {});                         // crash

Single-character argv entries don't crash because JSC's single-char small-string cache returns a singleton and never touches the input StringImpl. Inserting Bun.gc(true) between the two workers also avoids the crash (it collects the first Worker wrapper and its WorkerOptions vector).

In CI this showed up as the Windows parallel test batch reporting whichever unrelated file was inflight on the crashing worker as worker crashed: exit code 9 (0xC0000409 & 0xff). On Windows aarch64 it was almost always test/regression/issue/20875.test.ts; on x64 it rotated between 20875, 09279, 02499, and 08965. The actual trigger is the argv / execArgv options test in test/js/web/workers/worker.test.ts, which runs in the same shard.

Verification

Windows x64 release, worker-argv-cross-thread-fixture.test.ts:

  • before: 10/10 crash (-1073740791)
  • after: 30/30 pass

Windows x64 release, 21-file subset of the CI shard at --parallel=2:

  • before: 20/20 worker crashed: exit code 9
  • after: 50/50 clean

test/js/web/workers/worker.test.ts (26 tests) and the execArgv option suite in test/js/node/worker_threads/ pass on both Linux debug+ASAN and Windows release.


[review] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/workers/worker.test.ts
bun test v1.4.0 (ca970753a)

test/js/web/workers/worker.test.ts:
(pass) web worker > preload > invalid file URL [11.60ms]
(pass) web worker > preload > string [141.48ms]
(pass) web worker > preload > array of 2 strings [155.65ms]
(pass) web worker > preload > array of string [134.06ms]
(pass) web worker > preload > error in preload doesn't crash parent [137.82ms]
(pass) web worker > worker [124.87ms]
(pass) web worker > worker-env [131.51ms]
(pass) web worker > worker-env: SHARE_ENV via the global Worker constructor [915.27ms]
(pass) web worker > worker-env with a lot of properties [328.52ms]
(pass) web worker > argv / execArgv defaults [135.74ms]
(pass) web worker > argv / execArgv options [138.18ms]
(pass) web worker > sending 50 messages should just work [172.62ms]
(pass) web worker > worker with event listeners doesn't close event loop [499.06ms]
(pass) web worker > worker with event listeners doesn't close event loop 2 [502.94ms]
(pass) web worker > worker with process.exit [177.28ms]
(pass) web 
... (truncated)

release without fix: 3 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/web/workers/worker.test.ts:
(pass) web worker > preload > invalid file URL [0.20ms]
(pass) web worker > preload > string [4.05ms]
(pass) web worker > preload > array of 2 strings [4.16ms]
(pass) web worker > preload > array of string [4.07ms]
(pass) web worker > preload > error in preload doesn't crash parent [2.73ms]
(pass) web worker > worker [2.61ms]
(pass) web worker > worker-env [2.44ms]
162 |       ],
163 |       env: bunEnv,
164 |       stderr: "pipe",
165 |     });
166 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
167 |     expect(JSON.parse(stdout)).toEqual({ seen: "from-parent", parentSees: "from-worker" });
                      ^
SyntaxError: JSON Parse error: Unexpected EOF
      at <anonymous> (/workspace/bun/test/js/web/workers/worker.test.ts:167:17)
(fail) web worker > worker-env: SHARE_ENV via the global Worker constructor [20.09ms]
(pass) web worker > worker-env with a lot of properties [4.65ms]
(pass) web worker > argv / execArgv defaults [2.56ms]
(pass) web worker > argv / execArgv options [2.45ms]
(pass) web worker > sending 50 mess
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/workers/worker.test.ts
bun test v1.4.0 (ca970753a)

test/js/web/workers/worker.test.ts:
(pass) web worker > preload > invalid file URL [11.63ms]
(pass) web worker > preload > string [151.03ms]
(pass) web worker > preload > array of 2 strings [142.27ms]
(pass) web worker > preload > array of string [134.34ms]
(pass) web worker > preload > error in preload doesn't crash parent [125.65ms]
(pass) web worker > worker [125.49ms]
(pass) web worker > worker-env [131.13ms]
(pass) web worker > worker-env: SHARE_ENV via the global Worker constructor [908.43ms]
(pass) web worker > worker-env with a lot of properties [321.72ms]
(pass) web worker > argv / execArgv defaults [139.67ms]
(pass) web worker > argv / execArgv options [139.37ms]
(pass) web worker > sending 50 messages should just work [173.09ms]
(pass) web worker > worker with event listeners doesn't close event loop [508.77ms]
(pass) web worker > worker with event listeners doesn't close event loop 2 [502.53ms]
(pass) web worker > worker with process.exit [176.03ms]
(pass) web 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 689ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/22] cxx obj/unified/UnifiedSource-src_jsc_bindings-2.cpp.o
[2/22] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-3.cpp.o
[3/22] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[4/22] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[5/22] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[6/22] cxx obj/unified/UnifiedSource-src_jsc_bindings_node-0.cpp.o
[7/22] cxx obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o
[8/22] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-4.cpp.o
[9/22] cxx obj/unified/UnifiedSource-src_jsc_modules-0.cpp.o
[10/22] cxx obj/src/jsc/bindings/BunObject.cpp.o
[11/22] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[12/22] cxx obj/src/jsc/bindings/bindings.cpp.o
[13/22] cxx obj/src/jsc/bindings/napi.cpp.o
[14/22] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[15/22] gen cpp.rs (cppbind)
[15/22] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
... (truncated)
diff hotspot
src/runtime/node/node_process.rs                   | 30 ++++++++++++++--
 .../workers/worker-argv-cross-thread-fixture.ts    | 40 ++++++++++++++++++++++
 test/js/web/workers/worker.test.ts                 | 18 ++++++++++
 3 files changed, 85 insertions(+), 3 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                     reads  edits  tests
src/runtime/node/node_process.rs                             7      9      0
test/js/web/workers/worker-argv-cross-thread-fixture.ts      0      0      0
test/js/web/workers/worker.test.ts                           4      2      0

When a Worker is created with an explicit argv or execArgv option, the
strings are stored in the parent thread's WorkerOptions vector and a raw
pointer to that storage is handed to the Rust WebWorker. When the worker
thread later builds process.argv/process.execArgv, it wrapped each
parent-owned StringImpl* directly in a BunString and handed it to JSC.
That lets JSC on the worker thread take further refs on a StringImpl
that is not thread-safe, which on Windows release reliably crashed with
STATUS_STACK_BUFFER_OVERRUN once a second worker loading
node:worker_threads was created before the first was GC'd.

Copy each entry's bytes into a fresh worker-local StringImpl instead,
mirroring the isolatedCopy() the C++ side already does for options.name
in createNodeWorkerThreadsBinding.

This showed up in CI as the parallel test batch reporting an unrelated
test file (most often test/regression/issue/20875.test.ts on Windows
aarch64) as 'worker crashed: exit code 9', because the crashing worker
process happened to have that file inflight when the panic fired.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Worker argument safety

Layer / File(s) Summary
Clone worker arguments locally
src/runtime/node/node_process.rs
Worker argv and execArgv entries are cloned into worker-local BunString values, with temporary references released through scope guarding.
Validate sequential worker spawning
test/js/web/workers/worker-argv-cross-thread-fixture.test.ts, test/js/web/workers/worker.test.ts
Tests verify explicit worker arguments and ensure a subsequent worker_threads Worker completes without a crash.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the main fix: copying argv/execArgv into worker-local storage.
Description check ✅ Passed It covers what changed, why it was needed, and how it was verified, though the section headings differ from the template.

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

@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:11 PM PT - Jul 29th, 2026

@robobun, your commit ca97075 has 2 failures in Build #85399 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36393

That installs a local version of the PR into your bun-36393 executable, so you can run:

bun-36393 --bun

Comment thread src/runtime/node/node_process.rs Outdated
Comment thread src/runtime/node/node_process.rs Outdated
Comment thread src/runtime/node/node_process.rs Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/workers/worker-argv-cross-thread-fixture.test.ts`:
- Around line 21-29: Update the Worker promise in the “worker_threads Worker
after the above” test to reject on the worker’s error event or an exit occurring
before a message is received, while preserving successful message resolution.
Move worker termination into a finally block so the worker is terminated on both
success and failure paths.
- Around line 9-18: Update the worker lifecycle in the test around the Worker
instance and message promise: wrap the await and assertions in try/finally,
terminate the worker in finally, and await termination before the test completes
so teardown is guaranteed even when message handling or assertions fail.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 44e28c41-5001-4057-a1c8-acfc19dd02e1

📥 Commits

Reviewing files that changed from the base of the PR and between 6d3f97f and 23480cc.

📒 Files selected for processing (3)
  • src/runtime/node/node_process.rs
  • test/js/web/workers/worker-argv-cross-thread-fixture.test.ts
  • test/js/web/workers/worker.test.ts

Comment thread test/js/web/workers/worker-argv-cross-thread-fixture.test.ts Outdated
Comment thread test/js/web/workers/worker-argv-cross-thread-fixture.ts
Comment thread src/runtime/node/node_process.rs
Comment thread src/runtime/node/node_process.rs

@claude claude Bot left a comment

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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/runtime/node/node_process.rs:210-219 — nit: the unsafe { &*ptr }; if impl_.is_8bit() { clone_latin1(...) } else { clone_utf16(...) } block (and its ~7-line explanatory comment) is duplicated verbatim between create_exec_argv and create_argv. Per REVIEW.md ("The second time a multi-line block appears in your diff, extract a named helper"), consider a small local helper — e.g. fn clone_impl_isolated(impl_: *const WTFStringImpl) -> BunString — carrying the isolated-copy comment once, called from both sites.

    Extended reasoning...

    What

    This diff introduces the same multi-line block twice:

    In create_exec_argv (exec_argv worker branch):

    let impl_ = unsafe { &*wtf };
    let s = if impl_.is_8bit() {
        BunString::clone_latin1(impl_.latin1_slice())
    } else {
        BunString::clone_utf16(impl_.utf16_slice())
    };

    In create_argv (argv worker branch):

    let impl_ = unsafe { &*arg };
    args_list.push(if impl_.is_8bit() {
        BunString::clone_latin1(impl_.latin1_slice())
    } else {
        BunString::clone_utf16(impl_.utf16_slice())
    });

    Additionally, both sites carry a near-identical 7–8 line explanatory comment about the parent-thread StringImpl* cross-thread hazard and the isolatedCopy() analogy.

    Why flag it

    REVIEW.md → Code style & idioms reviewers enforce → "Simplest honest shape; deduplicate within your own diff":

    The second time a multi-line block appears in your diff, extract a named helper and use it at EVERY parallel site.

    This is a direct match: the same 5-line branching block and the same rationale comment appear at two parallel sites introduced by this PR.

    Is there an existing helper?

    I checked whether an in-tree helper already covers this ("grep for the in-tree helper before hand-writing anything"):

    • BunString::to_thread_safe() / BunString__toThreadSafe operates on a &mut BunString that is already tagged WTFStringImpl — constructing that BunString from the raw parent-thread pointer would first require BunString::init(wtf), which refs the parent impl, defeating the whole point of this PR.
    • WTFStringImplStruct (in src/bun_alloc/lib.rs) exposes is_8bit(), latin1_slice(), utf16_slice() but no "clone bytes into a fresh impl without touching the source refcount" method.

    So there is no existing helper; a small local one (or a new method on WTFStringImplStruct, e.g. isolated_clone(&self) -> BunString) is the appropriate dedup.

    Step-by-step

    1. PR adds worker-branch handling in create_exec_argv — introduces the is_8bit → clone_latin1/clone_utf16 block plus the explanatory comment.
    2. PR adds the same handling in create_argv — introduces the identical block plus a near-verbatim copy of the comment.
    3. Two occurrences of the same multi-line block within one diff → REVIEW.md rule applies.
    4. Suggested shape:
      /// Copy a parent-thread StringImpl's bytes into a fresh worker-local impl
      /// without touching the source refcount — the isolatedCopy() equivalent
      /// for a raw `*const WTFStringImpl` we may only read, not ref.
      fn clone_impl_isolated(ptr: *const WTFStringImpl) -> BunString {
          // SAFETY: caller guarantees ptr borrows live parent WorkerOptions storage.
          let impl_ = unsafe { &*ptr };
          if impl_.is_8bit() {
              BunString::clone_latin1(impl_.latin1_slice())
          } else {
              BunString::clone_utf16(impl_.utf16_slice())
          }
      }
      Both call sites then collapse to clone_impl_isolated(wtf) / clone_impl_isolated(arg), and the long cross-thread-hazard comment lives once on the helper.

    Impact / severity

    Purely code organization — no behavioral difference, no correctness issue. Marking nit: not worth blocking merge, but it's a specific pattern the repo's review guide calls out, and consolidating the comment on the helper makes the invariant ("never ref a parent-thread StringImpl from the worker") easier to keep enforced if a third site appears later.

Comment thread test/js/web/workers/worker-argv-cross-thread-fixture.test.ts Outdated
Comment thread test/js/web/workers/worker-argv-cross-thread-fixture.test.ts Outdated

@claude claude Bot left a comment

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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/runtime/server/DirectoryRoute.rs:299-321open_beneath only jails symlink resolution on Linux (openat2_in_root with RESOLVE_IN_ROOT|NO_MAGICLINKS); on macOS/Windows it falls through to plain bun_sys::openat with no O_NOFOLLOW and no post-open realpath containment check, so a symlink inside the served root (e.g. public/etc -> /etc) lets GET /static/etc/passwd escape the root on those platforms while Linux confines it. resolve_subpath's checks are purely lexical on the URL and cannot see filesystem symlinks — REVIEW.md → Security: "lexical containment is defeated by symlinks — re-verify after realpath, prefer O_NOFOLLOW-style atomic flags." (This file also looks unrelated to the worker-argv fix and may have been staged accidentally; if it's removed per the other comment this is moot for this PR, but the gap should be closed before DirectoryRoute lands anywhere.)

    Extended reasoning...

    What the bug is

    DirectoryRoute::open_beneath (src/runtime/server/DirectoryRoute.rs:299-321) is the sole gatekeeper between a URL-derived relative path and the filesystem. Its own doc comment promises "openat2(RESOLVE_IN_ROOT|NO_MAGICLINKS) on Linux, openat elsewhere" — and that is exactly the problem. The Linux/Android branch calls bun_sys::openat2_in_root(root_fd, zrel, flags, 0), which uses RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS so every symlink component is re-rooted under root_fd and cannot escape. Every other platform (macOS, Windows, the BSDs) falls through to plain bun_sys::openat(root_fd, zrel, flags, 0) with O::RDONLY | O::CLOEXEC [| O::NONBLOCK] — no O_NOFOLLOW, no O_NOFOLLOW_ANY (macOS 11+), and no post-open realpath/fcntl(F_GETPATH) check that the resolved path is still under the root.

    Why resolve_subpath doesn't catch it

    resolve_subpath is thorough for what it does — it rejects .., %2F, encoded pchars, empty/. segments, NUL, backslash, and colon — but every one of those checks operates on the URL bytes, before any filesystem call. It has no view of what etc is on disk. A URL of /static/etc/passwd decodes to the relative path etc/passwd, which is two clean non-dot segments and passes every check.

    Step-by-step proof

    Setup on macOS: Bun.serve({ routes: { "/static/*": { dir: "./public" } } }) where ./public contains ln -s /etc etc.

    1. Client sends GET /static/etc/passwd.
    2. resolve_subpath(b"/static/etc/passwd", b"/static/", out) strips the prefix → after_prefix = b"etc/passwd". No %, no .., no empty segments, no NUL/\\/:. Returns Some((10, false)); rel = b"etc/passwd".
    3. open_subpath(b"etc/passwd", false) calls open_beneath(b"etc/passwd").
    4. On macOS the #[cfg(not(any(linux, android)))] branch runs: bun_sys::openat(root_fd, "etc/passwd\0", O_RDONLY|O_CLOEXEC|O_NONBLOCK, 0). The kernel resolves etc relative to root_fd, finds the symlink, follows it to /etc, then opens /etc/passwd. openat only scopes the starting directory — it does not confine symlink targets.
    5. fstat reports a regular file; S::ISREG is true; the response streams /etc/passwd with content-type: application/octet-stream and a 200.

    On Linux the identical request hits openat2_in_root, which resolves the absolute symlink target /etc relative to root_fd (i.e. <public>/etc), so it either loops on itself or fails ENOENT — the client gets a 404. Same input, different security outcome, silently, by platform.

    Why this matters

    REVIEW.md → Security is explicit on both counts:

    • "lexical containment is defeated by symlinks — re-verify after realpath, prefer O_NOFOLLOW-style atomic flags over check-then-act."
    • "Security checks fail closed and cover every path to the protected effect" — a check that only exists on one #[cfg] branch does not cover every path.

    Whether symlink-following is acceptable for a static server is a design choice (nginx and npm send follow by default). What is not acceptable is that the function is named open_beneath, the Linux branch honors that name, and the macOS/Windows branch silently does not. A user who tests containment on Linux and deploys on macOS gets an escape they never observed.

    How to fix

    Pick one policy and enforce it everywhere:

    • Confine (match Linux): on macOS add O_NOFOLLOW_ANY (Big Sur+) to the open flags, or fall back to per-component openat(..., O_NOFOLLOW) walking. On Windows, open with FILE_FLAG_OPEN_REPARSE_POINT and reject reparse points, or after opening call GetFinalPathNameByHandleW and verify the result is still under the root's final path. On BSDs, O_RESOLVE_BENEATH (FreeBSD 13+) is the direct analogue.
    • Follow (match macOS today): drop RESOLVE_IN_ROOT on Linux so all platforms follow, and document that the served directory is trusted. This is the weaker option and contradicts the function name.

    Either way, the platform divergence should be closed before this file lands.

    Note on scope

    DirectoryRoute.rs is a 712-line new file with no connection to the worker-argv StringImpl fix this PR is titled for; it appears to have been staged accidentally (see the separate comment). If the file is removed from this PR, this finding is moot here — but it applies to whatever PR eventually lands DirectoryRoute.

  • 🔴 src/runtime/server/DirectoryRoute.rs:1 — This PR's diff adds a brand-new 712-line src/runtime/server/DirectoryRoute.rs implementing static-directory HTTP serving for Bun.serve, which is entirely unrelated to the worker argv/execArgv thread-safety fix — it isn't mentioned in the title, description, or any commit, isn't wired into src/runtime/server/mod.rs, and has no callers or tests in this PR. This looks like an accidental git add of WIP from another branch; please drop it from this PR and land it separately with its own review and tests.

    Extended reasoning...

    What the issue is

    The PR is a focused ~40-line thread-safety fix: copy worker argv/execArgv StringImpl bytes into worker-local storage instead of handing parent-thread-owned impls to JSC. The diff, however, also introduces a completely unrelated new file:

    diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs
    new file mode 100644
    index 000000000000..79977e1f11ca
    --- /dev/null
    +++ b/src/runtime/server/DirectoryRoute.rs
    @@ -0,0 +1,712 @@
    +//! Serve a directory tree at a URL prefix: `"/static/*": { dir: "./public" }`.
    

    This is a 712-line implementation of static-directory HTTP serving for Bun.serve (a DirectoryRoute type with path-traversal validation, ETag/Last-Modified handling, range requests, a stat cache, and ~120 lines of unit tests). It has zero relationship to worker argv handling.

    Step-by-step proof it's an accidental inclusion

    1. Not mentioned anywhere. PR title: "worker: copy argv/execArgv into worker-local StringImpls". PR description: entirely about the StringImpl cross-thread crash. The three commits on the branch are 1ee738b4 (the fix), 23480cc9 ([autofix.ci]), and 88bf79ae ("review: factor clone into a helper; harden fixture error handling") — none mention DirectoryRoute, Bun.serve, or static file serving.
    2. Not wired into the build. src/runtime/server/mod.rs declares static_route, file_route, file_response_stream, html_bundle, etc. — but has no mod directory_route / mod DirectoryRoute line. grep -r DirectoryRoute src/ returns nothing. Cargo does not compile a .rs file that isn't reachable from a mod declaration, so this file is a dead orphan — it wouldn't even be type-checked by bun bd.
    3. No history. git log --all -- src/runtime/server/DirectoryRoute.rs returns nothing. ls src/runtime/server/ on the checked-out branch tip does not list it. This is consistent with a stale hunk in the GitHub diff (e.g. staged from another worktree/branch and later force-pushed away, or a mis-staged file that never landed in the branch's commit graph but is still in GitHub's cached compare).
    4. No tests or callers in this PR. Nothing in test/ references it; ServerConfig.rs doesn't parse a { dir: ... } route option; no other file in the diff touches it.

    Why this should block

    Even though the file is currently unreachable from mod.rs (so it wouldn't run), merging it still lands 712 lines of unreviewed feature code in-tree under a PR whose review record says "worker argv StringImpl fix". The next PR that wires it into mod.rs will look like a one-line change against "already-merged" code, bypassing the review a new HTTP static-file server (path-traversal validation, symlink handling, RFC 3986 parsing, security-sensitive by REVIEW.md's own §Security) actually needs.

    REVIEW.md is explicit on both counts:

    • "don't ride file-wide standardization on a focused bugfix" — this rides a whole new subsystem on one.
    • "New cross-cutting abstractions need maintainer agreement before appearing inside a feature PR."

    Fix

    Remove src/runtime/server/DirectoryRoute.rs from this PR (git rm / drop the hunk) and open it as its own PR with the mod.rs wiring, ServerConfig option parsing, and test/js/bun/http/ integration tests it needs. The rest of this PR (the node_process.rs change + worker fixture) is self-contained and unaffected.

@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

CI build 85399: the diff is green for what this PR fixes. Neither test/regression/issue/20875.test.ts nor test/js/web/workers/worker.test.ts appear in the Windows parallel-batch failures anymore.

The two hard failures are pre-existing main flakes currently hitting many unrelated branches:

  • worker-transfer-terminate-stress.test.ts (x64-asan): the test's own header comment documents it as an intermittent x64-asan SIGABRT in JSC's ExceptionScope during MessagePort transfer; seen on builds 85400, 85383, 85367, 85359, 85356, 85354 across unrelated branches. The test creates workers with { eval: true } (no argv/execArgv), so the code path this PR changes is never reached.
  • proxy-stress-protocol.test.ts (x64-asan): ECONNRESET in an HTTP proxy stress test; seen on builds 85393, 85387, 85386, 85383, 85381, 85380, 85378, 85373, 85348, 85342, 85340, 85335.

Everything else passed on retry alone ([flaky]).

@claude claude Bot left a comment

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.

Both prior findings (fixture .test.ts discovery, missing error/exit reject wiring) are addressed. The refcount handling checks out — deref() is a no-op for non-WTFStringImpl tags so the new scopeguard in create_argv is safe on the borrow_utf8/static_ entries, and the exec_argv closure derefs unconditionally so the error path doesn't leak. Deferring only because this is cross-thread StringImpl lifetime in the native runtime; worth a maintainer glance at clone_parent_worker_option_string.

What was reviewed:

  • clone_latin1/clone_utf16 return a fresh worker-local impl at +1; to_js refs, caller derefs — matches the existing standalone-graph branch pattern in the same function.
  • Reading is_8bit()/latin1_slice()/utf16_slice() off the parent StringImpl* touches only construction-time-immutable fields (flag bit + payload ptr/len), not the refcount.
  • Fixture rename keeps it out of isTestStrict discovery; wrapper spawns it by explicit path.
Extended reasoning...

Overview

Targeted thread-safety fix in src/runtime/node/node_process.rs: when a worker builds process.argv/process.execArgv from parent-supplied options, it now copies each entry's bytes into a worker-local WTFStringImpl (via BunString::clone_latin1/clone_utf16) instead of wrapping the parent thread's StringImpl* directly. A helper clone_parent_worker_option_string was factored out and applied at both call sites (argv and execArgv). A scopeguard was added to create_argv to release the +1 from the clones. New subprocess-driven fixture + wrapper test in worker.test.ts.

Security risks

None. No user-controlled parsing, auth, or trust-boundary changes — this is internal string ownership across a worker thread boundary.

Level of scrutiny

High — this is native memory-safety code in the cross-thread StringImpl category REVIEW.md flags as most-blocked. I verified: (1) String::deref() is gated on Tag::WTFStringImpl so the scopeguard is a no-op on the ZigString/Static entries pushed by the non-worker branches (src/bun_core/string/mod.rs:512); (2) the exec_argv closure stores to_js()'s JsResult before calling deref(), so the +1 is released even when to_js throws; (3) the clone pattern mirrors the existing scopeguard+clone_utf8+to_js_array sequence already present in the standalone-graph branch of create_exec_argv; (4) latin1_slice/utf16_slice on WTFStringImplStruct read only m_hash_and_flags (8-bit flag set at construction) and m_ptr/m_length — all immutable after construction, so the cross-thread read doesn't race the parent's refcount.

Other factors

My two prior findings and CodeRabbit's were addressed in 88bf79a/c63aedfa: the fixture was renamed to drop .test. so CI's isTestStrict no longer discovers it, both fixture tests now wire error/exit to reject and terminate in finally, and the clone was factored into a named helper. The comment-cop bot flags on the helper doc-comment were pushed back on reasonably (it documents the thread-safety invariant, not a workaround). The two robobun CI failures (worker-transfer-terminate-stress, proxy-stress-protocol) are on x64-asan and unrelated to this diff. Deferring rather than approving because cross-thread WTF string handling is exactly the area where a maintainer with JSC context should confirm the reasoning.

@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

This bug was independently root-caused in #37140, now closed as a duplicate of this PR. Two findings from that investigation that may help land this one:

  1. The Windows parallel-lane worker crashes that have been recurring since Aug 4 under a different blame (test/js/node/zlib/zlib-estimated-size-gc.test.ts, builds 88721 through 89988, both the 2019 x64 and 11 aarch64 lanes) are this same argv/execArgv cross-thread AtomString abort. The blamed file moved from the 20875-era files in this PR's description because duration-balanced sharding reshuffled the parallel buckets; the trigger is still the worker argv/execArgv tests running earlier in the same worker process. A debug build of the reconstructed shard bucket aborts at AtomStringImpl::remove ("The string being removed is an atom in the string table of an other thread!") with ~WorkerOptions on the stack. So this PR also fixes the currently hottest Windows CI flake.

  2. A deterministic regression test now exists for the Linux debug+ASAN build, where this PR's fixture passes even without the fix. Recipe: pin the fixture process to one CPU (taskset), put the exiting worker thread into SCHED_IDLE (chrt -i -p 0 <tid>, tid located via /proc/self/task/*/comm and the worker name), then run Bun.gc(true) on the first microtask after the exit event. The worker thread is starved between posting its exit and destroying its thread-local atom table, so the parent's sweep reliably performs the final deref: 8/8 aborts without a fix, clean with it. The test is in commit 78e2f60 on branch farm/ed6bb19f/worker-argv-cross-thread-atomstring (test/js/node/worker_threads/worker_threads.test.ts), free to cherry-pick. That branch also moved the copy to WebWorker__create so the raw argv_ptr/exec_argv_ptr sharing disappears entirely and the downstream consumers simplify; the node_process.rs copy here is sufficient to fix the crash either way, and its clone_latin1/clone_utf16 preserves the exact encoding.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #37075, which landed the same fix: worker_option_string in src/runtime/node/node_process.rs copies each parent-owned argv/execArgv StringImpl into a worker-local one, and both create_argv and create_exec_argv go through it. It also added a dedicated test ("worker argv/execArgv option strings, read repeatedly in the worker" in test/js/node/worker_threads/worker_threads.test.ts).

The original symptom this PR was chasing (test/regression/issue/20875.test.ts blamed for worker crashed: exit code 9 in the Windows parallel batch) has not appeared in any of the last 60 main builds since #37075 merged.

Closing.

@robobun robobun closed this Aug 16, 2026
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.

2 participants