Skip to content

Defer termination at the reifyStaticProperty call sites and propagate builder exceptions - #282

Merged
Jarred-Sumner merged 3 commits into
mainfrom
robobun/defer-termination-lazy-property-callback
Jul 17, 2026
Merged

Defer termination at the reifyStaticProperty call sites and propagate builder exceptions#282
Jarred-Sumner merged 3 commits into
mainfrom
robobun/defer-termination-lazy-property-callback

Conversation

@robobun

@robobun robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

LazyPropertyCallback runs inside reifyStaticPropertysetUpStaticFunctionSlotgetOwnPropertySlot, none of which check for exceptions after the builder. When worker.terminate() arms the termination trap while a builder enters JS, the trap fires inside the builder and the TerminationException stays pending after it returns; getOwnPropertySlot then returns true with an exception pending and trips EXCEPTION_ASSERT(!scope.exception() || !hasSlot) in JSValue::get / JSObject::getOwnPropertyDescriptor.

LazyProperty::callFunc already wraps its initializer in DeferTerminationForAWhile for the same reason. This does the same at the two call sites of reifyStaticProperty that can hit a PropertyCallback builder (kept out of Lookup.h to avoid pulling DeferTermination.h into the header):

  • setUpStaticFunctionSlot (Lookup.cpp): defer across the reify; afterwards, if an exception is pending, return false so getOwnPropertySlot reports the slot as not found and the caller's EXCEPTION_ASSERT(!scope.exception() || !result) holds.
  • JSObject::reifyAllStaticProperties: defer across the loop; on exception, bail without setting staticPropertiesReified(true) so the remaining properties stay lazy and the caller propagates.

reifyStaticProperty's PropertyCallback arm also skips putDirect when the callback returns an empty value, so an empty JSValue never lands in the slot.

Bun hits this via its process.stdout/stderr/stdin/nextTick builders, which enter JS to load node:stream: oven-sh/bun#33966 adds per-builder DeferTerminationForAWhile scopes as an interim fix that become redundant once this lands and the WebKit version is bumped. Deterministic repro on a debug build:

const w = new Worker("data:text/javascript," + encodeURIComponent(
  'postMessage("go"); Bun.sleepSync(300); process["stdout"];'
));
w.addEventListener("message", () => w.terminate());

(aborts with the !scope.exception() || !hasSlot assertion on every run before the fix.)

LazyPropertyCallback runs inside reifyStaticProperty -> setUpStaticFunctionSlot
-> getOwnPropertySlot, none of which check for exceptions after the builder.
When worker.terminate() arms the termination trap while a builder enters JS, the
trap fires inside the builder and the TerminationException stays pending after
it returns; getOwnPropertySlot then returns true with an exception pending and
trips EXCEPTION_ASSERT(!scope.exception() || !hasSlot) in JSValue::get /
JSObject::getOwnPropertyDescriptor.

LazyProperty::callFunc already wraps its initializer in DeferTerminationForAWhile
for the same reason. Do the same for the PropertyCallback arm of
reifyStaticProperty, covering both callers (setUpStaticFunctionSlot from
getOwnPropertySlot and reifyAllStaticProperties from deleteProperty).
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7cfb6053-1329-4236-82ef-77153b0c3bc5

📥 Commits

Reviewing files that changed from the base of the PR and between dd23e28 and e9de788.

📒 Files selected for processing (2)
  • Source/JavaScriptCore/runtime/JSObject.cpp
  • Source/JavaScriptCore/runtime/Lookup.cpp

Walkthrough

Changes

Static property reification exception handling

Layer / File(s) Summary
Guard callback reification results
Source/JavaScriptCore/runtime/Lookup.h
Property callbacks now return without storing an empty value when an exception is pending.
Defer termination and propagate exceptions
Source/JavaScriptCore/runtime/Lookup.cpp, Source/JavaScriptCore/runtime/JSObject.cpp
Static property reification defers termination and exits when vm.exceptionForInspection() detects an exception.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the bug, fix, and repro, but it omits the required Bugzilla/link, Reviewed by line, and commit-message template fields. Add the bug title, Bugzilla link, Reviewed by line, and format the description to match the repository's commit-message template.
✅ Passed checks (3 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: deferring termination at reifyStaticProperty call sites and propagating builder exceptions.

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

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any issues — the change is small and mirrors the existing DeferTerminationForAWhile guard in LazyProperty::callFunc — but it sits in the core getOwnPropertySlot reification path and adjusts termination semantics, so it's worth a human sign-off.

What was reviewed:

  • Confirmed DeferTerminationForAWhile is the alias in DeferTermination.h and the include resolves.
  • Verified LazyPropertyInlines.h:102 uses the identical pattern the PR cites as precedent.
  • Checked scope placement — the guard is local to the PropertyCallback arm only, other reify branches are untouched.
Extended reasoning...

Overview

The PR adds a DeferTerminationForAWhile RAII scope around the PropertyAttribute::PropertyCallback branch of reifyStaticProperty in Lookup.h, plus the corresponding #include "DeferTermination.h". The intent is to prevent a TerminationException (armed by worker.terminate()) from firing inside a lazy-property builder that enters JS, which would otherwise leave an exception pending when getOwnPropertySlot returns true and trip EXCEPTION_ASSERT(!scope.exception() || !hasSlot).

Security risks

None identified. This does not touch auth, parsing, or memory layout. The only behavioural effect is that VM termination is deferred for the duration of the builder callback — the same trade-off already accepted for LazyProperty::callFunc.

Level of scrutiny

Medium-high. The diff is five lines, but reifyStaticProperty sits on the hot getOwnPropertySlot path for every object with a static hashtable, and it changes when a worker's termination trap can fire. The pattern is precedented (LazyPropertyInlines.h wraps its initializer identically), which lowers risk considerably, but a JSC maintainer should confirm this is the preferred fix vs. making the callers exception-aware, and that no in-tree LazyPropertyCallback can run long enough to make termination feel unresponsive.

Other factors

  • The DeferTermination.h header is self-contained and already included transitively in many runtime TUs, so the new include is low-risk for build breakage.
  • The PR description includes a deterministic repro and references a companion Bun-side interim fix (oven-sh/bun#33966), which gives good context.
  • No prior human review comments on the PR; only a rate-limited coderabbit stub.

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
dd23e28f autobuild-preview-pr-282-dd23e28f 2026-07-11 09:06:58 UTC
7876e92f autobuild-preview-pr-282-7876e92f 2026-07-11 06:32:38 UTC

Per review: keep the include out of Lookup.h. DeferTerminationForAWhile
now wraps the reifyStaticProperty call in setUpStaticFunctionSlot
(Lookup.cpp) and the reify loop in JSObject::reifyAllStaticProperties.

Also propagate a non-termination exception from a PropertyCallback
builder: reifyStaticProperty skips the putDirect when the callback
returns empty, setUpStaticFunctionSlot returns false (slot not found)
when an exception is pending so JSValue::get / getOwnPropertyDescriptor's
EXCEPTION_ASSERT(!scope.exception() || !result) holds, and
reifyAllStaticProperties leaves the remaining properties lazy.
@robobun robobun changed the title Defer termination across static-hashtable PropertyCallback builders Defer termination at the reifyStaticProperty call sites and propagate builder exceptions Jul 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Source/JavaScriptCore/runtime/JSObject.cpp`:
- Around line 2963-2968: In JSObject::deleteProperty, add an immediate
vm.exceptionForInspection() check after reifyAllStaticProperties() returns, and
return without further reading or mutating structure when an exception is
pending. Preserve the existing delete behavior when reification completes
without throwing.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0073c0fc-f836-4b6d-b784-12defd71ed99

📥 Commits

Reviewing files that changed from the base of the PR and between 234d8b3 and dd23e28.

📒 Files selected for processing (3)
  • Source/JavaScriptCore/runtime/JSObject.cpp
  • Source/JavaScriptCore/runtime/Lookup.cpp
  • Source/JavaScriptCore/runtime/Lookup.h

Comment thread Source/JavaScriptCore/runtime/JSObject.cpp

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any bugs, but this touches core JSC property-lookup and termination/exception semantics, so it's worth a human look.

Checked that DeferTerminationForAWhile is already available in JSObject.cpp (used elsewhere in the file, so no missing include).
Checked that the new return false in setUpStaticFunctionSlot keeps the EXCEPTION_ASSERT(!scope.exception() || !result) invariant for its getStaticPropertySlotFromTable caller.
Checked that reifyAllStaticProperties callers (JSObject::deleteProperty, ObjectConstructor.cpp, JSGlobalObjectFunctions.cpp) all sit under scopes that will observe the propagated exception.
Noted the third reifyStaticProperty call site — the reifyStaticProperties template in Lookup.h — is intentionally left un-deferred; worth a human confirming that path never hits a JS-entering PropertyCallback.

Extended reasoning...

Overview

This PR wraps the two reifyStaticProperty call sites that can invoke a PropertyCallback builder (setUpStaticFunctionSlot in Lookup.cpp and JSObject::reifyAllStaticProperties in JSObject.cpp) in DeferTerminationForAWhile, and teaches both to bail out when an exception is pending afterwards. It also makes reifyStaticProperty's PropertyCallback arm skip putDirect when the callback returns an empty JSValue. The goal is to stop a TerminationException armed by worker.terminate() from surfacing mid-reify and tripping EXCEPTION_ASSERT(!scope.exception() || !hasSlot) in JSValue::get / getOwnPropertyDescriptor.

Security risks

None identified. This is exception-propagation plumbing; no new inputs, parsing, auth, or memory layout changes. The if (!result) return; guard in Lookup.h is strictly defensive — it prevents an empty JSValue from being stored via putDirect, which would otherwise be the more dangerous outcome.

Level of scrutiny

High. setUpStaticFunctionSlot sits on the getOwnPropertySlot path — one of the hottest and most invariant-laden paths in the VM — and reifyAllStaticProperties changes observable state (which properties are materialized vs. still lazy, whether staticPropertiesReified gets set). The change is small and mirrors the existing LazyProperty::callFunc precedent, but the interaction between DeferTerminationForAWhile, vm.exceptionForInspection(), and callers' ThrowScope/CatchScope conventions is subtle enough that a JSC-familiar reviewer should confirm the contract.

Other factors

  • The PR description is detailed with a deterministic repro, and the pattern directly mirrors LazyProperty::callFunc (LazyPropertyInlines.h:102), which is reassuring.
  • JSObject.cpp already uses DeferTerminationForAWhile without a direct #include "DeferTermination.h" (lines ~3052/3317/3462), so the new use compiles via the existing transitive include.
  • The template reifyStaticProperties in Lookup.h is a third caller of reifyStaticProperty and is not wrapped; the PR comment names only two callers. That's likely intentional (it runs at finishCreation time, not from JS-triggered lookup), but it's the kind of judgment call a human reviewer should sign off on.
  • Early-returning from reifyAllStaticProperties leaves the object partially reified with staticPropertiesReified still false — consistent, but again worth a human confirming that's the intended semantics for the delete/assign/freeze callers.
  • No prior human review on the thread; only bot comments.

…sites

exceptionForInspection() is for assertions/diagnostics only (every other
use in JSC is inside ASSERT or #if ASSERT_ENABLED). It bypasses exception
scope verification because it doesn't clear m_needExceptionCheck.

Declare a ThrowScope and use RETURN_IF_EXCEPTION, matching the pattern at
JSGlobalObject.cpp tryInstallSpeciesWatchpoint / JSBoundFunction.cpp
nameSlow. In setUpStaticFunctionSlot the defer scope ends before the
check so a suspended termination that is re-thrown on defer exit is
observed and we return false, satisfying the caller's
EXCEPTION_ASSERT(!scope.exception() || !hasSlot).
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Replaced vm.exceptionForInspection() with a proper ThrowScope + RETURN_IF_EXCEPTION at both call sites (e9de788).

exceptionForInspection() is for assertions/diagnostics only (every other use in JSC is inside ASSERT or #if ASSERT_ENABLED); it reads m_exception directly without clearing m_needExceptionCheck, so it bypasses exception scope verification.

In setUpStaticFunctionSlot the defer scope ends before the exception check, so a suspended termination that is re-thrown on defer exit is observed and we return false, satisfying the caller's EXCEPTION_ASSERT(!scope.exception() || !hasSlot). This matches the defer+scope pattern in JSBoundFunction::nameSlow and JSGlobalObject::tryInstallSpeciesWatchpoint.

@Jarred-Sumner
Jarred-Sumner merged commit ae5110d into main Jul 17, 2026
35 of 47 checks passed

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No bugs found, but this touches core JSC property-lookup and termination/exception-scope semantics on a hot path, so it's worth a human look before merging.

What was reviewed:

  • Confirmed the defer+ThrowScope pattern matches LazyProperty::callFunc and JSBoundFunction::nameSlow.
  • Checked that getOwnStaticPropertySlot's classInfo walk continuing after a false return with a pending exception still yields false overall, so the caller's EXCEPTION_ASSERT holds.
  • Verified the empty-result guard in reifyStaticProperty only affects the PropertyCallback arm and skips putDirect rather than storing an empty JSValue.
Extended reasoning...

Overview

The PR adds DeferTerminationForAWhile + ThrowScope/RETURN_IF_EXCEPTION around the two call sites of reifyStaticProperty that can invoke a PropertyCallback builder (setUpStaticFunctionSlot in Lookup.cpp and JSObject::reifyAllStaticProperties in JSObject.cpp), and adds an empty-result guard in reifyStaticProperty's PropertyCallback arm in Lookup.h. The goal is to fix a debug assertion (!scope.exception() || !hasSlot) that fires when worker.terminate() arms the termination trap while a builder is running JS.

Security risks

None identified. This is exception-propagation plumbing; it does not change access control, parsing, or expose new surface. The empty-value guard prevents an empty JSValue from being stored via putDirect, which is strictly safer than before.

Level of scrutiny

High. setUpStaticFunctionSlot sits directly in the getOwnPropertySlot path — one of the hottest and most correctness-sensitive paths in the VM. Introducing a ThrowScope and a new early-return branch there, plus a DeferTerminationForAWhile in reifyAllStaticProperties, changes exception-scope-verification behavior and control flow in ways that are subtle to reason about (defer scope destructor ordering vs. throw-scope check, DeferForAWhile re-arming as a trap vs. re-throwing, interaction with callers that lack a ThrowScope like deleteProperty). The author's reasoning and the cited precedents look correct, but this is not a mechanical change I'm comfortable auto-approving.

Other factors

  • The PR went through an iteration: an earlier revision used vm.exceptionForInspection() and was corrected to a proper ThrowScope — the right call, but it means the current shape hasn't yet been reviewed by a human.
  • CodeRabbit's deleteProperty concern was discussed and withdrawn as pre-existing; that thread is resolved.
  • Bug hunters raised and refuted a concern about the classInfo walk continuing after setUpStaticFunctionSlot returns false with an exception; I re-checked getOwnStaticPropertySlot and agree the final result is still false, so the caller assertion holds.
  • Preview builds exist for the earlier commits but not yet for e9de788 (the ThrowScope revision).

robobun added a commit that referenced this pull request Jul 17, 2026
…ffset

#282 added a ThrowScope here so that a PropertyCallback
builder exception (or a re-thrown termination after the defer scope ends)
makes setUpStaticFunctionSlot return false. But a ThrowScope's destructor
always simulates a throw to its caller, so every getOwnStaticPropertySlot /
getOwnNonIndexPropertySlot / getPropertySlot call site on the property-lookup
fast path would need a RETURN_IF_EXCEPTION. Under validateExceptionChecks=1
the second DECLARE_THROW_SCOPE at the same depth (next table in
getOwnStaticPropertySlot's loop, or the next property lookup) asserts.

reifyStaticProperty already skips the putDirect when the callback returned
empty, so getDirectOffset stays invalid in exactly the cases the ThrowScope
was detecting. Return false on an invalid offset with an
ASSERT(vm.exceptionForInspection()) instead of forcing a viral exception
check onto the fast path.
robobun added a commit that referenced this pull request Jul 18, 2026
… builder exceptions (#282)

* Defer termination across static-hashtable PropertyCallback builders

LazyPropertyCallback runs inside reifyStaticProperty -> setUpStaticFunctionSlot
-> getOwnPropertySlot, none of which check for exceptions after the builder.
When worker.terminate() arms the termination trap while a builder enters JS, the
trap fires inside the builder and the TerminationException stays pending after
it returns; getOwnPropertySlot then returns true with an exception pending and
trips EXCEPTION_ASSERT(!scope.exception() || !hasSlot) in JSValue::get /
JSObject::getOwnPropertyDescriptor.

LazyProperty::callFunc already wraps its initializer in DeferTerminationForAWhile
for the same reason. Do the same for the PropertyCallback arm of
reifyStaticProperty, covering both callers (setUpStaticFunctionSlot from
getOwnPropertySlot and reifyAllStaticProperties from deleteProperty).

* Move the defer to the call sites and propagate exceptions

Per review: keep the include out of Lookup.h. DeferTerminationForAWhile
now wraps the reifyStaticProperty call in setUpStaticFunctionSlot
(Lookup.cpp) and the reify loop in JSObject::reifyAllStaticProperties.

Also propagate a non-termination exception from a PropertyCallback
builder: reifyStaticProperty skips the putDirect when the callback
returns empty, setUpStaticFunctionSlot returns false (slot not found)
when an exception is pending so JSValue::get / getOwnPropertyDescriptor's
EXCEPTION_ASSERT(!scope.exception() || !result) holds, and
reifyAllStaticProperties leaves the remaining properties lazy.

* Use a ThrowScope instead of exceptionForInspection at the reify call sites

exceptionForInspection() is for assertions/diagnostics only (every other
use in JSC is inside ASSERT or #if ASSERT_ENABLED). It bypasses exception
scope verification because it doesn't clear m_needExceptionCheck.

Declare a ThrowScope and use RETURN_IF_EXCEPTION, matching the pattern at
JSGlobalObject.cpp tryInstallSpeciesWatchpoint / JSBoundFunction.cpp
nameSlow. In setUpStaticFunctionSlot the defer scope ends before the
check so a suspended termination that is re-thrown on defer exit is
observed and we return false, satisfying the caller's
EXCEPTION_ASSERT(!scope.exception() || !hasSlot).
robobun added a commit to oven-sh/bun that referenced this pull request Jul 24, 2026
…nstructVersions to RETURN_IF_EXCEPTION

setUpStaticFunctionSlot now wraps reifyStaticProperty in DeferTerminationForAWhile
and treats a pending exception as slot-not-found (oven-sh/WebKit#282, #306, landed
in bun via #34373). reifyStaticProperty itself early-returns on an empty callback
result instead of putDirect'ing it. So PropertyCallback builders can
RETURN_IF_EXCEPTION(scope, {}) and the exception propagates to JS correctly.

This removes the builder-level DeferTerminationForAWhile this PR had added to
constructStdioWriteStream/constructStdin/constructEnv/constructNextTickFn and
callLazyProcessBuilder (now redundant with the JSC-side wrapping), and reverts
constructVersions to main's RETURN_IF_EXCEPTION. The pre-existing clear-and-report
patterns on main are left alone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants