Check exceptions between static property builders, in the JSONL parse loop and in the JSONP fast path - #514
Conversation
… 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.
|
Warning Review limit reached
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 detailsLimit details: You’ve used all 5 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
| 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]] |
There was a problem hiding this comment.
🔴 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() constis 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
- Compiler processes
Lookup.cpp→ includesJSCInlines.h→ transitivelyVM.h. - At Lookup.cpp:74, inside the free function
bool JSC::setUpStaticFunctionSlot(...), the expressionvm.exception()performs member access on aVM&. - Name lookup finds
VM::exception()at VM.h:1235; access checking sees it isprivateand the enclosing context is notVM, not a derived class, and not a friend. - Compilation aborts with error: 'exception' is a private member of 'JSC::VM'.
- The identical failure occurs independently at JSObject.cpp:2981 inside
JSObject::reifyAllStaticProperties(JSObjectis not a friend ofVM).
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.
…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>
Problem
validateExceptionChecks=1:ERROR: Unchecked JS exceptionfromJSC::VM::verifyExceptionCheckNeedIsSatisfied.JSObject::reifyAllStaticProperties(JSObject.cpp:2978) andsetUpStaticFunctionSlot(Lookup.cpp:73) readvm.exceptionForInspection()after aPropertyCallbackbuilder. 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'snode:moduleandBun.sqllazy properties are built this way (bun'stest/no-validate-exceptions.txtlists 8 tests for this).LiteralParser::tryStreamingParse(LiteralParser.cpp:1985) callsparseRecursivelyper line with no scope and no check.Interpreter::executeProgram's JSONPdoGetlambda (Interpreter.cpp:1135) callsPropertySlot::getValueaftergetPropertySlotwith no check.Fix
vm.exception(), which is the check the builder's scope needs. Behavior is unchanged: both already returned early on a pending exception.tryStreamingParsedeclares a ThrowScope and checks after each value. An out-of-memory or stack-overflow exception is no longer reported asStatus::Errorwith the exception still pending; the caller (Bun.JSONL.parsein bun, which checks right afterstreamingJSONParse) reports it.doGetchecks betweengetPropertySlotandgetValue. Both call sites already check right afterdoGet.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 withBUN_JSC_validateExceptionChecks=1 bun -e 'Bun.sql'on a debug build.Background
BUN_JSC_validateExceptionChecks=1makes every ThrowScope destructor setVM::m_needExceptionCheck. The next ThrowScope constructor or non-released destructor asserts if the bit is still set. OnlyVM::exception()(andclearException) clears it.exceptionForInspection()readsm_exceptionwithout touching the bit, for code that explicitly must not count as a check.PropertyCallbackbuilder has the signatureJSValue(VM&, JSObject*), so it cannot propagate an exception toreifyStaticProperty. 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.