Skip to content

Drop the ThrowScope from the reifyStaticProperty call sites - #306

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/reify-static-no-throwscope
Jul 17, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
robobun/reify-static-no-throwscope

Conversation

@robobun

@robobun robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

ae5110d (#282) added DECLARE_THROW_SCOPE inside setUpStaticFunctionSlot and reifyAllStaticProperties so a PropertyCallback builder that throws can be detected and propagated. A ThrowScope's destructor unconditionally simulates a throw to its caller, so this made both functions look throwing to the exception-scope verifier.

setUpStaticFunctionSlot runs on every first lookup of a static hashtable property (via getStaticPropertySlotFromTablegetOwnPropertySlot), and JSC-internal callers of that path never check afterward. With validateExceptionChecks=1 they crash even though nothing threw:

ERROR: Unchecked JS exception:
    This scope can throw a JS exception: setUpStaticFunctionSlot @ Lookup.cpp:62
    But the exception was unchecked as of this scope: initializeTemplateObjects @ CodeBlock.cpp:1119

reifyAllStaticProperties has the same issue for JSObject::deleteProperty, which reaches it without a scope.

This keeps the DeferTerminationForAWhile and the early-return-on-exception, but reads vm.exceptionForInspection() directly instead of declaring a ThrowScope. That preserves #282's behaviour (report the slot as not found when the builder left an exception pending, bail from the reify-all loop) without forcing a simulated throw onto callers that have never had to handle one. LazyProperty::callFunc, the original model for #282's defer-termination, does not declare a scope either.

Repro

Build bun with WEBKIT_VERSION at any post-#282 commit (ae5110d3071f or later), then on a debug/ASAN build:

BUN_JSC_validateExceptionChecks=1 bun-debug test test/js/bun/globals.test.js

fails at the globals are deletable test (and ~20 other tests in oven-sh/bun#34428's CI run https://buildkite.com/bun/bun/builds/74528 debian-13 x64-asan lane).

Verification

Built bun locally against this branch (debug+ASAN, validateExceptionChecks=1):

test 722f2a8 this branch
test/js/bun/globals.test.js 1 fail 0 fail
test/cli/run/shell-keepalive.test.ts 2 fail 0 fail
test/js/bun/resolve/toml/toml.test.js crash 0 fail
test/config/bunfig/preload.test.ts 7 fail 0 fail
test/js/bun/css/color.test.ts 5 fail 0 fail

The #282 worker-terminate repro (terminate while process.stdout is being lazily built) still passes.

ae5110d added DECLARE_THROW_SCOPE inside setUpStaticFunctionSlot and
reifyAllStaticProperties so a PropertyCallback builder that throws can be
detected and propagated. A ThrowScope's destructor unconditionally
simulates a throw to its caller, so this made both functions look
throwing to the exception-scope verifier. setUpStaticFunctionSlot runs on
every first lookup of a static hashtable property via getOwnPropertySlot,
and JSC-internal callers of that path (CodeBlock::finishCreation via
initializeTemplateObjects, among others) never check afterward, so with
validateExceptionChecks=1 they now crash even though nothing threw.
reifyAllStaticProperties has the same issue for JSObject::deleteProperty.

Keep the DeferTerminationForAWhile and the early-return-on-exception,
but read vm.exceptionForInspection() directly instead of declaring a
ThrowScope. That preserves the ae5110d behaviour (report the slot as
not found when the builder left an exception pending, bail from the
reify-all loop) without forcing a simulated throw onto callers that have
never had to handle one.

@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, but this touches JSC's exception-scope machinery on the core property-lookup path, so it's worth a human sign-off.

Checked that vm.exceptionForInspection() reads m_exception directly (VM.h:840) — matches the intent of detecting a builder-thrown exception without the ThrowScope destructor's simulated throw. Confirmed LazyProperty::callFunc (the cited precedent) uses DeferTerminationForAWhile with no ThrowScope. In reifyAllStaticProperties the defer scope is still active at the check, and in setUpStaticFunctionSlot the ForAWhile variant doesn't re-throw on destruction, so reading m_exception directly is equivalent to the prior RETURN_IF_EXCEPTION for the non-termination case.

Extended reasoning...

Overview

This PR adjusts two call sites that #282 recently modified: setUpStaticFunctionSlot in Lookup.cpp and JSObject::reifyAllStaticProperties in JSObject.cpp. #282 had added DECLARE_THROW_SCOPE + RETURN_IF_EXCEPTION so that a PropertyCallback builder throwing during lazy reification would be detected. The problem is that a ThrowScope's destructor unconditionally simulates a throw to its caller under validateExceptionChecks=1, and these functions sit under getOwnPropertySlot / deleteProperty — paths whose many internal callers have never needed to check for an exception. The fix keeps the DeferTerminationForAWhile guard and the early-return-on-exception, but reads vm.exceptionForInspection() directly instead of declaring a ThrowScope.

Security risks

None. This is purely about the exception-scope verifier's bookkeeping and early-return control flow; no new data flows, no auth/crypto/permissions surface.

Level of scrutiny

High. Both sites are on critical JSC runtime paths — setUpStaticFunctionSlot runs on every first lookup of a static hashtable property, and reifyAllStaticProperties is reached from JSObject::deleteProperty. The semantics of RETURN_IF_EXCEPTION (which routes through vm.traps().maybeNeedHandling() / hasExceptionsAfterHandlingTraps()) vs. a raw m_exception read differ subtly around trap handling. I convinced myself the difference is benign here (termination is deferred, and the ForAWhile variant re-arms the trap rather than re-throwing on destruction, so only a real builder exception can be in m_exception at the check), but JSC's exception-scope verifier is subtle enough that a maintainer familiar with it should confirm.

Other factors

The PR description is thorough: it includes a concrete repro (BUN_JSC_validateExceptionChecks=1 on the bun test suite), a before/after table across five affected tests, and confirmation that #282's original worker-terminate repro still passes. The cited precedent (LazyProperty::callFunc) checks out — it uses DeferTerminationForAWhile with no ThrowScope. exceptionForInspection() is documented in VM.h as intended for exactly this use ("check for any pending exception without interfering with Throw/CatchScopes"). The bug-hunting pass found nothing. I'm deferring solely because this is core runtime exception-handling code, not because I see a problem.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
1eb2de4b autobuild-preview-pr-306-1eb2de4b 2026-07-17 11:14:00 UTC

@Jarred-Sumner
Jarred-Sumner merged commit a8d15c1 into main Jul 17, 2026
49 checks passed
robobun added a commit that referenced this pull request Jul 18, 2026
ae5110d added DECLARE_THROW_SCOPE inside setUpStaticFunctionSlot and
reifyAllStaticProperties so a PropertyCallback builder that throws can be
detected and propagated. A ThrowScope's destructor unconditionally
simulates a throw to its caller, so this made both functions look
throwing to the exception-scope verifier. setUpStaticFunctionSlot runs on
every first lookup of a static hashtable property via getOwnPropertySlot,
and JSC-internal callers of that path (CodeBlock::finishCreation via
initializeTemplateObjects, among others) never check afterward, so with
validateExceptionChecks=1 they now crash even though nothing threw.
reifyAllStaticProperties has the same issue for JSObject::deleteProperty.

Keep the DeferTerminationForAWhile and the early-return-on-exception,
but read vm.exceptionForInspection() directly instead of declaring a
ThrowScope. That preserves the ae5110d behaviour (report the slot as
not found when the builder left an exception pending, bail from the
reify-all loop) without forcing a simulated throw onto callers that have
never had to handle one.
robobun added a commit that referenced this pull request Jul 19, 2026
Resolves the Lookup.cpp conflict by taking main's #306 version of
setUpStaticFunctionSlot: both branches independently dropped the
ThrowScope from the reifyStaticProperty call site; main's version
checks vm.exceptionForInspection() directly and keeps the original
RELEASE_ASSERT_NOT_REACHED for the no-exception-but-no-offset case.

Also brings in #304 (Windows WallTime/MonotonicTime) and #310
(preCommitStackMemory guard).
robobun added a commit that referenced this pull request Aug 20, 2026
…throws

In this fork a static-table PropertyCallback builder can run JS and
throw. setUpStaticFunctionSlot then reports the slot as not found so
that the caller observes the exception. Every caller treated that miss
like an ordinary miss and kept going with the exception pending:

- JSObject::getPropertySlot and JSObject::getNonIndexPropertySlot
  walked on to the prototype. A prototype that overrides
  getOwnPropertySlot (a Proxy, a host object) then ran with the
  exception pending. Exception scope verification reports this as an
  unchecked exception. getPropertySlot also read the prototype off the
  Structure* it had loaded before the builder ran, which asserts in
  Structure::storedPrototype when the builder transitioned the object
  before it threw.
- The megamorphic get_by_id, get_by_val, in_by_id and in_by_val slow
  paths walked to the end of the chain and recorded the miss in the
  MegamorphicCache for the base object's structure. The builder stores
  nothing when it throws, so the structure does not change, and every
  later megamorphic access of that property on that object returned
  undefined (false for `in`) without running the builder again.
- JSObject::getOwnStaticPropertySlot went on to the parent class
  tables.

Check for a pending exception after the own-property step on an object
that has a static property table, which is the only case in which
getOwnNonIndexPropertySlot can run a builder, and stop the lookup
there. Objects without a static table take the same path as before.
In the loops that own a ThrowScope the check is the same
RETURN_IF_EXCEPTION they already make after an overridden
getOwnPropertySlot, and it is made on a hit as well: under exception
scope verification, getNonIndexPropertySlot's own scope otherwise
reports the scope of a builder that succeeded as unchecked.
getPropertySlot has no scope of its own and checks with
exceptionForInspection() on a miss, leaving the check to its caller as
the plain-prototype case does today.

reifyAllStaticProperties runs builders back to back and has the same
verification problem between two of them: the first builder's scope is
reported as unchecked when the second one declares its scope, so
spreading or Object.assign'ing such an object aborts under
validateExceptionChecks even though nothing threw. Give it a
TopExceptionScope (not a ThrowScope, which would simulate a throw to
callers such as JSObject::deleteProperty that do not check, see #306)
and check it after each builder. A real exception still stays pending
for the caller as before.
robobun added a commit that referenced this pull request Aug 27, 2026
…throws

In this fork a static-table PropertyCallback builder can run JS and
throw. setUpStaticFunctionSlot then reports the slot as not found so
that the caller observes the exception. Every caller treated that miss
like an ordinary miss and kept going with the exception pending:

- JSObject::getPropertySlot and JSObject::getNonIndexPropertySlot
  walked on to the prototype. A prototype that overrides
  getOwnPropertySlot (a Proxy, a host object) then ran with the
  exception pending. Exception scope verification reports this as an
  unchecked exception. getPropertySlot also read the prototype off the
  Structure* it had loaded before the builder ran, which asserts in
  Structure::storedPrototype when the builder transitioned the object
  before it threw.
- The megamorphic get_by_id, get_by_val, in_by_id and in_by_val slow
  paths walked to the end of the chain and recorded the miss in the
  MegamorphicCache for the base object's structure. The builder stores
  nothing when it throws, so the structure does not change, and every
  later megamorphic access of that property on that object returned
  undefined (false for `in`) without running the builder again.
- JSObject::getOwnStaticPropertySlot went on to the parent class
  tables.

Check for a pending exception after the own-property step on an object
that has a static property table, which is the only case in which
getOwnNonIndexPropertySlot can run a builder, and stop the lookup
there. Objects without a static table take the same path as before.
In the loops that own a ThrowScope the check is the same
RETURN_IF_EXCEPTION they already make after an overridden
getOwnPropertySlot, and it is made on a hit as well: under exception
scope verification, getNonIndexPropertySlot's own scope otherwise
reports the scope of a builder that succeeded as unchecked.
getPropertySlot has no scope of its own and checks with
exceptionForInspection() on a miss, leaving the check to its caller as
the plain-prototype case does today.

reifyAllStaticProperties runs builders back to back and has the same
verification problem between two of them: the first builder's scope is
reported as unchecked when the second one declares its scope, so
spreading or Object.assign'ing such an object aborts under
validateExceptionChecks even though nothing threw. Give it a
TopExceptionScope (not a ThrowScope, which would simulate a throw to
callers such as JSObject::deleteProperty that do not check, see #306)
and check it after each builder. A real exception still stays pending
for the caller as before.
robobun added a commit that referenced this pull request Aug 28, 2026
…throws

In this fork a static-table PropertyCallback builder can run JS and
throw. setUpStaticFunctionSlot then reports the slot as not found so
that the caller observes the exception. Every caller treated that miss
like an ordinary miss and kept going with the exception pending:

- JSObject::getPropertySlot and JSObject::getNonIndexPropertySlot
  walked on to the prototype. A prototype that overrides
  getOwnPropertySlot (a Proxy, a host object) then ran with the
  exception pending. Exception scope verification reports this as an
  unchecked exception. getPropertySlot also read the prototype off the
  Structure* it had loaded before the builder ran, which asserts in
  Structure::storedPrototype when the builder transitioned the object
  before it threw.
- The megamorphic get_by_id, get_by_val, in_by_id and in_by_val slow
  paths walked to the end of the chain and recorded the miss in the
  MegamorphicCache for the base object's structure. The builder stores
  nothing when it throws, so the structure does not change, and every
  later megamorphic access of that property on that object returned
  undefined (false for `in`) without running the builder again.
- JSObject::getOwnStaticPropertySlot went on to the parent class
  tables.

Check for a pending exception after the own-property step on an object
that has a static property table, which is the only case in which
getOwnNonIndexPropertySlot can run a builder, and stop the lookup
there. Objects without a static table take the same path as before.
In the loops that own a ThrowScope the check is the same
RETURN_IF_EXCEPTION they already make after an overridden
getOwnPropertySlot, and it is made on a hit as well: under exception
scope verification, getNonIndexPropertySlot's own scope otherwise
reports the scope of a builder that succeeded as unchecked.
getPropertySlot has no scope of its own and checks with
exceptionForInspection() on a miss, leaving the check to its caller as
the plain-prototype case does today.

reifyAllStaticProperties runs builders back to back and has the same
verification problem between two of them: the first builder's scope is
reported as unchecked when the second one declares its scope, so
spreading or Object.assign'ing such an object aborts under
validateExceptionChecks even though nothing threw. Give it a
TopExceptionScope (not a ThrowScope, which would simulate a throw to
callers such as JSObject::deleteProperty that do not check, see #306)
and check it after each builder. A real exception still stays pending
for the caller as before.
robobun added a commit that referenced this pull request Aug 28, 2026
…throws

In this fork a static-table PropertyCallback builder can run JS and
throw. setUpStaticFunctionSlot then reports the slot as not found so
that the caller observes the exception. Every caller treated that miss
like an ordinary miss and kept going with the exception pending:

- JSObject::getPropertySlot and JSObject::getNonIndexPropertySlot
  walked on to the prototype. A prototype that overrides
  getOwnPropertySlot (a Proxy, a host object) then ran with the
  exception pending. Exception scope verification reports this as an
  unchecked exception. getPropertySlot also read the prototype off the
  Structure* it had loaded before the builder ran, which asserts in
  Structure::storedPrototype when the builder transitioned the object
  before it threw.
- The megamorphic get_by_id, get_by_val, in_by_id and in_by_val slow
  paths walked to the end of the chain and recorded the miss in the
  MegamorphicCache for the base object's structure. The builder stores
  nothing when it throws, so the structure does not change, and every
  later megamorphic access of that property on that object returned
  undefined (false for `in`) without running the builder again.
- JSObject::getOwnStaticPropertySlot went on to the parent class
  tables.

Check for a pending exception after the own-property step on an object
that has a static property table, which is the only case in which
getOwnNonIndexPropertySlot can run a builder, and stop the lookup
there. Objects without a static table take the same path as before.
In the loops that own a ThrowScope the check is the same
RETURN_IF_EXCEPTION they already make after an overridden
getOwnPropertySlot, and it is made on a hit as well: under exception
scope verification, getNonIndexPropertySlot's own scope otherwise
reports the scope of a builder that succeeded as unchecked.
getPropertySlot has no scope of its own and checks with
exceptionForInspection() on a miss, leaving the check to its caller as
the plain-prototype case does today.

reifyAllStaticProperties runs builders back to back and has the same
verification problem between two of them: the first builder's scope is
reported as unchecked when the second one declares its scope, so
spreading or Object.assign'ing such an object aborts under
validateExceptionChecks even though nothing threw. Give it a
TopExceptionScope (not a ThrowScope, which would simulate a throw to
callers such as JSObject::deleteProperty that do not check, see #306)
and check it after each builder. A real exception still stays pending
for the caller as before.
robobun added a commit that referenced this pull request Aug 28, 2026
…throws

In this fork a static-table PropertyCallback builder can run JS and
throw. setUpStaticFunctionSlot then reports the slot as not found so
that the caller observes the exception. Every caller treated that miss
like an ordinary miss and kept going with the exception pending:

- JSObject::getPropertySlot and JSObject::getNonIndexPropertySlot
  walked on to the prototype. A prototype that overrides
  getOwnPropertySlot (a Proxy, a host object) then ran with the
  exception pending. Exception scope verification reports this as an
  unchecked exception. getPropertySlot also read the prototype off the
  Structure* it had loaded before the builder ran, which asserts in
  Structure::storedPrototype when the builder transitioned the object
  before it threw.
- The megamorphic get_by_id, get_by_val, in_by_id and in_by_val slow
  paths walked to the end of the chain and recorded the miss in the
  MegamorphicCache for the base object's structure. The builder stores
  nothing when it throws, so the structure does not change, and every
  later megamorphic access of that property on that object returned
  undefined (false for `in`) without running the builder again.
- JSObject::getOwnStaticPropertySlot went on to the parent class
  tables.

Check for a pending exception after the own-property step on an object
that has a static property table, which is the only case in which
getOwnNonIndexPropertySlot can run a builder, and stop the lookup
there. Objects without a static table take the same path as before.
In the loops that own a ThrowScope the check is the same
RETURN_IF_EXCEPTION they already make after an overridden
getOwnPropertySlot, and it is made on a hit as well: under exception
scope verification, getNonIndexPropertySlot's own scope otherwise
reports the scope of a builder that succeeded as unchecked.
getPropertySlot has no scope of its own and checks with
exceptionForInspection() on a miss, leaving the check to its caller as
the plain-prototype case does today.

reifyAllStaticProperties runs builders back to back and has the same
verification problem between two of them: the first builder's scope is
reported as unchecked when the second one declares its scope, so
spreading or Object.assign'ing such an object aborts under
validateExceptionChecks even though nothing threw. Give it a
TopExceptionScope (not a ThrowScope, which would simulate a throw to
callers such as JSObject::deleteProperty that do not check, see #306)
and check it after each builder. A real exception still stays pending
for the caller as before.
robobun added a commit that referenced this pull request Aug 28, 2026
…throws

In this fork a static-table PropertyCallback builder can run JS and
throw. setUpStaticFunctionSlot then reports the slot as not found so
that the caller observes the exception. Every caller treated that miss
like an ordinary miss and kept going with the exception pending:

- JSObject::getPropertySlot and JSObject::getNonIndexPropertySlot
  walked on to the prototype. A prototype that overrides
  getOwnPropertySlot (a Proxy, a host object) then ran with the
  exception pending. Exception scope verification reports this as an
  unchecked exception. getPropertySlot also read the prototype off the
  Structure* it had loaded before the builder ran, which asserts in
  Structure::storedPrototype when the builder transitioned the object
  before it threw.
- The megamorphic get_by_id, get_by_val, in_by_id and in_by_val slow
  paths walked to the end of the chain and recorded the miss in the
  MegamorphicCache for the base object's structure. The builder stores
  nothing when it throws, so the structure does not change, and every
  later megamorphic access of that property on that object returned
  undefined (false for `in`) without running the builder again.
- JSObject::getOwnStaticPropertySlot went on to the parent class
  tables.

Check for a pending exception after the own-property step on an object
that has a static property table, which is the only case in which
getOwnNonIndexPropertySlot can run a builder, and stop the lookup
there. Objects without a static table take the same path as before.
In the loops that own a ThrowScope the check is the same
RETURN_IF_EXCEPTION they already make after an overridden
getOwnPropertySlot, and it is made on a hit as well: under exception
scope verification, getNonIndexPropertySlot's own scope otherwise
reports the scope of a builder that succeeded as unchecked.
getPropertySlot has no scope of its own and checks with
exceptionForInspection() on a miss, leaving the check to its caller as
the plain-prototype case does today.

reifyAllStaticProperties runs builders back to back and has the same
verification problem between two of them: the first builder's scope is
reported as unchecked when the second one declares its scope, so
spreading or Object.assign'ing such an object aborts under
validateExceptionChecks even though nothing threw. Give it a
TopExceptionScope (not a ThrowScope, which would simulate a throw to
callers such as JSObject::deleteProperty that do not check, see #306)
and check it after each builder. A real exception still stays pending
for the caller as before.
robobun added a commit that referenced this pull request Aug 28, 2026
…throws

In this fork a static-table PropertyCallback builder can run JS and
throw. setUpStaticFunctionSlot then reports the slot as not found so
that the caller observes the exception. Every caller treated that miss
like an ordinary miss and kept going with the exception pending:

- JSObject::getPropertySlot and JSObject::getNonIndexPropertySlot
  walked on to the prototype. A prototype that overrides
  getOwnPropertySlot (a Proxy, a host object) then ran with the
  exception pending. Exception scope verification reports this as an
  unchecked exception. getPropertySlot also read the prototype off the
  Structure* it had loaded before the builder ran, which asserts in
  Structure::storedPrototype when the builder transitioned the object
  before it threw.
- The megamorphic get_by_id, get_by_val, in_by_id and in_by_val slow
  paths walked to the end of the chain and recorded the miss in the
  MegamorphicCache for the base object's structure. The builder stores
  nothing when it throws, so the structure does not change, and every
  later megamorphic access of that property on that object returned
  undefined (false for `in`) without running the builder again.
- JSObject::getOwnStaticPropertySlot went on to the parent class
  tables.

Check for a pending exception after the own-property step on an object
that has a static property table, which is the only case in which
getOwnNonIndexPropertySlot can run a builder, and stop the lookup
there. Objects without a static table take the same path as before.
In the loops that own a ThrowScope the check is the same
RETURN_IF_EXCEPTION they already make after an overridden
getOwnPropertySlot, and it is made on a hit as well: under exception
scope verification, getNonIndexPropertySlot's own scope otherwise
reports the scope of a builder that succeeded as unchecked.
getPropertySlot has no scope of its own and checks with
exceptionForInspection() on a miss, leaving the check to its caller as
the plain-prototype case does today.

reifyAllStaticProperties runs builders back to back and has the same
verification problem between two of them: the first builder's scope is
reported as unchecked when the second one declares its scope, so
spreading or Object.assign'ing such an object aborts under
validateExceptionChecks even though nothing threw. Give it a
TopExceptionScope (not a ThrowScope, which would simulate a throw to
callers such as JSObject::deleteProperty that do not check, see #306)
and check it after each builder. A real exception still stays pending
for the caller as before.
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