Skip to content

worker_threads: implement parentPort.close()/ref()/unref()/hasRef() - #30549

Closed
robobun wants to merge 8 commits into
mainfrom
farm/f98f030b/parentport-close-unref
Closed

robobun wants to merge 8 commits into
mainfrom
farm/f98f030b/parentport-close-unref

Conversation

@robobun

@robobun robobun commented May 12, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #11760

Repro

// caller.mjs
import { Worker } from "worker_threads";
const worker = new Worker("./worker.mjs");
worker.postMessage("");

// worker.mjs
import { parentPort } from "worker_threads";
parentPort.on("message", () => {
  parentPort.close();
});

Node exits immediately. Bun hangs forever, and a message posted after close() is still delivered. Same hang for parentPort.unref().

Cause

parentPort in a node:worker_threads worker is an emulated object (fakeParentPort in src/js/node/worker_threads.ts). Its .on("message", ...) forwards to self.addEventListener("message", ...), and WorkerGlobalScope::onDidChangeListenerImpl (BunWorkerGlobalScope.cpp) takes an event-loop ref while any message listener is registered on the global scope. But close(), ref(), unref() and hasRef() were all no-ops:

Object.defineProperty(fake, "close", { value() {} });
Object.defineProperty(fake, "unref", { value() {} });
Object.defineProperty(fake, "ref",   { value() {} });
Object.defineProperty(fake, "hasRef",{ value() { return false; } });

so the listener ref was never released, vm.isEventLoopAlive() stayed true in WebWorker.spin(), the worker never reached shutdown(), and the parent parent_poll_ref was never dropped.

Fix

fakeParentPort now keeps its own listener arrays for message/messageerror and installs a single forwarder on self per event type. A new host function jsFunctionNodeWorkerIncRef (Worker.cpp) adjusts the worker event loop concurrent ref so the port ref state is purely driven by hasRef && !closed, independent of the WorkerGlobalScope auto-ref that the forwarder presence implies. This lets unref() drop the loop ref while still delivering messages, which is not expressible by just removing the listener.

Semantics verified against Node:

  • hasRef(): false initially; true after a message/messageerror listener or ref(); false after unref() or after the last listener is removed; unchanged by close().
  • close(): drops the ref, emits close on next tick, stops dispatching, makes postMessage a no-op; other handles (timers etc.) keep the worker alive until they complete.
  • ref() with no listener keeps the worker alive on its own.
  • onmessage is stored in the same list so dispatch order relative to .on() matches registration order.

The forwarder install/remove and ref-adjustment are sequenced so the concurrent ref counter never dips negative during transitions.

Verification

New test file test/js/node/worker_threads/parentport-lifecycle.test.ts with 8 cases covering close(), late-postMessage rejection after close, unref(), ref(), hasRef() transitions, onmessage, and last-listener removal.

USE_SYSTEM_BUN=1 bun test test/js/node/worker_threads/parentport-lifecycle.test.ts
 1 pass, 7 fail  (hangs)

bun bd test test/js/node/worker_threads/parentport-lifecycle.test.ts
 8 pass, 0 fail

test/js/node/worker_threads/worker_threads.test.ts is unchanged at 91 pass.


[review] gate passed · iteration 8 · 4 files touched

fails on main (without fix)
ASAN without fix: 8 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/worker_threads/parentport-lifecycle.test.ts
bun test v1.4.0 (b33e63dee)

test/js/node/worker_threads/parentport-lifecycle.test.ts:
49 |   }
50 |   return { stdout, stderr, exitCode, error: "" };
51 | }
52 | 
53 | function check({ stdout, stderr, exitCode, error }: Awaited<ReturnType<typeof run>>, expectedLines: string[]) {
54 |   expect(error).toBe("");
                     ^
error: expect(received).toBe(expected)

Expected: ""
Received: "subprocess did not exit within 30000ms (signal SIGKILL, stdout="", stderr="")"

      at check (/workspace/bun/test/js/node/worker_threads/parentport-lifecycle.test.ts:54:17)
      at <anonymous> (/workspace/bun/test/js/node/worker_threads/parentport-lifecycle.test.ts:63:5)
(fail) parentPort.close() inside a message handler lets the process exit [30085.82ms]
49 |   }
50 |   return { stdout, stderr, exitCode, error: "" };
51 | }
52 | 
53 | function check({ stdout, stderr, exitCode, error }: Awaited<ReturnType<typeof run>>, expectedLines: string[]) {
54 |   expect(error).toBe("");
         
... (truncated)

release without fix: 8 FAILED
bun test v1.3.14 (0d9b296a)

test/js/node/worker_threads/parentport-lifecycle.test.ts:
51 | }
52 | 
53 | function check({ stdout, stderr, exitCode, error }: Awaited<ReturnType<typeof run>>, expectedLines: string[]) {
54 |   expect(error).toBe("");
55 |   expect(stderr).toBe("");
56 |   expect(stdout.split("\n").filter(Boolean)).toEqual(expectedLines);
                                                  ^
error: expect(received).toEqual(expected)

  [
-   "worker: hasRef true",
-   "worker: still alive",
+   "worker: hasRef false",
    "parent: exit 0",
  ]

- Expected  - 2
+ Received  + 1

      at check (/workspace/bun/test/js/node/worker_threads/parentport-lifecycle.test.ts:56:46)
      at <anonymous> (/workspace/bun/test/js/node/worker_threads/parentport-lifecycle.test.ts:180:5)
(fail) parentPort.ref() keeps the worker alive without a listener [31.68ms]
(pass) removing the last parentPort message listener lets the worker exit [30.41ms]
49 |   }
50 |   return { stdout, stderr, exitCode, error: "" };
51 | }
52 | 
53 | function check({ stdout, stderr, exitCode, error }: Awaited<ReturnType<typeof run>>, expectedLines: string[]) {
54 |   expect(error).toBe("");
        
... (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/node/worker_threads/parentport-lifecycle.test.ts
bun test v1.4.0 (b33e63dee)

test/js/node/worker_threads/parentport-lifecycle.test.ts:
(pass) parentPort.close() inside a message handler lets the process exit [2593.48ms]
(pass) parentPort.close() stops delivery: late postMessage is dropped [3067.18ms]
(pass) parentPort.unref() still delivers messages while another handle holds the loop [3062.24ms]
(pass) parentPort.close() emits 'close' and lets other handles drain [3088.62ms]
(pass) parentPort.unref() lets the worker exit while a listener is still installed [3091.80ms]
(pass) parentPort.ref() keeps the worker alive without a listener [3082.69ms]
(pass) parentPort.hasRef() tracks listener/ref/unref/close transitions [3062.39ms]
(pass) parentPort.onmessage keeps the worker alive and close() releases it [3055.20ms]
(pass) removing the last parentPort message listener lets the worker exit [3054.52ms]

 9 pass
 0 fail
 36 expect() calls
Ran 9 tests across 1 file. [8.26s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     b33e63dee6
  features     baseline

22 deps, 108 codegen, 1170 objects in 567ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1237] mkdir codegen
[2/1237] mkdir stamps
[3/1237] mkdir pch
[4/1237] mkdir obj
[5/1237] install /workspace/bun
bun install v1.3.14 (0d9b296a)

Checked 124 installs across 170 packages (no changes) [5.00ms]
[6/1237] install /workspace/bun/packages/bun-error
bun install v1.3.14 (0d9b296a)

Checked 1 install across 2 packages (no changes) [1.00ms]
[7/1237] install /workspace/bun/src/node-fallbacks
bun install v1.3.14 (0d9b296a)

Checked 129 installs across 147 packages (no changes) [8.00ms]
[8/1237] gen ErrorCode+*.h
[9/1237] gen bindgenv2
[10/1237] fetch picohttpparser
[picohttpparser] up to date
[11/1237] fetch zlib
[zlib] up to date
[12/1237] fetch tinycc
[tinycc] up to date
[13/1237] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[14/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lu
... (truncated)
diff hotspot
src/js/node/worker_threads.ts                      | 278 +++++++++++++++++++--
 src/jsc/bindings/webcore/Worker.cpp                |  23 ++
 src/jsc/bindings/webcore/Worker.h                  |   1 +
 .../worker_threads/parentport-lifecycle.test.ts    | 253 +++++++++++++++++++
 4 files changed, 532 insertions(+), 23 deletions(-)

gate history · 3 passed · 1 rejected · iteration 8

evidence per changed file
file                                                      reads  edits  tests
src/js/node/worker_threads.ts                                12     19     22
src/jsc/bindings/webcore/Worker.cpp                           3      2     22
src/jsc/bindings/webcore/Worker.h                             1      1     22
test/js/node/worker_threads/parentport-lifecycle.test.ts      4      9     22

@coderabbitai

coderabbitai Bot commented May 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Implements a complete fake Node parentPort (listeners, postMessage, close, ref/unref/hasRef), adds a JSC host binding to adjust the worker event-loop ref count, and adds subprocess tests validating parentPort lifecycle transitions.

Changes

Worker parentPort Lifecycle Implementation

Layer / File(s) Summary
Fake parentPort message port and lifecycle
src/js/node/worker_threads.ts
Expands fakeParentPort() from stub to full implementation with internal listener registries for "message" and "messageerror", DOM/Node-style addEventListener/removeEventListener and { once: true } support, onmessage/onmessageerror property routing, isolated handler exception behavior, postMessage, a teardown-capable close() that schedules asynchronous "close" events and clears handlers, and ref()/unref()/hasRef() tracking with forwarder wiring.
Event loop ref-counting JSC binding
src/jsc/bindings/webcore/Worker.h, src/jsc/bindings/webcore/Worker.cpp
Declares and implements jsFunctionNodeWorkerIncRef JSC host function and adds an extern "C" declaration for Bun__eventLoop__incrementRefConcurrently, enabling the fake port to increment/decrement the worker event-loop reference count from JS.
Test infrastructure and lifecycle cases
test/js/node/worker_threads/parentport-lifecycle.test.ts, test/expectations.txt
Adds subprocess-based test helpers (run(), check()), an ASAN expectation entry, and seven concurrent tests covering parentPort.close(), close event emission, unref()/ref() behavior, hasRef() state tracking, onmessage handler lifetime, and removal of the last message listener to ensure worker subprocesses exit reliably.
🚥 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 accurately summarizes the main change: implementing parentPort lifecycle/ref semantics in worker_threads.
Description check ✅ Passed The description is detailed and includes the change rationale, fix, and verification, though it uses custom headings instead of the template's exact section names.

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

@robobun

robobun commented May 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:36 PM PT - Jul 22nd, 2026

❌ @robobun, your commit b33e63d has 3 failures in Build #77972 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30549

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

bun-30549 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. node:worker_threads: Messages delivered twice when both parentPort.on('message') and self.onmessage are registered #25860 - The fakeParentPort() rewrite maintains its own listener arrays with a single forwarder on self, preventing the double message dispatch when both parentPort.on and self.onmessage are registered
  2. Z3 doesn't work in Bun #19453 - Z3 Emscripten code registers both parentPort.on and self.onmessage; the dual dispatch caused removeRunDependency to fire twice, tripping an assertion - fixed by the single-forwarder approach
  3. z3-solver behavior different from Node #29635 - Same Emscripten dual-dispatch root cause as 19453, directly fixed by the parentPort rewrite

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

Fixes #25860
Fixes #19453
Fixes #29635

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:worker_threads: implement postMessageToThread [1bx5ty] #29828 - Also fixes MessagePort ref()/unref()/hasRef()/close() being no-ops, overlapping with parentPort lifecycle fixes
  2. fix(worker_threads): use MessagePort for parentPort instead of dispatching to self #26293 - Replaces fakeParentPort with a real MessagePort pair, which would inherently provide working close()/ref()/unref() methods
  3. Stop dispatching parent messages to self.onmessage in node:worker_threads workers #29215 - Rewrites fakeParentPort listener management with own EventTarget and lazy forwarder install/removal, same mechanism as this PR

🤖 Generated with Claude Code

Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts
@robobun
robobun requested a review from Jarred-Sumner as a code owner May 12, 2026 11:56

@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 `@src/js/node/worker_threads.ts`:
- Around line 235-292: The add/remove logic must treat the capture flag as part
of the listener identity; currently addEventListener and removeEventListener
dedupe and remove only by callback which breaks the EventTarget spec. Modify the
storage for listeners[type] to track entries with both callback and capture
(e.g., objects {callback, capture, entryWrapper}) or use a per-listener WeakMap
to map capture->wrapper so that addEventListener(type, fn, true) and
addEventListener(type, fn, false) are distinct and removeEventListener honors
the capture argument. Update addEventListener (checks that search list by
callback+capture, create once-wrapper and store it keyed by capture), update
removeEventListener (match and remove by callback+capture and clear the
per-capture wrapper), and keep calls to
ensureForwarder/dropForwarder/syncRef/afterListenerRemoved unchanged except they
should operate on the updated list length/entries.
- Around line 407-414: The current Object.defineProperty calls set
fake.addListener/fake.removeListener to directly alias
addEventListener/removeEventListener which bypasses the EventEmitter wrapper
(functionForEventType) so listeners on parentPort added via addListener receive
raw events; change the definitions so addListener and removeListener reuse the
EventEmitter wrapper path used by on/off (i.e., call the same wrapper that wraps
handlers with functionForEventType for the "message" event) rather than directly
aliasing addEventListener/removeEventListener, ensuring addListener -> the
wrapped add path and removeListener -> the wrapped remove path (use the same
internal helper used for on/off).
🪄 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: be4125bd-7677-4a39-9255-9f0be1bf8de0

📥 Commits

Reviewing files that changed from the base of the PR and between c0b02b3 and 7bbf1a6.

📒 Files selected for processing (1)
  • src/js/node/worker_threads.ts

Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/worker_threads.ts
Comment thread src/jsc/bindings/webcore/Worker.cpp
Comment thread src/js/node/worker_threads.ts
@robobun

robobun commented May 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status

The diff is green on everything it touches; parentport-lifecycle.test.ts does not appear in any failure log.

Build 77972 (at b33e63de, all lanes finished) has seven unrelated failures, none of which overlap with this PR's worker_threads changes:

  • test/js/node/test/parallel/test-net-connect-memleak.js (ubuntu x64 + debian x64): node:net FinalizationRegistry assertion. Also failing on current main; reported to main-break triage.
  • test/js/node/test/parallel/test-gc-http-client-connaborted.js (ubuntu x64): node:http GC timeout.
  • test/cli/run/no-orphans.test.ts (darwin x64): marked pre-existing.
  • test/cli/hot/watch-many-dirs.test.ts (ubuntu/debian aarch64): --hot file-watching timeout, marked flaky.
  • test/cli/install/bun-security-scanner-matrix-with-node-modules.test.ts (alpine aarch64): bun install security-scanner TTY, marked flaky.
  • test/js/node/test/parallel/test-fs-promises-file-handle-readFile.js (ubuntu aarch64/x64): FileHandle GC, marked flaky.
  • test/js/bun/s3/s3.test.ts (darwin x64): S3 test, marked flaky.

Ready for review/merge.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up for anyone reviewing this: #32828 is a fresh report of the same parentPort.close() behavior this PR fixes (effectively a duplicate of #11760), so this PR resolves its primary symptom too.

One thing this PR does not cover that #32828's reproduction also hits: Worker.terminate() hangs when called on a worker that already exited with code 0 (a falsy-zero check in terminate() waits on a close event that already fired). That repro ends with await worker.terminate() on a worker that just wound down with code 0, so it still hangs after close() is fixed here. I put a focused fix for that separate bug in #32831.

parentPort in a node:worker_threads worker is a facade over the worker
global scope. close(), ref(), unref() were empty stubs and hasRef()
returned a hard-coded false, so the idiomatic self-shutdown pattern
(parentPort.on('message', h); ...; parentPort.close()) hung forever:
the message listener's auto-ref on the event loop was never released,
and a message posted after close() was still delivered.

The facade now keeps its own listener lists for 'message' and
'messageerror' and installs a single forwarder on self per type.
Installing a 'message' listener on self takes one auto-ref on the
worker loop (BunWorkerGlobalScope.cpp); a new native hook,
jsFunctionNodeWorkerIncRef, lets the JS layer apply a compensating
delta so the net ref is driven purely by hasRef() and close() state.
ref()/unref() flip the flag, close() drops both forwarders and fires
'close' on nextTick, hasRef() returns the live value, and postMessage
after close() is a no-op.

Fixes #11760
@robobun
robobun force-pushed the farm/f98f030b/parentport-close-unref branch from 02aa55a to b4879f3 Compare July 22, 2026 15:06
Comment thread test/js/node/worker_threads/parentport-lifecycle.test.ts
The worker never called parentPort.postMessage, so the parent's
worker.once('message') handler (which sends the 'late' message) never
fired and the test degenerated to 'close() lets the process exit'. Have
the worker ack before close() so the parent actually posts 'late' while
the worker is held open by the timeout; if close() were a no-op the
worker would then print 'worker: got late' and the assertion would fail.
Comment thread test/js/node/worker_threads/parentport-lifecycle.test.ts Outdated
Comment thread src/js/node/worker_threads.ts Outdated
robobun added 2 commits July 22, 2026 15:46
…-bearing

close() was doing listeners.message.length = 0, which aliases the array
dispatch()'s per-entry liveness check reads — so if handler A calls
close() mid-dispatch, every not-yet-reached handler B is skipped. Node's
MessagePort.close() doesn't touch the listener list (in-flight dispatch
continues); closed=true + dropForwarder already make the arrays
unreachable for future events, so the truncation was redundant.

Also unref the setImmediate in the ref() test so parentPort.ref() is the
only thing holding the loop; previously setImmediate's own ref kept the
worker alive regardless of whether ref()'s syncRef() fired.
…ually fires

The squash/autofix rebase dropped the per-test timeout args, so without
the fix the default 5 s test timeout fired before the 30 s spawn timeout
and the hung subprocess survived, leaving dozens of processes behind
that starved later build steps. With the timeout back, the spawn SIGKILL
fires first and the fail-before run exits cleanly (7 fail, 0 leftover
processes).
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread test/js/node/worker_threads/parentport-lifecycle.test.ts Outdated
Comment thread src/js/node/worker_threads.ts Outdated
…ener options; cover unref-still-delivers

- close() no longer nulls onmessageEntry: Node's MessagePort.close()
  leaves the onmessage getter returning the last-set handler, and since
  the wrapper is still reachable via listeners.message the null didn't
  help GC anyway.
- removeEventListener now forwards its third (options/capture) arg to
  self for non-message event types, matching addEventListener and the
  pre-PR self.removeEventListener.bind(self) behaviour.
- New test: unref() still delivers messages while another handle holds
  the loop. This is the property the compensating incEventLoopRef path
  exists for; without it a dropForwarder()-based unref() passed every
  test byte-identically.
Comment thread src/js/node/worker_threads.ts
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by the Worker rewrite in #37075, which landed on main and closed #11760. In a node:worker_threads worker, parentPort is now a real MessagePort entangled with the parent's public port, so close(), ref(), unref() and hasRef() are the MessagePort implementations; the emulated parentPort this PR extended is only left as a fallback for non-node workers that import worker_threads.

Verified on current main (165dc9f, debug build): 8 of the 9 cases in this PR's parentport-lifecycle.test.ts pass, including the #11760 repro (parentPort.close() inside a message handler lets the process exit) and the unref() cases. The remaining case expects hasRef() to still report true immediately after close(); that is a MessagePort-level ordering difference from node that applies to MessageChannel ports too and is unrelated to the code this PR changed, so it is being looked at separately. Closing.

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

Bun doesn't finish the execution when all workers' ports are closed

1 participant