Skip to content

ai slop - #29322

Closed
robobun wants to merge 2 commits into
mainfrom
farm/147c495c/worker-terminate-uaf
Closed

ai slop#29322
robobun wants to merge 2 commits into
mainfrom
farm/147c495c/worker-terminate-uaf

Conversation

@robobun

@robobun robobun commented Apr 15, 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.

…r thread exits

The Zig WebWorker struct was destroyed on the worker thread inside
exitAndDeinit(), but the owning C++ Worker kept a raw pointer to it in
impl_. A subsequent Worker.terminate() from JS on the parent thread would
call WebWorker__notifyNeedTermination(impl_) and read the freed struct
(status, requested_terminate, vm, parent_poll_ref), tripping ASAN with
use-after-poison.

Tie the struct's lifetime to the C++ Worker instead: deinit() now only
releases owned resources (specifier, preloads, parent poll ref) and the
allocation itself is freed from ~Worker() via a new WebWorker__destroy
export. The worker thread now runs deinit() before WebWorker__dispatchExit
drops the Zig-held ref on the C++ Worker so the struct is never touched
after it can be freed. Worker::terminate() also short-circuits once any
termination flag is set, avoiding a redundant cross-thread poke.
@robobun

robobun commented Apr 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:14 PM PT - Apr 14th, 2026

@autofix-ci[bot], your commit c934eb3 has 4 failures in Build #45736 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29322

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

bun-29322 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Comlink segfaults during normal operation #23194 - Comlink segfaults during normal Worker/MessagePort operation; the crash pattern (dereferencing near-null address during postMessage after worker has been running) is consistent with the use-after-free race this PR fixes where the Zig WebWorker is freed on the worker thread while the parent still holds a dangling pointer

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #23194

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Apr 15, 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: 34c61f43-be9a-4625-a703-9e4e6d3bb81a

📥 Commits

Reviewing files that changed from the base of the PR and between 186d8d4 and c934eb3.

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

Walkthrough

Added explicit Zig/C destroy for WebWorker and adjusted teardown ordering; ensured C++ Worker destructor calls the Zig destroy when present; made Worker::terminate() idempotent to avoid duplicate termination notifications; added a test that calls terminate() twice after worker exit to validate behavior.

Changes

Cohort / File(s) Summary
C++ Worker wrapper
src/bun.js/bindings/webcore/Worker.cpp
Declared extern "C" void WebWorker__destroy(void* worker);. Worker::~Worker() now calls WebWorker__destroy(impl_) when impl_ is non-null. Worker::terminate() was changed to atomically set termination flag and return early if already set, avoiding duplicate WebWorker__notifyNeedTermination calls.
Zig WebWorker implementation
src/bun.js/web_worker.zig
Added exported WebWorker__destroy(this: *WebWorker) that frees the WebWorker. Reordered teardown: deinit() now clears unresolved_specifier and preloads and is invoked before dispatching exit; removed the later deinit() call so Zig-owned resources are released before C++ drops its reference.
Tests
test/js/web/workers/worker-terminate-after-exit.test.ts
New test that spawns multiple Bun processes running a script which creates a Worker with a missing entrypoint, registers close/error handlers, calls terminate() twice (once immediately and once after close), forces GC, and asserts each child exits with code 0 and no stderr.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main fix: addressing a use-after-free issue in Worker.terminate() when called after the worker thread exits, which aligns perfectly with the changeset's objectives and file modifications.
Description check ✅ Passed The description fully covers both required template sections with comprehensive detail: 'What does this PR do?' explains the use-after-free issue and root cause, and 'How did you verify your code works?' describes the test added and validation results.

✏️ 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: 2

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

Inline comments:
In `@src/bun.js/web_worker.zig`:
- Around line 405-422: Add idempotent guards in WebWorker.deinit to avoid
double-free: check unresolved_specifier.len > 0 before calling
bun.default_allocator.free(this.unresolved_specifier) and set
this.unresolved_specifier = "" after freeing; similarly iterate preloads only if
this.preloads.len > 0, free each entry and free this.preloads only when len > 0,
then reset this.preloads = &.{}; keep WebWorker__destroy unchanged. Ensure
references are to deinit, unresolved_specifier, preloads, and WebWorker__destroy
so future calls from exitAndDeinit remain safe.

In `@test/js/web/workers/worker-terminate-after-exit.test.ts`:
- Around line 38-43: Replace the current indirect stderr assertion with an
explicit failure message when a run fails: inside the for loop over runs, check
if exitCode !== 0 and if so throw or call fail with a clear message like `Worker
failed: exitCode ${exitCode}, stderr: ${stderr}` (referencing the variables
stderr, exitCode and the runs iterator), then assert expect(exitCode).toBe(0) as
the normal success check; this will surface stderr directly on failure instead
of producing the confusing empty-string assertion.
🪄 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: 6e0078b3-1c64-4742-b534-6715c846e7d8

📥 Commits

Reviewing files that changed from the base of the PR and between 9e5cb3d and 186d8d4.

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

Comment thread src/bun.js/web_worker.zig
Comment thread test/js/web/workers/worker-terminate-after-exit.test.ts
@robobun

robobun commented Apr 15, 2026

Copy link
Copy Markdown
Collaborator Author

Not linking #23194 — that crash is in MessagePort::postMessage (null m_entangledChannel), which this change doesn't touch. This fix is specific to Worker::terminate() dereferencing the Zig WebWorker struct after the worker thread has freed it.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. ai slop #28227 - Fixes the same WebWorker use-after-free by deferring struct destruction to the parent thread and guarding impl_ in terminate()/setKeepAlive()
  2. ai slop #28794 - Extends WebWorker lifetime to match the C++ Worker via WebWorker__deinit in ~Worker() — closest structural match to this PR's approach
  3. ai slop #28795 - Defers WebWorker struct free to parent thread via partialDeinit to prevent the same dangling impl_ dereference
  4. fix use-after-free when terminating a worker after it exited #28840 - Adds a lock on impl_ and nulls it via WebWorker__clearImpl before deinit() to prevent the same terminate() use-after-free
  5. ai slop #28855 - Prevents the same use-after-free by intentionally not freeing the WebWorker struct after exitAndDeinit
  6. ai slop #29180 - Adds early-return in Worker::terminate() when termination flags are already set to prevent impl_ dereference
  7. ai slop #29200 - Guards impl_ with a lock and nulls it in dispatchExit before struct destruction (bundled with an unrelated mock fix)

🤖 Generated with Claude Code

@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 real use-after-free in the worker lifecycle and the approach is sound, but the change is non-trivial (cross-thread C++/Zig lifetime management) and merits human review — particularly the interaction between the new WebWorker__destroy destructor path and the pre-existing name field leak that deinit() still doesn't address.

Extended reasoning...

Overview

The PR restructures WebWorker struct lifetime to fix a use-after-free: previously exitAndDeinit() destroyed the struct on the worker thread while the C++ Worker kept a raw impl_ pointer reachable from terminate() on the parent thread. The fix ties struct deallocation to ~Worker() (ref-counted, parent-thread lifetime) via a new WebWorker__destroy export, and makes deinit() idempotent so it can safely run before WebWorker__dispatchExit drops the Zig-held C++ ref.

Security risks

The bug being fixed is a memory safety issue (use-after-free / use-after-poison). The fix is strictly additive in terms of security posture: it eliminates a UAF, adds no new attack surface, and the new WebWorker__destroy export is only called from ~Worker() which has correct lifetime semantics relative to JS-reachable terminate() calls.

Level of scrutiny

This warrants close human review. The change is in cross-thread, cross-language (C++/Zig) code managing raw pointer lifetimes. Key questions a reviewer should satisfy themselves on: (1) Is the ~Worker() destructor guaranteed to run after all possible terminate() calls? (2) Is there any path where impl_ is used after WebWorker__destroy is called? (3) The Worker::terminate() short-circuit now returns early if any flag is set — does this correctly handle the case where TerminatedFlag is set without TerminateRequestedFlag (normal event-loop-death exit)?

Other factors

Two bugs were flagged by the automated review: a pre-existing name field leak that deinit() omits despite now being the canonical resource-release function, and an inverted test assertion order that produces misleading failure output on crash (though no false passes). Neither was introduced by this PR, but the first is worth addressing here since deinit() is being restructured. The test coverage and 80-run ASAN verification are solid signals, but the complexity of the lifetime change means a human familiar with the Worker/Zig FFI boundary should sign off.

Comment on lines +39 to +41
if (exitCode !== 0) {
expect(stderr).toBe("");
}

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

Comment thread src/bun.js/web_worker.zig
Comment on lines 408 to +418
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 = &.{};
}

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.

@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 terminate() is called after the worker thread exits ai slop Apr 15, 2026
@github-actions github-actions Bot closed this Apr 15, 2026
Comment thread src/bun.js/web_worker.zig
Comment on lines +418 to 422
}

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

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.

Comment thread src/bun.js/web_worker.zig
Comment on lines +657 to 661
// collects the wrapper, `~Worker()` will free this struct, so it must not be touched
// afterwards.
this.deinit();

WebWorker__dispatchExit(globalObject, cpp_worker, exit_code);

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.

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