Skip to content

ai slop - #29363

Closed
robobun wants to merge 3 commits into
mainfrom
farm/e1df800f/worker-impl-uaf
Closed

ai slop#29363
robobun wants to merge 3 commits into
mainfrom
farm/e1df800f/worker-impl-uaf

Conversation

@robobun

@robobun robobun commented Apr 16, 2026

Copy link
Copy Markdown
Collaborator

This PR has been marked as AI slop and the description has been updated to avoid confusion or misleading reviewers.

Many AI PRs are fine, but sometimes they submit a PR too early, fail to test if the problem is real, fail to reproduce the problem, or fail to test that the problem is fixed. If you think this PR is not AI slop, please leave a comment.

…t thread

The Zig WebWorker struct is destroyed on the worker thread in
exitAndDeinit() once the worker's event loop finishes. The C++ Worker
object outlives it and keeps a raw impl_ pointer. If the parent thread
calls worker.ref()/unref()/terminate() while (or after) the worker
thread tears down, WebWorker__setRef / WebWorker__notifyNeedTermination
read from the freed struct.

Guard impl_ with a lock: clear it from WebWorker__dispatchExit (called
on the worker thread right before the Zig struct is freed), and have
setKeepAlive()/terminate() take the same lock and early-return when
impl_ is null. Also clear impl_ when WebWorker__updatePtr fails to spawn
the thread.
@robobun

robobun commented Apr 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:27 PM PT - Apr 15th, 2026

@robobun, your commit 1b946a8 has 2 failures in Build #45879 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29363

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

bun-29363 --bun

@coderabbitai

coderabbitai Bot commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fab2b602-3997-4485-9620-6332658e301f

📥 Commits

Reviewing files that changed from the base of the PR and between d0d6a86 and 1b946a8.

📒 Files selected for processing (1)
  • test/js/web/workers/worker-terminate-race.test.ts

Walkthrough

Adds a lock (m_implLock) to protect Worker::impl_, introduces Worker::clearZigImpl(), and updates Worker methods to acquire the lock and guard against null impl_. Also adds a new subprocess-based test exercising terminate/ref/unref races on workers.

Changes

Cohort / File(s) Summary
Worker synchronization & API
src/bun.js/bindings/webcore/Worker.h, src/bun.js/bindings/webcore/Worker.cpp
Added Lock m_implLock and annotated impl_ as WTF_GUARDED_BY_LOCK(m_implLock). Added void clearZigImpl(). Wrapped reads/writes of impl_ with Locker, added null-impl_ early returns, and updated setKeepAlive(), updatePtr(), create(), and terminate() to synchronize access. Also clear impl_ from the worker thread in WebWorker__dispatchExit.
Tests — worker termination race
test/js/web/workers/worker-terminate-race.test.ts
New test file that runs Bun in a subprocess to exercise races between terminate(), ref(), and unref() using inline data: worker code and asserts clean process exit and no unexpected output.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: preventing use-after-free in Worker when ref/unref/terminate calls race with worker thread exit.
Description check ✅ Passed The PR description fully addresses both required template sections: 'What does this PR do?' explains the ASAN issue and root cause, and 'How did you fix it?' details the locking mechanism and behavioral preservation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/js/web/workers/worker-terminate-race.test.ts`:
- Around line 17-18: Refactor the combined assertion that checks
stdout/stderr/exitCode into the CLAUDE.md house-style: after awaiting stdout,
stderr, and exited into the variables stdout, stderr, exitCode, first assert
stdout is "" (expect(stdout).toBe("")), then add the conditional check if
(exitCode !== 0) { expect(stderr).toBe(""); } to surface stderr on failure, and
finally assert the exit code (expect(exitCode).toBe(0)); update the assertion
block that currently references stdout/stderr/exitCode together to use these
separate checks.
🪄 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: 10675949-abaa-4ed9-9c88-bf59d8d18917

📥 Commits

Reviewing files that changed from the base of the PR and between 942c656 and ed60be1.

📒 Files selected for processing (3)
  • src/bun.js/bindings/webcore/Worker.cpp
  • src/bun.js/bindings/webcore/Worker.h
  • test/js/web/workers/worker-terminate-race.test.ts

Comment thread test/js/web/workers/worker-terminate-race.test.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. ai slop #28227 - Fixes the same dangling impl_ race by deferring WebWorker destruction and adding null checks in terminate()/setKeepAlive()
  2. ai slop #28794 - Same impl_ use-after-free when terminate()/ref()/unref() race worker exit; fixes by extending WebWorker lifetime to C++ destructor
  3. ai slop #28795 - Same root cause (exitAndDeinit frees struct while parent holds impl_); defers destruction to parent thread
  4. fix use-after-free when terminating a worker after it exited #28840 - Identical fix strategy: adds lock on Worker and WebWorker__clearImpl to null impl_ before deinit()
  5. ai slop #28855 - Same bug and same lock-based fix with WebWorker__clearImpl + null check in setKeepAlive/terminate
  6. ai slop #29180 - Subcase of the same bug: double-terminate() dereferences freed impl_
  7. ai slop #29200 - Structurally identical fix: Lock m_implLock in Worker.h, null out impl_ in dispatchExit, guard accessors under the lock

🤖 Generated with Claude Code

@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.

♻️ Duplicate comments (1)
test/js/web/workers/worker-terminate-race.test.ts (1)

17-18: ⚠️ Potential issue | 🟡 Minor

Use house-style split assertions for subprocess results.

Line 18 combines stdout/stderr/exitCode into one assertion, which conflicts with repo test style and makes failure diagnostics less useful. Keep stdout assertion first, conditionally print-check stderr on failure, then assert exit code last.

♻️ Suggested change
-  const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
-  expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: expect.any(String), exitCode: 0 });
+  const [stdout, stderr, exitCode] = await Promise.all([
+    proc.stdout.text(),
+    proc.stderr.text(),
+    proc.exited,
+  ]);
+  expect(stdout).toBe("");
+  if (exitCode !== 0) {
+    expect(stderr).toBe("");
+  }
+  expect(exitCode).toBe(0);

As per coding guidelines, expect(stdout) should come before expect(exitCode), and based on learnings this file follows the if (exitCode !== 0) { expect(stderr).toBe(""); } house style for better failure output.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/web/workers/worker-terminate-race.test.ts` around lines 17 - 18,
Split the combined assertion into the repo's house-style split checks: first
assert stdout is empty using expect(stdout).toBe(""), then if (exitCode !== 0)
assert stderr is empty with expect(stderr).toBe("") to surface stderr only on
failure, and finally assert the exit code with expect(exitCode).toBe(0); apply
these changes around the variables stdout, stderr, exitCode returned from
Promise.all so diagnostics match project style.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@test/js/web/workers/worker-terminate-race.test.ts`:
- Around line 17-18: Split the combined assertion into the repo's house-style
split checks: first assert stdout is empty using expect(stdout).toBe(""), then
if (exitCode !== 0) assert stderr is empty with expect(stderr).toBe("") to
surface stderr only on failure, and finally assert the exit code with
expect(exitCode).toBe(0); apply these changes around the variables stdout,
stderr, exitCode returned from Promise.all so diagnostics match project style.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1f0f628b-6bee-4fc8-a71f-90271dbde6a6

📥 Commits

Reviewing files that changed from the base of the PR and between ed60be1 and d0d6a86.

📒 Files selected for processing (1)
  • test/js/web/workers/worker-terminate-race.test.ts

@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.

This PR fixes a genuine use-after-free with a straightforward mutex-guard pattern, but given it touches C++ cross-thread worker lifecycle management, it warrants a human look — especially to confirm the lock ordering in terminate() (flag set atomically before lock acquired) is intentional and safe.

Extended reasoning...

Overview\nThis PR adds a Lock m_implLock to guard the impl_ pointer in WebCore::Worker, which points to the Zig WebWorker struct that is freed on the worker thread during teardown. Three methods (setKeepAlive, terminate, updatePtr) now hold the lock before dereferencing impl_ and early-return if it is null. WebWorker__dispatchExit calls clearZigImpl() (which locks and nulls the pointer) before dispatching exit and freeing the Zig struct. The create() site also locks before assigning impl_.\n\n### Security risks\nNo security risks. This is a memory-safety fix (use-after-free → use-after-poison in ASAN terms), not a security-sensitive code path.\n\n### Level of scrutiny\nThe pattern is textbook — protect a raw pointer with a mutex, clear it before the pointee is freed, early-return on null — but this is production C++ concurrency code on a critical path (worker thread lifecycle). One detail worth a human eye: in terminate(), m_terminationFlags.fetch_or(TerminateRequestedFlag) is done before acquiring m_implLock. This appears intentional (the description says event semantics are unchanged), but the ordering deserves explicit sign-off.\n\n### Other factors\nThe only bug found is a minor nit: the new test uses stderr: expect.any(String) instead of expect(stderr).toBe(""), which silently accepts non-empty stderr. This is a test quality issue, not a correctness issue with the fix itself. No prior reviews on this PR.

});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: expect.any(String), exitCode: 0 });
}

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.

🟡 The run() helper in worker-terminate-race.test.ts checks stderr with expect.any(String) instead of expect(stderr).toBe(""), violating the convention explicitly documented in test/CLAUDE.md. This silently masks non-fatal warnings or error output during teardown races that don't cause a non-zero exit code.

Extended reasoning...

Bug description: The run() helper at line 19 of worker-terminate-race.test.ts asserts stderr using stderr: expect.any(String), which accepts any string content—including non-empty error output. The documented convention in test/CLAUDE.md (lines 48 and 94) explicitly shows the pattern expect(stderr).toBe("") for subprocess tests.

Specific code path: The assertion object is { stdout: "", stderr: expect.any(String), exitCode: 0 }. The expect.any(String) matcher passes as long as stderr is a string, making it equivalent to asserting nothing about stderr content. All three test cases share this single run() helper, so all three inherit the permissive check.

Why existing code doesn't prevent it: Jest/Bun test's toEqual matcher treats expect.any(String) as a type-only check ("is this a string?"), not a value check. Since stderr is always a string from proc.stderr.text(), the assertion can never fail regardless of what the subprocess prints.

Impact: The tests exist specifically to exercise race conditions during worker thread teardown (ref/unref/terminate while the worker exits). If the fix is incomplete or regresses, the process might print non-fatal diagnostic messages, ASAN symbolization notes, Zig panic-style warnings, or other runtime output to stderr before exiting cleanly with code 0. These would be silently swallowed. ASAN crashes are still caught via exit code, but softer issues are not.

Step-by-step proof: Suppose a regression causes WebWorker__setRef to print "WARNING: potential race detected\n" to stderr before returning normally (exit 0). With expect.any(String), the test passes. With expect(stderr).toBe(""), the test fails and the regression is caught. The empty workers ("data:text/javascript,") produce no console output by design, so there is no valid reason to tolerate non-empty stderr.

Fix: Change line 19 from stderr: expect.any(String) to stderr: "" (or restructure the assertion to use expect(stderr).toBe("") separately), matching the pattern used in neighboring tests like message-port-closed-leak.test.ts:51.

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.

The attempted fix (commit 1b946a8) improves failure diagnostics by splitting the combined assertion, but still does not fully address the original concern. The current code only checks expect(stderr).toBe("") when exitCode \!== 0, which means stderr is still silently ignored on clean exits (exitCode 0).

The original comment's core concern was: if a regression causes a non-fatal warning to be printed to stderr but the process still exits with code 0, the test would still pass silently. That scenario is unchanged.

The fix should unconditionally assert expect(stderr).toBe("") regardless of exit code:

expect(stdout).toBe("");
expect(stderr).toBe("");
expect(exitCode).toBe(0);

The empty data:text/javascript, workers produce no legitimate stderr output, so there is no valid reason to conditionally skip the assertion based on exit code.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The conditional is intentional. Debug/ASAN builds print WARNING: ASAN interferes with JSC signal handlers; useWasmFastMemory and useWasmFaultSignalHandler will be disabled. to stderr on every invocation, so an unconditional expect(stderr).toBe("") would make the test fail on every debug CI run. The exit-code check catches the actual use-after-free (ASAN aborts with non-zero exit), and stderr is surfaced in the failure output when that happens.

Comment on lines 133 to 155

void Worker::setKeepAlive(bool keepAlive)
{
Locker locker { m_implLock };
if (!impl_)
return;
WebWorker__setRef(impl_, keepAlive);
}

void Worker::clearZigImpl()
{
Locker locker { m_implLock };
impl_ = nullptr;
}

bool Worker::updatePtr()
{
Locker locker { m_implLock };
if (!WebWorker__updatePtr(impl_, this)) {
impl_ = nullptr;
m_onlineClosingFlags = ClosingFlag;
m_terminationFlags.fetch_or(TerminatedFlag);
return false;

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.

🟣 Pre-existing ref count leak when Worker thread spawn fails. In Worker::create(), worker->ref() is called unconditionally before checking if impl is null; the matching worker->deref() only fires inside WebWorker__dispatchExit, which is never reached on the two early-failure paths (null impl from WebWorker__create, or thread spawn failure via WebWorker__updatePtr). The Worker object is permanently leaked in both cases. This PR correctly adds impl_=nullptr in the updatePtr() failure path (fixing the use-after-free), but the companion ref count imbalance is left unaddressed.

Extended reasoning...

What the bug is and how it manifests

In Worker::create() (Worker.cpp), worker->ref() is called unconditionally at line 222 with the comment "now referenced by Zig". This extra reference is the Zig-side ownership token that is balanced only by worker->deref() inside WebWorker__dispatchExit (Worker.cpp:493). There are two code paths where WebWorker__dispatchExit is never called, leaving the refcount permanently inflated by 1 and the Worker object unreachable for destruction.

The specific code paths that trigger it

Path A — WebWorker__create returns null: worker->ref() is called unconditionally, raising refcount to 2 (adoptRef starts it at 1). If WebWorker__create() returns null (e.g., a preload module URL fails to resolve), the code executes return Exception { TypeError, ... }. The local Ref destructs, decrementing refcount to 1. No code ever calls deref() again — the Worker leaks.

Path B — WebWorker__updatePtr fails (thread spawn fails): In web_worker.zig, when std.Thread.spawn() fails, the Zig side calls worker.deinit() (line 76) and returns false. The deinit() function only frees Zig-side memory; it does NOT call WebWorker__dispatchExit. Without dispatchExit, worker->deref() is never called. The JS wrapper is eventually GC'd (one deref: refcount 2 to 1), but it never reaches 0.

Why existing code does not prevent it

The ref/deref pattern relies entirely on WebWorker__dispatchExit being the single point of cleanup. There is no guard or RAII wrapper around the worker->ref() call in create() that would ensure a matching deref() on early-exit paths. The two failure paths diverge before a Zig thread is ever started, so the normal exit/deinit lifecycle never runs.

Impact

Every new Worker() call that fails at thread-spawn time permanently leaks a Worker C++ object (and all memory it holds: options, URL strings, pending task queue, etc.). In environments where worker creation can transiently fail (resource limits, preload module errors), this accumulates unboundedly.

How to fix it

For Path A: call worker->deref() before returning the Exception, or move worker->ref() to after the null check so it is only called when a thread is actually starting. For Path B: add worker->deref() in the updatePtr() failure branch in Worker::create(), mirroring what WebWorker__dispatchExit would have done.

Step-by-step proof for Path A

  1. new Worker() is called with a URL whose preload module fails to resolve.
  2. adoptRef(*new Worker(...)) — refcount = 1.
  3. WebWorker__create(...) returns null.
  4. worker->ref() is called — refcount = 2.
  5. if (!impl) is true, return Exception { TypeError, ... }.
  6. The local Ref destructor runs — refcount = 1.
  7. No further owner; Worker::~Worker() is never called. Object is permanently leaked.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed this is a pre-existing leak — leaving it out of this PR to keep the diff focused on the use-after-free.

@github-actions

Copy link
Copy Markdown
Contributor

This PR has been closed because it was flagged as AI slop.

Many AI-generated PRs are fine, but this one was identified as having one or more of the following issues:

  • Fails to verify the problem actually exists
  • Fails to test that the fix works
  • Makes incorrect assumptions about the codebase
  • Submits changes that are incomplete or misleading

If you believe this was done in error, please leave a comment explaining why.

@github-actions github-actions Bot changed the title worker: fix use-after-free when ref/unref/terminate race worker thread exit ai slop Apr 17, 2026
@github-actions github-actions Bot closed this Apr 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants