Skip to content

worker_threads: don't abort when terminate() interrupts a lazy property builder - #33418

Closed
robobun wants to merge 3 commits into
mainfrom
farm/912391f8/lazy-property-termination
Closed

worker_threads: don't abort when terminate() interrupts a lazy property builder#33418
robobun wants to merge 3 commits into
mainfrom
farm/912391f8/lazy-property-termination

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Deflakes test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js, which has been aborting the whole test process on the x64-asan lane (5 of the last 120 failed builds, e.g. builds 68677, 68637, 68564, 68435, 68343):

test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js - SIGABRT on 13 x64-asan
ASSERTION FAILED: !scope.exception() || !hasSlot
cache/webkit-c9ad5813fd23bd8b-asan/include/JavaScriptCore/JSCJSValuePropertyInlines.h(51) : JSValue JSC::JSValue::get(JSGlobalObject *, PropertyName, PropertySlot &) const

Repro

The test creates 10 workers and terminates each one on the next setImmediate, so terminate() lands while the worker is still evaluating its entry module. Deterministically, with no racing:

const w = new Worker(
  "data:text/javascript," +
    encodeURIComponent(`
      postMessage("go");
      Bun.sleepSync(300);   // terminate() arrives while we are parked in native code
      process.nextTick;     // first touch: the lazy builder enters JS
    `),
);
w.addEventListener("message", () => w.terminate());

Aborts every time on a debug build. process.mainModule, process.stdin and Bun.$ abort the same way (Bun.$ via JSCJSValueCell.h:67:34: member call on null pointer of type 'JSC::JSCell' under UBSAN, because its builder hands the empty JSValue to putDirect).

Cause

process.nextTick, process.mainModule, process.stdin, process.stdout, process.stderr, process.channel and Bun.$ are PropertyCallback entries in a static hash table. JSC reifies them in reifyStaticProperty (Lookup.h):

if (value.attributes() & PropertyAttribute::PropertyCallback) {
    JSValue result = value.lazyPropertyCallback()(vm, &thisObj);
    thisObj.putDirect(vm, propertyName, result, attributesForStructure(value.attributes()));
    return;
}

No exception check, and getPropertySlot then reports the slot as found. So a builder must never return with an exception pending — the builders know that and say so in a comment:

// Lazy property builder: exceptions must not propagate into
// reifyStaticProperty, which performs no exception check.
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
JSValue nextTickFunction = JSC::profiledCall(globalObject, ProfilingReason::API, initializer, ...);
if (auto* exception = scope.exception()) [[unlikely]] {
    (void)scope.tryClearException();
    Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception);
    return JSC::jsUndefined();
}

ExceptionScope::tryClearException() refuses to clear a TerminationException and returns false, which nothing checks. When worker.terminate() has armed the termination trap, profiledCall throws the TerminationException at the initializer's entry, the builder leaves it pending, getPropertySlot returns true, and JSValue::get's EXCEPTION_ASSERT(!scope.exception() || !hasSlot) fires. (JSObject::get tolerates this exact state — !scope.exception() || vm.hasPendingTerminationException() || !hasProperty — but the LLInt's get_by_id slow path goes through JSValue::get, which does not.)

The window in the vendored test is the straight-line code between the last trap check and the first process.nextTick read inside an internal module.

Fix

Wrap the builders that enter JS in JSC::DeferTerminationForAWhile, which is what JSC::LazyProperty::callFunc already does for JSC's own lazy properties (LazyPropertyInlines.h). The builder runs to completion, no exception is left pending, and the trap refires on scope exit so the worker unwinds at the next safepoint exactly as before.

Applied to every PropertyCallback builder that can enter JS:

  • constructNextTickFn, constructStdioWriteStream (process.stdout/process.stderr), constructStdin, constructProcessChannel, constructMainModuleProperty
  • constructBunShell (Bun.$), defaultBunSQLObject (Bun.sql/Bun.postgres), constructBunSQLObject (Bun.SQL) — these evaluate a builtin or the bun:sql internal module
  • DEFINE_ZIG_BUN_OBJECT_GETTER_WRAPPER, the one wrapper behind all 38 Rust-backed Bun.* getters

The Bun.* builders fail differently today: RETURN_IF_EXCEPTION(scope, {}) hands the empty JSValue to putDirect, so the symptom is JSCJSValueCell.h:67:34: member call on null pointer of type 'JSC::JSCell' rather than the assertion. Same contract violation, same fix. (Bun.argv and Bun.embeddedFiles reach it through the Rust wrapper.)

Verification

test/js/web/workers/worker-terminate-lifetime.test.ts gains a case that terminates one worker per builder shape, each parked in Bun.sleepSync so terminate() is requested before its first touch of the property.

  • git stash push -- src/ && bun bd test test/js/web/workers/worker-terminate-lifetime.test.ts → fails with ASSERTION FAILED: !scope.exception() || !hasSlot
  • git stash pop && bun bd test test/js/web/workers/worker-terminate-lifetime.test.ts → 4 pass

Sweeping the repro over all 46 lazy properties on the Bun and process objects (Bun.$, Bun.sql, Bun.SQL, Bun.postgres, Bun.argv, Bun.embeddedFiles, the 38 Rust-backed getters, process.nextTick/mainModule/stdin/stdout/stderr/channel) reports none crashing; before the change, nine of them did.

The vendored node test also passes, and the previously-crashing timing window (terminate() at 400-720 ms into a debug build's worker startup, where it reproduced 2 of 3 runs) is now clean across 16 runs.

Adjacent: #33211

#33211 fixes a different bug in the same reifyStaticProperty contract — a Bun.* lazy getter that throws returns the empty JSValue, which putDirect then stores. It touches constructBunShell, so these two conflict textually. The lazyPropertyResult() guard it introduces uses tryClearException() too, so it still needs the DeferTerminationForAWhile from this PR for the termination case; whichever lands second should put the defer inside that helper.

JSC's reifyStaticProperty calls a PropertyCallback builder and stores
the result with putDirect, performing no exception check, so a builder
must never return with a pending exception. The process.* and Bun.*
builders relied on TopExceptionScope::tryClearException(), which is a
no-op for a TerminationException. A worker.terminate() that landed
while one of them was entering JS therefore left the exception pending
and tripped EXCEPTION_ASSERT(!scope.exception() || !hasSlot) in
JSValue::get.

Wrap the builders that enter JS in DeferTerminationForAWhile, the same
guard JSC::LazyProperty::callFunc uses: the builder runs to completion
and the trap refires afterwards, so the unwind happens at the next
safepoint instead.
@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:17 AM PT - Jul 6th, 2026

@robobun, your commit 4cd43cd has 1 failures in Build #68785 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33418

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

bun-33418 --bun

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix segfault when a lazy Bun.* getter throws during reification #33211 - Also guards lazy Bun.* property builders (constructBunShell, etc.) against crashes during reification, using a generic exception guard rather than DeferTerminationForAWhile
  2. Fix Bun.inspect null deref with Proxy prototypes; stop reporting from inside the sql lazy-property builders #30245 - Fixes null JSCell derefs in the same Bun object lazy init codepath (constructBunShell and siblings); partially superseded by Fix segfault when a lazy Bun.* getter throws during reification #33211

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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: fcc8a9b2-2ed7-4773-a518-689cdde6f7ed

📥 Commits

Reviewing files that changed from the base of the PR and between ff8ad9e and 4cd43cd.

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

Walkthrough

Adds termination deferral guards around lazy/static property reification paths in BunObject and BunProcess, updates the generated lazy getter wrapper, and adds a worker regression test that terminates during lazy property access.

Changes

Termination deferral during lazy property reification

Layer / File(s) Summary
Generated lazy getter guard
src/jsc/bindings/BunObject+exports.h
Includes DeferTermination.h and wraps the generated lazy getter callback body with DeferTerminationForAWhile before calling the exported callback.
BunObject lazy property guards
src/jsc/bindings/BunObject.cpp
Includes DeferTermination.h and adds DeferTerminationForAWhile guards in defaultBunSQLObject, constructBunSQLObject, and constructBunShell before lazy/static property reification paths.
BunProcess lazy property guards
src/jsc/bindings/BunProcess.cpp
Includes DeferTermination.h and adds DeferTerminationForAWhile guards in constructStdioWriteStream, constructStdin, constructProcessChannel, constructMainModuleProperty, and Process::constructNextTickFn.
Regression test for lazy property termination
test/js/web/workers/worker-terminate-lifetime.test.ts
Adds a worker test that blocks in native code, touches each lazy property, terminates during reification, and asserts clean exit output and status.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: preventing aborts when terminate() interrupts a lazy property builder.
Description check ✅ Passed The description covers the bug, root cause, fix, repro, and verification, even though it doesn't use the exact template headings.
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.

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

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/BunObject.cpp (1)

318-341: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add the termination guard to both SQL property builders src/jsc/bindings/BunObject.cpp:318-341defaultBunSQLObject and constructBunSQLObject follow the same lazy PropertyCallback path as constructBunShell, and both can enter JS via requireId(...)/.get(). Add JSC::DeferTerminationForAWhile here too so reifyStaticProperty can’t observe a TerminationException.

🤖 Prompt for 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.

In `@src/jsc/bindings/BunObject.cpp` around lines 318 - 341, Add the termination
guard to both SQL property builders, since `defaultBunSQLObject` and
`constructBunSQLObject` follow the same lazy `PropertyCallback` path as
`constructBunShell` and can enter JS through `requireId(...)` and `.get()`.
Update `BunObject.cpp` so each function wraps the JS-facing work with
`JSC::DeferTerminationForAWhile`, ensuring `reifyStaticProperty` cannot observe
a `TerminationException`. Keep the change localized to these two helpers and
preserve their existing exception handling flow.
🤖 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.

Outside diff comments:
In `@src/jsc/bindings/BunObject.cpp`:
- Around line 318-341: Add the termination guard to both SQL property builders,
since `defaultBunSQLObject` and `constructBunSQLObject` follow the same lazy
`PropertyCallback` path as `constructBunShell` and can enter JS through
`requireId(...)` and `.get()`. Update `BunObject.cpp` so each function wraps the
JS-facing work with `JSC::DeferTerminationForAWhile`, ensuring
`reifyStaticProperty` cannot observe a `TerminationException`. Keep the change
localized to these two helpers and preserve their existing exception handling
flow.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e6f09c10-d06c-4cfc-9fc8-7cb5b7075cc1

📥 Commits

Reviewing files that changed from the base of the PR and between af362c9 and 148063a.

📒 Files selected for processing (3)
  • src/jsc/bindings/BunObject.cpp
  • src/jsc/bindings/BunProcess.cpp
  • test/js/web/workers/worker-terminate-lifetime.test.ts

Comment thread src/jsc/bindings/BunObject.cpp
defaultBunSQLObject / constructBunSQLObject evaluate the bun:sql internal
module through requireId(), and every Rust-backed getter behind
DEFINE_ZIG_BUN_OBJECT_GETTER_WRAPPER can leave an exception pending too.
Without the defer they hand the empty JSValue to putDirect instead of
tripping the assertion, but it is the same contract violation.

Sweeping all 46 lazy properties through the repro now reports none
crashing.
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — the review caught a real gap. Both @coderabbitai and @claude flagged the same thing independently, and they were right.

Bun.sql / Bun.SQL / Bun.postgres (fixed in ff8ad9e)

defaultBunSQLObject and constructBunSQLObject evaluate the bun:sql internal module through requireId()generateModule()JSC::profiledCall, which is exactly the JS entry point that produced the original crash. Verified against a build that already had the process.* and Bun.$ guards:

Bun.sql      -> exit=1 :: JSCJSValueCell.h:67:34: member call on null pointer of type 'JSC::JSCell'
Bun.SQL      -> exit=1 :: ...
Bun.postgres -> exit=1 :: ...
process.nextTick -> exit=0 :: ok

They fail through RETURN_IF_EXCEPTION(scope, {})putDirect(vm, prop, JSValue()) rather than the assertion, but it's the same contract violation, and DeferTerminationForAWhile fixes both symptoms.

And two more the review prompted me to find

Rather than re-assert that the rest "only allocate", I swept the repro over every lazy property on the Bun and process objects. Two more were crashing: Bun.argv and Bun.embeddedFiles, both behind DEFINE_ZIG_BUN_OBJECT_GETTER_WRAPPER. That macro is the single wrapper for all 38 Rust-backed Bun.* getters, so the guard went there instead of on individual getters.

All 46 lazy properties now survive terminate(); nine of them crashed before. The PR description's completeness claim has been corrected.

On the duplicate flag

Not a duplicate of #33211 or #30245. Those fix a lazy getter that throws (user clobbers Error, stack overflow, bad REDIS_URL) returning the empty JSValue. This fixes a lazy getter that is terminatedExceptionScope::tryClearException() returns false without clearing a TerminationException, and nothing checks that return value, so the exception is still pending when the builder returns. The lazyPropertyResult() guard #33211 introduces uses tryClearException() too, so it does not close the termination case on its own; DeferTerminationForAWhile is what keeps the exception from ever being thrown inside the builder.

They do overlap textually in BunObject.cpp / BunObject+exports.h. Whichever lands second should fold the defer into lazyPropertyResult() / the wrapper macro — the two guards are complementary, not alternatives.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@robobun Thanks for the thorough follow-up and for tracking down the Bun.sql/Bun.SQL/Bun.postgres gap as well as Bun.argv/Bun.embeddedFiles. Fixing the wrapper macro (DEFINE_ZIG_BUN_OBJECT_GETTER_WRAPPER) once instead of patching each of the 38 Rust-backed getters individually is the right call — good catch expanding the repro sweep to confirm all 46 lazy properties now survive terminate().

The clarification on why this isn't a duplicate of #33211/#30245 is helpful — the throw-vs-terminate distinction (tryClearException() not clearing a TerminationException) is subtle and worth keeping in mind for whichever PR merges second, since folding DeferTerminationForAWhile into lazyPropertyResult() would consolidate both guards cleanly.

( •ᴗ• )b


✏️ Learnings added
Learnt from: robobun
URL: https://github.com/oven-sh/bun/pull/33418

Timestamp: 2026-07-06T06:48:17.795Z
Learning: In oven-sh/bun, `Bun.sql`/`Bun.SQL`/`Bun.postgres` lazy getters (`defaultBunSQLObject`/`constructBunSQLObject`) evaluate the `bun:sql` internal module via `requireId()` → `generateModule()` → `JSC::profiledCall`, which is a JS entry point vulnerable to the same `worker.terminate()` pending-`TerminationException` crash described in PR `#33418`. This is fixed by wrapping with `JSC::DeferTerminationForAWhile`.

Learnt from: robobun
URL: https://github.com/oven-sh/bun/pull/33418

Timestamp: 2026-07-06T06:48:17.795Z
Learning: In oven-sh/bun's `src/jsc/bindings/BunObject.cpp` (and `BunObject+exports.h`), all 38 Rust-backed `Bun.*` getters (e.g., `Bun.argv`, `Bun.embeddedFiles`) share a single wrapper macro `DEFINE_ZIG_BUN_OBJECT_GETTER_WRAPPER`. To guard lazy properties against pending `TerminationException` during `worker.terminate()` (per PR `#33418`), the `JSC::DeferTerminationForAWhile` fix should be applied at this macro level rather than on individual getters.

Learnt from: robobun
URL: https://github.com/oven-sh/bun/pull/33418

Timestamp: 2026-07-06T06:48:17.795Z
Learning: In oven-sh/bun, PR `#33418`'s `DeferTerminationForAWhile` fix for lazy property builders is complementary to (not a duplicate of) PRs `#33211` and `#30245`: those PRs address lazy getters that *throw* user-catchable errors (handled via `tryClearException()`), whereas `#33418` addresses lazy getters that are *terminated* mid-execution — `ExceptionScope::tryClearException()` returns `false` and does not clear a pending `TerminationException`, so `reifyStaticProperty` can store a value while an exception is still pending, tripping `JSValue::get`'s assertion. Whichever of these PRs lands second should fold `DeferTerminationForAWhile` into `lazyPropertyResult()` / the getter wrapper macro to consolidate both guards.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
test/js/web/workers/worker-terminate-lifetime.test.ts (2)

147-153: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert stderr before stdout in the combined object, per this file's established ASAN-diagnosability convention.

The combined assertion currently orders stdout before stderr. For this specific file, a prior review established that stderr must be checked first (and unconditionally) since ASAN can print a heap-use-after-free report to stderr even when the process exits 0 — putting stderr first surfaces that signal prominently in the failure diff.

🔧 Suggested reorder
-    const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
-    expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toEqual({
-      stdout: `terminated ${lazyProperties.length}\n`,
+    const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+    expect({ stderr, stdout, exitCode, signalCode: proc.signalCode }).toEqual({
       stderr: "",
+      stdout: `terminated ${lazyProperties.length}\n`,
       exitCode: 0,
       signalCode: null,
     });

Based on learnings, "assert stderr first (before stdout) and do so unconditionally... canonical ordering for this pattern: assert stderr → assert stdout → assert exitCode" for test/js/web/workers/worker-terminate-lifetime.test.ts.

🤖 Prompt for 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.

In `@test/js/web/workers/worker-terminate-lifetime.test.ts` around lines 147 -
153, The combined assertion in the worker lifetime test is using the wrong field
order for this file’s ASAN-diagnosability convention. Update the expect object
in the `worker-terminate-lifetime` test so `stderr` is asserted before `stdout`,
and keep that ordering unconditionally alongside `exitCode` and `signalCode` to
match the established pattern in this file.

Source: Learnings


121-138: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Wire the worker's error event to fail fast instead of relying on the outer timeout.

closed only resolves on the close event; there's no error (or similar failure) listener rejecting the promise if a worker throws for a reason unrelated to termination (e.g. a bug in the property-access script itself). If that happens, the test will hang until the whole-test timeout (20s/60s) rather than fail immediately with a useful error.

🛠️ Suggested fix
-            const closed = new Promise(resolve => w.addEventListener("close", resolve, { once: true }));
+            const { promise: closed, resolve, reject } = Promise.withResolvers();
+            w.addEventListener("close", resolve, { once: true });
+            w.addEventListener("error", reject, { once: true });
             w.addEventListener("message", () => w.terminate(), { once: true });
             return closed;

As per coding guidelines, "Wire EVERY failure event (error, close, abort, process exit) to reject the awaited promise — never throw inside event callbacks."

🤖 Prompt for 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.

In `@test/js/web/workers/worker-terminate-lifetime.test.ts` around lines 121 -
138, The worker promise in worker-terminate-lifetime.test.ts only waits for the
close event, so failures from the Worker can hang until the test timeout instead
of failing immediately. Update the closed promise in the properties.map block to
also listen for the worker’s error event (and reject on it) using the same
Worker instance that is already created and terminated via w.terminate(). Keep
the close listener resolving the promise, but make any non-termination worker
failure reject fast with the underlying error so the test surfaces the real
issue.

Source: Coding guidelines

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

Outside diff comments:
In `@test/js/web/workers/worker-terminate-lifetime.test.ts`:
- Around line 147-153: The combined assertion in the worker lifetime test is
using the wrong field order for this file’s ASAN-diagnosability convention.
Update the expect object in the `worker-terminate-lifetime` test so `stderr` is
asserted before `stdout`, and keep that ordering unconditionally alongside
`exitCode` and `signalCode` to match the established pattern in this file.
- Around line 121-138: The worker promise in worker-terminate-lifetime.test.ts
only waits for the close event, so failures from the Worker can hang until the
test timeout instead of failing immediately. Update the closed promise in the
properties.map block to also listen for the worker’s error event (and reject on
it) using the same Worker instance that is already created and terminated via
w.terminate(). Keep the close listener resolving the promise, but make any
non-termination worker failure reject fast with the underlying error so the test
surfaces the real issue.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 912ed2fb-2780-4abf-a345-2230c6311c7c

📥 Commits

Reviewing files that changed from the base of the PR and between 148063a and ff8ad9e.

📒 Files selected for processing (3)
  • src/jsc/bindings/BunObject+exports.h
  • src/jsc/bindings/BunObject.cpp
  • test/js/web/workers/worker-terminate-lifetime.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.

Thanks for covering the SQL builders and the Rust-backed wrapper macro — that closes the gap I flagged. Deferring to a human for sign-off given this is JSC exception/termination handling in the bindings and needs merge-order coordination with #33211.

Extended reasoning...

Overview

Adds JSC::DeferTerminationForAWhile RAII guards to every PropertyCallback lazy builder that can enter JS (process.nextTick/mainModule/stdin/stdout/stderr/channel, Bun.$, Bun.sql/SQL/postgres, and — via the shared wrapper macro in BunObject+exports.h — all ~40 Rust-backed Bun.* getters). This mirrors what JSC's own LazyProperty::callFunc does, so a TerminationException armed by worker.terminate() can no longer escape into reifyStaticProperty (which has no exception check) and trip EXCEPTION_ASSERT(!scope.exception() || !hasSlot). A deterministic regression test parks each worker in Bun.sleepSync, terminates it, then touches one lazy property per builder shape.

My earlier inline comment (SQL builders also enter JS via requireIdprofiledCall) has been addressed in commit ff8ad9e: both defaultBunSQLObject and constructBunSQLObject now carry the guard, and Bun.sql / Bun.SQL / Bun.argv were added to the test's lazyProperties array.

Security risks

None. This is purely worker-termination timing inside the runtime; no user-controlled input, auth, or data-exposure surface is touched.

Level of scrutiny

Medium-high. The change is mechanically small (one RAII line per function), but it lives in JSC C++ bindings and alters exception/termination-scope interaction across a wide fan-out — including a macro that now applies the defer to every Rust-backed Bun.* getter. That broad application looks correct (JSC does the same for its own lazy properties, and DeferTerminationForAWhile refires the trap on scope exit so the worker still unwinds promptly), but a maintainer familiar with the worker termination lifecycle should confirm.

Other factors

  • The PR description explicitly calls out a textual conflict with #33211 and specifies how the two should compose ("whichever lands second should put the defer inside that helper"). A human needs to decide merge order and reconcile.
  • The new test asserts stderr: "", which the repo guidelines warn against for ASAN/debug noise, but every other test in this file already does the same — it's the local convention, not new risk introduced here.
  • No bugs found by the automated bug hunt on this revision.

…tion order

Without an error listener a worker that dies before it signals still fires
close, so the test would report every property as terminated while having
exercised nothing.
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Both test findings applied in 4cd43cd.

error → reject

This one turned out to be substantive, not just hygiene. Without the listener, a worker that dies before it signals "go" never gets terminated, still fires close, and the parent counts it as done:

# worker script broken on purpose (Bun.sleepSyncTYPO), error listener removed
exit=0  stdout=[terminated 1]      <- vacuous pass, nothing exercised

# with the error listener
exit=1  stdout=[]
stderr: TypeError: Bun.sleepSyncTYPO is not a function.

So the seven-property matrix could have silently degraded to zero properties if the generated script ever broke. Now it fails fast with the real error.

(Worth noting for anyone reading the test later: a worker that reaches the property and then throws does not hit this path, because the refired termination trap unwinds it first. The listener is specifically guarding the "died before signalling" case, which is the one that was silent.)

stderr before stdout

Correct, that is this file's convention (lines 35, 78, 189 all assert stderr first). Reordered the keys in the combined object to match. Keeping the single toEqual rather than three toBes, per the repo's guidance on structured assertions.

@claude

Agreed on deferring to a human, and thanks for confirming the SQL + wrapper-macro coverage. The merge-order note for #33211 is in the PR description: the two guards compose, and whichever lands second should fold DeferTerminationForAWhile into lazyPropertyResult(). On stderr: "" — you read it right, it is the existing convention in this file rather than something this PR introduces.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green, the three red jobs are infra + a known flake

Build 68785 at 4cd43cd: 281 jobs passed, 3 failed. None of the three touch this change.

x64-asan — the lane this PR exists to fix — is 20/20 green. Both relevant tests ran on it and passed:

test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js shard 177/227, passed (this is the test that was SIGABRT'ing)
test/js/web/workers/worker-terminate-lifetime.test.ts shard 3/227, passed (the new regression test)

The three failures:

  1. darwin 26 aarch64 - test-bun ×2 — not a test failure. The runner never started:

    Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
    Refusing to continue with a partial download (would silently fall back to the wrong binary).
    

    darwin aarch64 - build-bun passed, so the artifact existed. This is the infra hiccup that 329ea9d (ci: fail loudly when artifact download times out #29039, "ci: fail loudly when artifact download times out") was written to surface rather than silently swallow.

  2. windows 2019 x64 - test-bunnapi > napi_wrap > has the right lifetime, failing with Condition was not met after 100 GC attempts from gcUntil. This is a pre-existing Windows GC flake, not something this PR introduced: it appears in 81 of the last 120 failed builds (68%), across branches with nothing in common (deps/update-sqlite, deps/update-hdrhistogram, ali/decouple-bake-3-seams, and a long tail of unrelated farm/* PRs). It also failed all 4 retries here, which is how that flake usually presents.

I checked whether my own commits introduced it rather than assuming: build 68762 (148063a, the process/shell guards only) had all 8 Windows x64 shards green, and the only code change after it is ff8ad9e (the SQL builders plus the getter-wrapper macro). A DeferTerminationForAWhile is a refcount bump with no GC interaction when nothing is terminating, so it cannot keep a napi_wrap'd object alive — and the flake's prevalence across unrelated branches confirms it.

Not re-triggering. With that napi flake hitting two thirds of failed builds, a re-roll would very likely land in the same place; it would burn a CI run for nothing. Happy to push a retrigger if a maintainer would rather see a clean board, otherwise this is ready for review on the merits.

The one open question for a human is still merge order against #33211, as described in the PR body: the two guards compose, and whichever lands second should fold DeferTerminationForAWhile into lazyPropertyResult().

@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

#33966 fixes the process.* side of this (which #31216 made hot in every node:worker_threads worker bootstrap via setupWorkerStdio, turning test-worker-message-port-transfer-terminate.js red on x64-asan), using TopExceptionScope::clearException() instead of DeferTerminationForAWhile.

I tried the DeferTerminationForAWhile approach from this PR first (rebased on current main): it fixes the primary assertion, but under the same 80-worker stress it surfaces a separate, rarer assertNoException (ExceptionScope.h:61) at ~1/4000 workers. With DeferTerminationForAWhile the stdio builder runs all of node:stream to completion under deferred termination, which shifts the termination window into later bootstrap code that has its own issue. Fully clearing (and returning jsUndefined) bails out promptly and stress-tests clean (0/7200).

The Bun.* builders here (Bun.$, Bun.sql, the Rust-backed wrapper macro) are not in the worker bootstrap path, so DeferTerminationForAWhile is probably fine for them. Left them to this PR.

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #32488 (merged 2026-07-16) added test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts, an 80-worker amplification of test-worker-message-port-transfer-terminate.js. It is now red on the x64-asan lane (e.g. build 73861) with the same assertion this PR fixes:

ASSERTION FAILED: !scope.exception() || !hasSlot
JSCJSValuePropertyInlines.h(51) : JSValue JSC::JSValue::get(...)

The stress test's worker body is require('worker_threads').parentPort.on('message', () => {}), so it hits the setupWorkerStdioprocess.stdout path during bootstrap. Merging this PR should turn that test green as well.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

This was fixed at the JSC layer instead: oven-sh/WebKit#282 wraps the two reifyStaticProperty call sites (setUpStaticFunctionSlot and reifyAllStaticProperties) in DeferTerminationForAWhile and reports the slot as not found when a builder throws, rather than storing an empty value. That covers every PropertyCallback builder at once, including the Bun.* getter wrapper. Bun picked it up with the WebKit bump in #34669.

This PR's test case passes unmodified against a debug ASAN build of current main (bdb7382): 3 runs, 4 pass / 0 fail each. The change is no longer needed. 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.

1 participant