Skip to content

Check exceptions between static property builders, in the JSONL parse loop and in the JSONP fast path - #514

Open
robobun wants to merge 1 commit into
mainfrom
robobun/exception-checks-static-reify-jsonl
Open

Check exceptions between static property builders, in the JSONL parse loop and in the JSONP fast path#514
robobun wants to merge 1 commit into
mainfrom
robobun/exception-checks-static-reify-jsonl

Conversation

@robobun

@robobun robobun commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Three JSC paths run a second throwing operation, or return from a ThrowScope, while the previous throwing call has not been checked. A debug build aborts on each under validateExceptionChecks=1: ERROR: Unchecked JS exception from JSC::VM::verifyExceptionCheckNeedIsSatisfied.
  • JSObject::reifyAllStaticProperties (JSObject.cpp:2978) and setUpStaticFunctionSlot (Lookup.cpp:73) read vm.exceptionForInspection() after a PropertyCallback builder. That read is not a check, so a builder with its own ThrowScope leaves the check pending and the next builder's scope constructor asserts. Bun's node:module and Bun.sql lazy properties are built this way (bun's test/no-validate-exceptions.txt lists 8 tests for this).
  • LiteralParser::tryStreamingParse (LiteralParser.cpp:1985) calls parseRecursively per line with no scope and no check. Interpreter::executeProgram's JSONP doGet lambda (Interpreter.cpp:1135) calls PropertySlot::getValue after getPropertySlot with no check.

Fix

  • The two reify sites use vm.exception(), which is the check the builder's scope needs. Behavior is unchanged: both already returned early on a pending exception.
  • tryStreamingParse declares a ThrowScope and checks after each value. An out-of-memory or stack-overflow exception is no longer reported as Status::Error with the exception still pending; the caller (Bun.JSONL.parse in bun, which checks right after streamingJSONParse) reports it.
  • doGet checks between getPropertySlot and getValue. Both call sites already check right after doGet.
  • Verified: read against the validator's rules in ThrowScope.cpp / VM::verifyExceptionCheckNeedIsSatisfied. I could not build JSC in the environment this was written in; CI is the build check. The bun side reproduces the first case with BUN_JSC_validateExceptionChecks=1 bun -e 'Bun.sql' on a debug build.

Background

  • BUN_JSC_validateExceptionChecks=1 makes every ThrowScope destructor set VM::m_needExceptionCheck. The next ThrowScope constructor or non-released destructor asserts if the bit is still set. Only VM::exception() (and clearException) clears it. exceptionForInspection() reads m_exception without touching the bit, for code that explicitly must not count as a check.
  • A static hash table PropertyCallback builder has the signature JSValue(VM&, JSObject*), so it cannot propagate an exception to reifyStaticProperty. The bun fork already lets it return empty with an exception pending; the two callers check and stop. This change only makes that check visible to the validator.

… loop and in the JSONP fast path

Three places ran a second throwing operation, or returned from a
ThrowScope, while the previous one had not been checked. Each one
aborts a debug build under validateExceptionChecks=1.

JSObject::reifyAllStaticProperties and setUpStaticFunctionSlot read
vm.exceptionForInspection() after a PropertyCallback builder. That read
does not count as a check, so a builder with its own ThrowScope (a
released one simulates a throw) leaves the check pending and the next
builder's scope asserts. Bun's node:module and Bun.sql builders do
this. vm.exception() is the check.

LiteralParser::tryStreamingParse called parseRecursively for every
line with no ThrowScope and no check in between. An out-of-memory or
stack-overflow exception was reported as a parse error and the second
line ran with it pending.

Interpreter::executeProgram's JSONP doGet called PropertySlot::getValue
after getPropertySlot with no check. A vm.Context sandbox can throw
from getOwnPropertySlot.
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 27 days. After that, they cost $0.25 per reviewed file.

Or wait 4 minutes for your next included review.

View limit details

Limit details: You’ve used all 5 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4e11869a-0e09-4e35-b8d0-2e1bfb12b626

📥 Commits

Reviewing files that changed from the base of the PR and between 94c452c and 98cb92f.

📒 Files selected for processing (4)
  • Source/JavaScriptCore/interpreter/Interpreter.cpp
  • Source/JavaScriptCore/runtime/JSObject.cpp
  • Source/JavaScriptCore/runtime/LiteralParser.cpp
  • Source/JavaScriptCore/runtime/Lookup.cpp

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

if (vm.exceptionForInspection()) [[unlikely]]
// lookup, and callers of getOwnPropertySlot don't check for one. This
// read is the exception check for the builder's own ThrowScope.
if (vm.exception()) [[unlikely]]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 VM::exception() is private (VM.h:1235, inside the private: block at 1205–1289) and only reachable through the fixed friend list at VM.h:1396–1407 — neither setUpStaticFunctionSlot nor JSObject is on it, so this line and the matching call at Source/JavaScriptCore/runtime/JSObject.cpp:2981 will fail to compile with "'exception' is a private member of 'JSC::VM'". The intent (making the read count as a validator check) is right, but it needs to go through an ExceptionScope — e.g. declare a local DECLARE_CATCH_SCOPE and test scope.exception() — rather than calling the private member directly.

Extended reasoning...

What the bug is

The PR replaces vm.exceptionForInspection() with vm.exception() at two sites — setUpStaticFunctionSlot (Lookup.cpp:74) and JSObject::reifyAllStaticProperties (JSObject.cpp:2981) — to make the post-builder exception read count as a check for validateExceptionChecks=1. However, VM::exception() is a private member of class VM, so both call sites fail to compile.

Where it lives in the header

In Source/JavaScriptCore/runtime/VM.h:

  • private: opens at line 1205.
  • Exception* exception() const is declared at line 1235.
  • The next public: is at line 1290.

So exception() sits squarely in a private section. By contrast, exceptionForInspection() (line 850) is in the public section that opens at line 473, which is why the pre-PR code compiled.

Access to VM::exception() is granted only via the friend list at VM.h:1396–1407: Heap, ExceptionScope, TopExceptionScope, ThrowScope, JSDollarVMHelper, LLIntOffsetsExtractor, TerminationDeadline, SuspendExceptionScope, FFI::CallbackEntryScope, and VMTraps. Neither the free function setUpStaticFunctionSlot nor the class JSObject appears there.

Why nothing else saves it

A repo-wide grep for bare vm.exception() shows every other real call site is inside one of those friends (e.g. Heap.cpp, ThrowScope.cpp, ExceptionScope.h, TopExceptionScope.h); the two lines this PR introduces are the only non-friend callers. There is no macro, using, or alternate declaration that would make exception() visible here. The PR description itself notes the author could not build JSC locally ("CI is the build check"), which is why this slipped through.

Step-by-step proof

  1. Compiler processes Lookup.cpp → includes JSCInlines.h → transitively VM.h.
  2. At Lookup.cpp:74, inside the free function bool JSC::setUpStaticFunctionSlot(...), the expression vm.exception() performs member access on a VM&.
  3. Name lookup finds VM::exception() at VM.h:1235; access checking sees it is private and the enclosing context is not VM, not a derived class, and not a friend.
  4. Compilation aborts with error: 'exception' is a private member of 'JSC::VM'.
  5. The identical failure occurs independently at JSObject.cpp:2981 inside JSObject::reifyAllStaticProperties (JSObject is not a friend of VM).

Impact

Hard build break on every platform/configuration — the PR cannot merge green.

How to fix

The goal is to clear VM::m_needExceptionCheck, which VM::exception() does but exceptionForInspection() does not. The public route to that is an ExceptionScope: ExceptionScope::exception() calls m_vm.exception() via friendship. Concretely, declare a local scope and read through it, e.g.:

auto catchScope = DECLARE_CATCH_SCOPE(vm);
reifyStaticProperty(...);
if (catchScope.exception()) [[unlikely]]
    return ...;

A CatchScope is preferable to a ThrowScope at both sites — the existing comment in Lookup.cpp already explains why a ThrowScope is undesirable there (it would simulate a throw on every first static-table lookup), and reifyAllStaticProperties similarly notes "No ThrowScope here". Alternatively, thread an ExceptionScope& in from the caller if one is already in hand.

dylan-conway pushed a commit to oven-sh/bun that referenced this pull request Aug 25, 2026
…40410)

### Problem
- Native code must check for a pending JS exception before the next
JS-observable call and before it returns from a `ThrowScope`. A missing
check asserts on debug builds (`ERROR: Unchecked JS exception` from
`VM::verifyExceptionCheckNeedIsSatisfied`). On release builds the code
runs on a dummy result, a later unrelated check misattributes the error,
or a second throw overwrites it.
- Coverage was dynamic only (`BUN_JSC_validateExceptionChecks=1` on the
ASAN lane), so a path no test executes was never checked.

### Fix
- `scripts/jsc-exception-lint`: a clang LibTooling checker that models
the validator's state machine over the CFG of every function in
`src/**/*.cpp`. Callees are classified from visible bodies, then from
summary passes over the JavaScriptCore sources and Bun's bindings, then
from the `JSGlobalObject*` / `ThrowScope&` convention. `run.ts` drives
it; `rust-externs.ts` cross-checks hand-declared Rust externs.
- Fixes its findings: `RETURN_IF_EXCEPTION` after the throwing call,
`RELEASE_AND_RETURN` for tail calls, `asNumber()`/`asInt32()` after a
type check instead of the throwing coercion, one nested
`DECLARE_THROW_SCOPE` removed. Rust externs whose C++ body throws go
through the scope helpers and return `JsResult`. No termination special
cases, no `clearException`.
- Not touched: the files and functions #40068 and #40249 cover (napi,
v8, JSMockFunction, ErrorCode, MIME, asymmetric matchers). The JSC-side
sites are in oven-sh/WebKit#514; their skip-list entries stay until that
bump.
- Verified: `test/js/bun/jsc/exception-checks.test.ts` (new; each
snippet aborted the validator before), the affected suites under the
validator (sqlite, process, ffi, headers, streams, workers, vm, buffer,
crypto), and `bun run rust:check` for linux, windows and macOS.

### Background
- The validator: every `ThrowScope` destructor sets
`VM::m_needExceptionCheck`. The next `ThrowScope` constructor or
non-released destructor asserts if it is still set. Only `exception()`
(what `RETURN_IF_EXCEPTION` expands to), `clearException()` and
`assertNoException` clear it. The tool reports the states in which those
asserts fire, plus a call made after the function already threw.
- A summary is a callee's exit state set: clean, check pending, thrown,
or conditional thrower (the caller tests the return value; reported only
with `--kind maybe-thrown-call`). Each pass resolves one more level of
cross-file calls, so JSC gets three passes (cached per WebKit version).
- Rust-implemented `extern "C"` functions run under their own scope and
signal a throw with a sentinel, so the C++ side sees them as conditional
throwers.

<details><summary>Notes</summary>

Numbers. First pass over `src/jsc/bindings` with only the signature
convention: 2194 findings. With JSC and Bun summaries, the
TopExceptionScope model, template-aware carrier detection, and the
Rust-extern rule: 667 findings in 103 files (480 pending-call, 164
unchecked-exit, 13 nested scope, 10 call-after-throw). 603 were in scope
for this PR after excluding the files above. After the fixes: 113 in 21
files, of which 72 are in the excluded files and the rest were reviewed
as false positives (generated `JSSink` and `ZigGeneratedClasses` code,
`JSSetIterator::next` in `Keys` mode, `JSFunction::name`,
`getCalculatedDisplayName`, `rejectWithCaughtException` right after a
throw, global object construction). Those need entries in `nothrow.txt`
or the summary pass over `build/*/codegen` the driver now does.

What the tool does not see: a throwing call whose result is passed
straight into another call and then `RELEASE_AND_RETURN` (the
JSMockFunction pattern #40068 fixes) is legal for the validator and not
reported. That needs value tracking. Exceptions observed only through a
return value (`if (!result) return {}` after a helper that throws into
the caller's scope) are reported as `maybe-thrown-call` and hidden by
default.

Running it: `bun scripts/jsc-exception-lint/run.ts` needs a configured
debug build (`build/debug/compile_commands.json`) and the LLVM 21
development package (`libclang-cpp`, headers; CI's `llvm.sh 21 all`
installs them). The first run parses the JSC sources three times (about
30 minutes, cached in `build/debug/jsc-exception-lint/`); later runs
take about 15 minutes. A CI step on the linux debug lane after the C++
build is the natural next step; this PR does not add it.

How the fixes were made: the findings were split by file and fixed in
parallel under one written rule set (`RETURN_IF_EXCEPTION` only, no
termination special cases, report false positives instead of editing),
then the tree was rebuilt, re-analyzed, and a second pass handled the
remainder. I reverted two of the resulting hunks by hand (a scope added
to `rsisDetachNativeTransform`, a no-op branch in
`JSCTaskScheduler.cpp`) because they rested on a stale classification of
callees that cannot throw.

Dynamic runs: `test/js/bun/util/BunObject.test.ts` and
`test/js/bun/jsonl/jsonl-parse.test.ts` still abort under the validator
on the JSC-side sites (oven-sh/WebKit#514).
`test/js/node/test/parallel/test-repl-inspect-defaults.js` is the JSONP
`doGet` case in the same PR. Tests that failed in my container (worker
message flood, ffi FTL warm-up, node:util parseArgs stress, stdin
fixtures, IPv6 fetch, root-permission checks) fail identically on an
unmodified main build there.
</details>

<!-- robobun:evidence:begin -->

---

**[review]** gate passed · iteration 0 · 115 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/jsc/exception-checks.test.ts
bun test v1.4.1 (4448a2e)

test/js/bun/jsc/exception-checks.test.ts:
(pass) process.exitCode assigned a rope string [272.39ms]
(pass) process.kill with an unknown rope signal name [268.19ms]
(pass) process.umask with a rope string [340.13ms]
42 |     // them in the comparison so a failure names the call site.
43 |     const unchecked = stderr
44 |       .split("\n")
45 |       .map(line => line.trim())
46 |       .filter(line => line.startsWith("This scope can throw") || line.startsWith("But the exception was unchecked"));
47 |     expect({ stdout: stdout.trim(), unchecked, exitCode }).toEqual({ stdout: expected, unchecked: [], exitCode: 0 });
                                                                ^
error: expect(received).toEqual(expected)

  {
-   "exitCode": 0,
-   "stdout": "TypeError: Expected 2 values to compare",
-   "unchecked": [],
+   "exitCode": 134,
+   "stdout": "",
+   "unchecked": [
+     "This scope can throw a JS exception: functionBunDeepEquals @ ../../src/jsc/bindin
... (truncated)

release without fix: all passed
bun test v1.4.1-canary.1 (a95369a)

test/js/bun/jsc/exception-checks.test.ts:
(pass) process.umask with a rope string [4.80ms]
(pass) Bun.deepEquals with one argument [5.74ms]
(pass) process.exitCode assigned a rope string [4.57ms]
(pass) process.kill with an unknown rope signal name [4.33ms]

 4 pass
 0 fail
 4 expect() calls
Ran 4 tests across 1 file. [91.00ms]
__F:0:S:0
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
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/bun/jsc/exception-checks.test.ts
bun test v1.4.1 (4448a2e)

test/js/bun/jsc/exception-checks.test.ts:
(pass) Bun.deepEquals with one argument [313.84ms]
(pass) process.umask with a rope string [277.49ms]
(pass) process.exitCode assigned a rope string [276.65ms]
(pass) process.kill with an unknown rope signal name [272.38ms]

 4 pass
 0 fail
 4 expect() calls
Ran 4 tests across 1 file. [2.32s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 628ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/60] gen ProcessBindingHTTPParser.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingHTTPParser.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingHTTPParser.cpp
[2/60] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[3/60] gen cpp.rs (cppbind)
[4/60] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[5/60] gen generated_host_exports.rs
generated_host_exports.rs: 120 exports (host=5, lazy=10, generic=105, rust=0); 242 extern-C blocks audited
[6/60] gen BunObject.lut.h
Generating /workspace/bun/build/release/codegen/BunObject.lut.h from /workspace/bun/src/jsc/bindings/BunObject.cpp
[7/60] gen JS modules (bundle-modules)
Preprocess modules (7492ms)
Bundle modules (48ms)
Postprocesss modules (96ms)
Bundle Functions (491ms)
Generate Code (32ms)

[8.17s] Bundled "src/js" for production
  2594 kb
  197 internal modules
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
scripts/jsc-exception-lint/README.md               |   83 ++
 scripts/jsc-exception-lint/jsc-exception-lint.cpp  | 1184 ++++++++++++++++++++
 scripts/jsc-exception-lint/nothrow.txt             |  112 ++
 scripts/jsc-exception-lint/run.ts                  |  450 ++++++++
 scripts/jsc-exception-lint/rust-externs.ts         |  142 +++
 src/http_jsc/headers_jsc.rs                        |    2 -
 src/jsc/ConsoleObject.rs                           |    4 +-
 src/jsc/FetchHeaders.rs                            |   18 +-
 src/jsc/JSGlobalObject.rs                          |   34 +-
 src/jsc/JSObject.rs                                |   18 +-
 src/jsc/JSUint8Array.rs                            |   23 +-
 src/jsc/JSValue.rs                                 |   43 +-
 src/jsc/VirtualMachine.rs                          |    7 +-
 src/jsc/array_buffer.rs                            |   16 +-
 src/jsc/bindings/BunDebugger.cpp                   |    7 +-
 src/jsc/bindings/BunInjectedScriptHost.cpp         |   34 +-
 src/jsc/bindings/BunObject.cpp                     |   13 +-
 src/jsc/bindings/BunPlugin.cpp                     |   20 +-
 src/jsc/bindings/BunProcess.cpp                    |   72 +-
 src/jsc/bindings/BunProcessReportObjectWindows.cpp |    1 +
 src/jsc/bindings/BunString.cpp                     |    2 +
 src/jsc/bindings/CallSite.cpp                      |    5 +
 src/jsc/bindings/CallSitePrototype.cpp             |    1 +
 src/jsc/bindings/ConsoleObject.cpp                 |    7 +-
 src/jsc/bindings/ErrorStackTrace.cpp               |   39 +-
 src/jsc/bindings/FormatStackTraceForJS.cpp         |    9 +-
 src/jsc/bindings/HTMLEntryPoint.cpp                |    2 +-
 src/jsc/bindings/ImportMetaObject.cpp              |    1 -
 src/jsc/bindings/InspectorLifecycleAgent.cpp       |    1 +
 src/jsc/bindings/InternalModuleRegistry.cpp        |    1 +
 src/jsc/bindings/JSBuffer.cpp                      |   12 +-
 src/jsc/bindings/JSCTestingHelpers.cpp       
... (truncated)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                               reads  edits  tests
scripts/jsc-exception-lint/README.md                   0      1      0
scripts/jsc-exception-lint/jsc-exception-lint.cpp      2      4      0
scripts/jsc-exception-lint/nothrow.txt                 0      1      0
scripts/jsc-exception-lint/run.ts                      0      1      0
scripts/jsc-exception-lint/rust-externs.ts             0      1      0
src/http_jsc/headers_jsc.rs                            0      0      0
src/jsc/ConsoleObject.rs                               0      0      0
src/jsc/FetchHeaders.rs                                0      0      0
src/jsc/JSGlobalObject.rs                              0      0      0
src/jsc/JSObject.rs                                    0      0      0
src/jsc/JSUint8Array.rs                                0      0      0
src/jsc/JSValue.rs                                     0      0      0
src/jsc/VirtualMachine.rs                              0      0      0
src/jsc/array_buffer.rs                                0      0      0
src/jsc/bindings/BunDebugger.cpp                       0      0      0
src/jsc/bindings/BunInjectedScriptHost.cpp             0      0      0
(+ 99 more files)
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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.

1 participant