Skip to content
Closed

ai slop #29322

Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 7 additions & 1 deletion src/bun.js/bindings/webcore/Worker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ WTF_MAKE_TZONE_ALLOCATED_IMPL(Worker);

extern "C" void WebWorker__notifyNeedTermination(
void* worker);
extern "C" void WebWorker__destroy(
void* worker);

static Lock allWorkersLock;
static HashMap<ScriptExecutionContextIdentifier, Worker*>& allWorkers() WTF_REQUIRES_LOCK(allWorkersLock)
Expand Down Expand Up @@ -228,6 +230,9 @@ Worker::~Worker()
Locker locker { allWorkersLock };
allWorkers().remove(m_clientIdentifier);
}
if (impl_) {
WebWorker__destroy(impl_);
}
// m_contextProxy.workerObjectDestroyed();
}

Expand Down Expand Up @@ -262,7 +267,8 @@ ExceptionOr<void> Worker::postMessage(JSC::JSGlobalObject& state, JSC::JSValue m
void Worker::terminate()
{
// m_contextProxy.terminateWorkerGlobalScope();
m_terminationFlags.fetch_or(TerminateRequestedFlag);
if (m_terminationFlags.fetch_or(TerminateRequestedFlag))
return;
WebWorker__notifyNeedTermination(impl_);
}

Expand Down
16 changes: 14 additions & 2 deletions src/bun.js/web_worker.zig
Original file line number Diff line number Diff line change
Expand Up @@ -402,16 +402,24 @@

/// Deinit will clean up vm and everything.
/// Early deinit may be called from caller thread, but full vm deinit will only be called within worker's thread.
/// The struct itself is freed separately by the owning C++ `Worker` via `WebWorker__destroy` so
/// that `WebWorker__notifyNeedTermination` never touches freed memory while JS still holds the
/// wrapper.
fn deinit(this: *WebWorker) void {
log("[{d}] deinit", .{this.execution_context_id});
this.parent_poll_ref.unrefConcurrently(this.parent);
bun.default_allocator.free(this.unresolved_specifier);
this.unresolved_specifier = "";
for (this.preloads) |preload| {
bun.default_allocator.free(preload);
}
bun.default_allocator.free(this.preloads);
this.preloads = &.{};
}
Comment on lines 408 to +418

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 name field is heap-allocated in create() when a non-empty name is provided, but deinit() never frees it, leaking the string for every named worker. This is a pre-existing issue, but the PR explicitly restructures deinit() as the canonical resource-release function (adding idempotency guards for unresolved_specifier and preloads), making the omission of name a conspicuous gap worth fixing at this point.

Extended reasoning...

What the bug is: In WebWorker.create(), the name field is conditionally heap-allocated using std.fmt.allocPrintSentinel(bun.default_allocator, ...) when name_str is non-empty, producing a [:0]const u8 slice owned by the default allocator. However, deinit() — now the canonical resource-release function per this PR — never calls bun.default_allocator.free(this.name). Similarly, WebWorker__destroy only calls bun.default_allocator.destroy(this), which frees the struct allocation itself but leaves the bytes pointed to by the name field unreleased.

The specific code path: In create() (web_worker.zig ~line 246):

.name = brk: {
    if (\!name_str.isEmpty()) {
        break :brk bun.handleOom(std.fmt.allocPrintSentinel(bun.default_allocator, "{f}", .{name_str}, 0));
    }
    break :brk "";
},

The non-empty branch produces a heap allocation. In deinit() (lines 408–418), the function frees unresolved_specifier and each preloads entry (with idempotency zero-guards), but has no corresponding bun.default_allocator.free(this.name).

Why existing code does not prevent it: WebWorker__destroy is the sole remaining deallocation site (called from ~Worker()) and only issues a struct-level destroy, not a field-level free of the name slice. There is no other caller of bun.default_allocator.free on this.name anywhere in web_worker.zig.

Impact: Every new Worker(url, { name: "something" }) call leaks the name string allocation for the lifetime of the process. In long-running servers or test suites that spawn many named workers, this accumulates unboundedly.

How to fix it: Add a free in deinit() guarded to avoid freeing the static "" literal used for unnamed workers. Since only non-empty name_str produces a heap allocation, checking this.name.len > 0 is sufficient:

if (this.name.len > 0) {
    bun.default_allocator.free(this.name);
    this.name = "";
}

A more robust alternative is a dedicated name_is_owned: bool field.

Step-by-step proof:

  1. new Worker("./worker.js", { name: "my-worker" }) calls Worker::createWebWorker__createWebWorker.create().
  2. name_str is non-empty, so std.fmt.allocPrintSentinel(bun.default_allocator, ...) allocates N+1 bytes on the heap. this.name now points to that allocation.
  3. The worker thread eventually calls exitAndDeinit()this.deinit(). deinit() frees unresolved_specifier and preloads, but returns without touching this.name.
  4. Later, ~Worker() calls WebWorker__destroy(impl_)bun.default_allocator.destroy(this). This frees the WebWorker struct bytes but the name string bytes remain allocated and unreachable — a confirmed leak.


export fn WebWorker__destroy(this: *WebWorker) void {
bun.default_allocator.destroy(this);
}

Check failure on line 422 in src/bun.js/web_worker.zig

View check run for this annotation

Claude / Claude Code Review

WebWorker struct leaks permanently when thread spawn fails (destroy removed from deinit catch path)

The thread-spawn failure path in `WebWorker__updatePtr` now leaks the `WebWorker` struct permanently. Before this PR, `deinit()` ended with `bun.default_allocator.destroy(this)`, so the struct was freed in the catch block when `std.Thread.spawn` failed; after this PR, `destroy` was moved exclusively to `WebWorker__destroy`, which is only reachable via `~Worker()`, but `~Worker()` requires the C++ ref count to reach 0 — and the Zig-held ref (bumped by `worker->ref()` in `Worker::create()`) is onl
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +418 to 422

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 thread-spawn failure path in WebWorker__updatePtr now leaks the WebWorker struct permanently. Before this PR, deinit() ended with bun.default_allocator.destroy(this), so the struct was freed in the catch block when std.Thread.spawn failed; after this PR, destroy was moved exclusively to WebWorker__destroy, which is only reachable via ~Worker(), but ~Worker() requires the C++ ref count to reach 0 — and the Zig-held ref (bumped by worker->ref() in Worker::create()) is only ever released inside WebWorker__dispatchExit, which is unreachable when the thread never starts. The fix is to call bun.default_allocator.destroy(worker) directly in the catch block after worker.deinit(), or have Worker::updatePtr() call worker->deref() when WebWorker__updatePtr returns false.

Extended reasoning...

What the bug is and how it manifests

In WebWorker__updatePtr (web_worker.zig lines 68–78), when std.Thread.spawn fails, the catch block calls worker.deinit() and returns false. Before this PR, deinit() ended with bun.default_allocator.destroy(this), so the WebWorker struct was freed immediately on the spawn-failure path. This PR moved the struct-free into the new exported function WebWorker__destroy (lines 420–422), which is called only from ~Worker() in C++.

The specific code path that triggers it

~Worker() is only invoked when the C++ Worker ref count reaches zero. In Worker::create(), after WebWorker__create succeeds, the code calls worker->ref() with the comment "now referenced by Zig". This Zig-held ref is only ever released inside WebWorker__dispatchExit via worker->deref(). WebWorker__dispatchExit is only called from exitAndDeinit(), which runs at the end of the worker thread's lifetime. If std.Thread.spawn fails, the worker thread never starts, exitAndDeinit() is never called, WebWorker__dispatchExit is never called, and the Zig ref stays permanently at 1.

Why existing code doesn't prevent it

Worker::updatePtr() in C++ sets TerminatedFlag when WebWorker__updatePtr returns false, but does NOT call worker->deref(). Without that deref, the C++ Worker ref count never drops to zero (even after the JS Worker object is GC'd and drops its own ref), so ~Worker() never runs and WebWorker__destroy is never called.

What the impact is

Any OOM condition or OS thread-limit hit during new Worker(url) construction causes both the C++ Worker and the Zig WebWorker struct to leak permanently. This is strictly worse than before the PR: previously the C++ Worker leaked (pre-existing) but the Zig struct was freed; now both leak.

How to fix it

Option A (minimal, restores old behavior for this path): in the WebWorker__updatePtr catch block, after worker.deinit(), add bun.default_allocator.destroy(worker).

Option B (cleaner): in Worker::updatePtr() (Worker.cpp line 141–148), after WebWorker__updatePtr returns false, call worker->deref() (and optionally null impl_ to prevent the double-destroy in ~Worker()).

Step-by-step proof

  1. Worker::create() succeeds; calls worker->ref() — Zig ref = 1, JS ref = 1.
  2. Worker::updatePtr() calls WebWorker__updatePtr(impl_, this).
  3. std.Thread.spawn fails (OOM or thread limit).
  4. Catch block: worker.deinit() frees unresolved_specifier and preloads, unrefs the parent poll. Does NOT call bun.default_allocator.destroy(this).
  5. WebWorker__updatePtr returns false; Worker::updatePtr() sets TerminatedFlag and returns false. No worker->deref() call.
  6. JS throws an exception; JS Worker object eventually GC'd, dropping the JS ref. Zig ref (=1) remains.
  7. ~Worker() requires ref count == 0. Since Zig ref = 1, ~Worker() never runs. WebWorker__destroy never called. The WebWorker struct leaks indefinitely.


fn flushLogs(this: *WebWorker) void {
jsc.markBinding(@src());
Expand Down Expand Up @@ -644,7 +652,13 @@
}
var arena = this.arena;

// Release owned resources before dispatching exit. `WebWorker__dispatchExit` drops the
// Zig-held ref on the C++ `Worker`; once the parent processes the close task and GC
// collects the wrapper, `~Worker()` will free this struct, so it must not be touched
// afterwards.
this.deinit();

WebWorker__dispatchExit(globalObject, cpp_worker, exit_code);

Check failure on line 661 in src/bun.js/web_worker.zig

View check run for this annotation

Claude / Claude Code Review

setRef() can re-activate parent_poll_ref after deinit() on natural worker exit

After this PR's reordering, `deinit()` runs before `WebWorker__dispatchExit`, so `parent_poll_ref.unrefConcurrently()` fires before the close event reaches the parent thread; if a close event handler then calls `worker.ref()`, `setRef()` passes its only guard (`hasRequestedTerminate()`, which is never set on the natural-exit path) and re-activates `parent_poll_ref`, a ref that is never balanced — `WebWorker__destroy` only frees the struct — causing the parent process to hang indefinitely. Fix: a
Comment on lines +657 to 661

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.

🔴 After this PR's reordering, deinit() runs before WebWorker__dispatchExit, so parent_poll_ref.unrefConcurrently() fires before the close event reaches the parent thread; if a close event handler then calls worker.ref(), setRef() passes its only guard (hasRequestedTerminate(), which is never set on the natural-exit path) and re-activates parent_poll_ref, a ref that is never balanced — WebWorker__destroy only frees the struct — causing the parent process to hang indefinitely. Fix: add a this.status.load(.acquire) == .terminated check in setRef(), mirroring the guard already present in notifyNeedTermination().

Extended reasoning...

What the bug is and how it manifests

When a worker's event loop goes idle without an explicit terminate() call (the natural exit path), requested_terminate is never set. This PR moves this.deinit() to run before WebWorker__dispatchExit in exitAndDeinit(). deinit() calls parent_poll_ref.unrefConcurrently(this.parent), which transitions the KeepAlive status from .active.inactive. WebWorker__dispatchExit then posts the close task to the parent thread asynchronously.

The specific code path that triggers it

When the close task runs on the parent thread and JS fires the close event, a handler that calls worker.ref() (a valid node:worker_threads API) invokes setRef(this, true). The only guard in setRef() (web_worker.zig ~line 588) is hasRequestedTerminate(), which reads the requested_terminate atomic — never set in the natural-exit path. The guard passes, so setRefInternal(true) calls parent_poll_ref.ref(parent). Since KeepAlive.status is now .inactive, KeepAlive.ref() does not no-op; it transitions status back to .active and increments the parent event loop's ref count.

Why existing code doesn't prevent it

notifyNeedTermination() (web_worker.zig ~line 612) already guards on this.status.load(.acquire) == .terminated, which IS set at the top of exitAndDeinit() before deinit() is called. But setRef() has no such status check — only the requested_terminate check, which is insufficient for the natural-exit path. In the old code order, deinit() ran after dispatchExit, so any worker.ref() call in the close handler would see KeepAlive.status == .active and KeepAlive.ref() would no-op; deinit() would then unref and balance it. After this PR the unref always precedes the close event, making the subsequent ref() permanently unbalanced.

Impact

WebWorker__destroy (called from ~Worker()) only calls bun.default_allocator.destroy(this) — it performs no KeepAlive cleanup. The event loop ref is permanently leaked. The parent process never exits, hanging indefinitely. This only triggers when a worker.ref() call is made inside a close event handler on a naturally-exited worker without a matching worker.unref(), but that is entirely valid API usage.

How to fix it

In setRef(), add a status check before calling setRefInternal:

pub fn setRef(this: *WebWorker, value: bool) callconv(.c) void {
    if (this.hasRequestedTerminate()) return;
    if (this.status.load(.acquire) == .terminated) return; // <-- add this
    this.setRefInternal(value);
}

this.status is set to .terminated at the top of exitAndDeinit(), before deinit() is called, so this guard is always true by the time parent_poll_ref.unrefConcurrently() fires.

Step-by-step proof

  1. Worker runs normally; event loop drains; spin() reaches exitAndDeinit().
  2. exitAndDeinit() sets this.status = .terminated and requested_terminate is still false.
  3. this.deinit() calls parent_poll_ref.unrefConcurrently(parent); KeepAlive.status → .inactive.
  4. WebWorker__dispatchExit posts the close task to the parent thread.
  5. Parent thread fires the close event; JS handler calls worker.ref().
  6. setRef(this, true) checks hasRequestedTerminate() == false — guard passes.
  7. setRefInternal(true) calls parent_poll_ref.ref(parent); KeepAlive.status → .active, ref count +1.
  8. Worker JS wrapper is eventually GC'd; ~Worker() calls WebWorker__destroybun.default_allocator.destroy(this). No unref.
  9. Parent event loop ref count is permanently +1; parent process hangs forever.

if (loop) |loop_| {
loop_.internal_loop_data.jsc_vm = null;
}
Expand All @@ -659,8 +673,6 @@
bun.windows.libuv.Loop.shutdown();
}

this.deinit();

if (vm_to_deinit) |vm| {
vm.deinit(); // NOTE: deinit here isn't implemented, so freeing workers will leak the vm.
}
Expand Down
47 changes: 47 additions & 0 deletions test/js/web/workers/worker-terminate-after-exit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, isDebug } from "harness";

// The Zig `WebWorker` struct used to be destroyed on the worker thread in
// `exitAndDeinit`, while `Worker::terminate()` on the main thread could still
// call `WebWorker__notifyNeedTermination(impl_)` afterwards, reading freed
// memory. Calling `terminate()` immediately sets `requested_terminate`, so the
// worker thread takes the fast early-exit path (before its VM is created) and
// the struct was freed right after the close event was posted. Calling
// `terminate()` again from the close handler then touched the freed struct.
test(
"Worker.terminate() after the worker thread has exited does not use freed memory",
async () => {
const code = `
for (let i = 0; i < 10; i++) {
const w = new Worker("nonexistent-entrypoint-58146");
const { promise, resolve } = Promise.withResolvers();
w.addEventListener("close", resolve);
w.addEventListener("error", () => {});
w.terminate();
await promise;
w.terminate();
}
Bun.gc(true);
`;
const concurrency = 5;
for (let batch = 0; batch < 4; batch++) {
const runs = Array.from({ length: concurrency }, async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
return { stderr, exitCode };
});
for (const { stderr, exitCode } of await Promise.all(runs)) {
if (exitCode !== 0) {
expect(stderr).toBe("");
}
Comment on lines +39 to +41

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 assertion at lines 39-41 is semantically inverted: when the process exits with a non-zero code (e.g., ASAN abort with exit code 134), stderr holds the crash trace, so expect(stderr).toBe("") fires first with the misleading message "Expected empty stderr" rather than surfacing the actual failure (non-zero exit code). The test still catches the use-after-free (no false negatives), but failure diagnostics are backwards. The fix is expect(exitCode, stderr: ${stderr}).toBe(0), which makes the exit code the assertion and the crash output the context message.

Extended reasoning...

What the bug is and how it manifests

In the new test file worker-terminate-after-exit.test.ts, lines 39-41 read:

if (exitCode \!== 0) {
  expect(stderr).toBe("");
}
expect(exitCode).toBe(0);

When the worker process crashes (e.g., due to ASAN detecting a use-after-poison with exit code 134), stderr contains the full ASAN trace. Because expect(stderr).toBe("") appears first inside the if (exitCode \!== 0) block, it fires before expect(exitCode).toBe(0) is ever reached, producing a misleading failure message: 'Expected "" but received "[ASAN trace]"'.

The specific code path that triggers it

A developer running the test against an unpatched ASAN build sees the test runner report a failure about unexpected stderr content rather than a clear 'process crashed with exit code 134' message. The intent appears to have been to surface stderr as diagnostic context when the process fails — but it is expressed as an assertion rather than a message parameter.

Why existing code doesn't prevent it

The test framework evaluates assertions eagerly and stops at the first failure. Since the stderr assertion is placed before the exit-code assertion inside the conditional block, it wins the race to produce the failure output whenever exitCode is non-zero.

Impact

No false passes are produced — every crash scenario is caught. However, a developer debugging a failure sees 'stderr should be empty' and may spend time investigating spurious output rather than immediately identifying the root cause as a use-after-free crash.

How to fix it

Replace the inverted assertion block with:

expect(exitCode, `stderr: ${stderr}`).toBe(0);

This makes the exit code the subject of the assertion (so the failure message clearly states 'Expected 0, received 134') and attaches the ASAN trace as context, which is exactly what a developer needs when diagnosing a crash.

Step-by-step proof

  1. Run test against unpatched ASAN build; worker crashes: exitCode = 134, stderr = '==ERROR: AddressSanitizer...'
  2. if (exitCode \!== 0) is true, so expect(stderr).toBe("") executes
  3. Assertion fails: 'Expected "" but received "==ERROR: AddressSanitizer..."'
  4. Test runner stops; expect(exitCode).toBe(0) is never reached
  5. Developer reads: 'Expected empty stderr' — misleading; the real issue is the crash, not the output

expect(exitCode).toBe(0);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
},
isDebug ? 60_000 : undefined,
);
Loading