Skip to content

[JSC] Console host functions release their ThrowScope before the ConsoleClient call - #790

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/69b487cb/console-client-exception-handoff
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/69b487cb/console-client-exception-handoff

Conversation

@robobun

@robobun robobun commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • console.assert(false) aborts a debug build of Bun under validateExceptionChecks=1: But the exception was unchecked as of this scope: consoleProtoFuncAssert @ runtime/ConsoleObject.cpp:218.
  • Nine host functions in runtime/ConsoleObject.cpp declare a ThrowScope, call a ConsoleClient hook, and return with no check or release (12 exits). Bun's hooks can throw.
  • Only assert aborts today: only its hook declares a ThrowScope (since Add jsc-exception-lint and fix the missing exception checks it finds bun#40410).

Fix

  • The other eight scoped functions call scope.release() before the client call (11 sites), so their caller owns the check.
  • assert loses its scope and its RETURN_IF_EXCEPTION, because JSValue::toBoolean() cannot throw.
  • Verified with Bun on this PR's build (Bump WebKit: console functions release their ThrowScope before the console hook bun#44950): 9 of 9 failing console.assert forms abort before, 0 after. With a ThrowScope in each Bun hook: 12 of 12 exits abort before, 0 after.

Background

  • With validateExceptionChecks, a ThrowScope destructor marks a check as needed unless it returns to JIT code. The next unreleased ThrowScope asserts on that mark.
  • Rejected: RETURN_IF_EXCEPTION after each client call (a load and a branch in release code, scope.release() is free), and a change in Bun's hook only (hides the 12 exits).

Downsides

  • ConsoleObject.cpp was byte-identical to upstream. It now differs by +11/-3 lines.
  • console.assert no longer polls VM traps. Its loop does (check_traps). A Worker spinning in console.assert(true) terminates in 10 of 10 runs before and after.
  • None found in release code: consoleProtoFuncAssert shrinks from 522 to 404 bytes, the other eight are identical.
Notes

The 12 exits, by line on main: assert :229, count :243, countReset :257, profile :271 and :278, profileEnd :292 and :299, takeHeapSnapshot :313 and :320, time :334, timeLog :348, timeEnd :362. The 17 other console host functions declare no scope and need no change.

Why upstream does not see it. JSGlobalObjectConsoleClient and the WebCore client stringify under a catch scope and never throw. Bun's messageWithTypeAndLevel (src/jsc/bindings/ConsoleObject.cpp:33 in oven-sh/bun) declares a ThrowScope, throws a TypeError for a bad console.table argument, and ends with RELEASE_AND_RETURN into a formatter that can run user code. console.assert(false, { [Symbol.for("nodejs.util.inspect.custom")]() { throw new Error("boom") } }) throws boom to the caller in Bun and in Node.js.

No JSTests test. The jsc shell has no console client that throws, so a stress test cannot fail before this change. The regression test is in oven-sh/bun#44950 (test/js/bun/jsc/exception-checks.test.ts, four rows, each exits 134 on the parent commit).

All runs are debug ASAN builds of Bun on Linux x64 that differ only in the WebKit build: the parent commit (0c06faad, Bun's current pin) and this PR's preview build (autobuild-preview-pr-790-cc38fc87).

Forms. Each alone under the validator: console.assert(false), console.assert(), console.assert(false, "x"), console.assert(0, {a:1}), console.assert(null, "%s:%d", "a", 1), console.assert("", new Error("e")), Reflect.apply(console.assert, console, [false, "x"]), [0].forEach(console.assert), JSON.stringify({ toJSON: console.assert }). console.assert(true) exits 0 on both builds.

The 11 exits that are inert today.

  • Bun's exception-check linter (scripts/jsc-exception-lint in oven-sh/bun) on this file, with summaries for ConsoleClient.cpp, ScriptArguments.cpp and ScriptCallStackFactory.cpp: 12 findings before (all unchecked-exit, one for each exit), 0 after.
  • Local experiment: a ThrowScope with RELEASE_AND_RETURN in each of Bun's eight other hooks, and one snippet for each exit. Before: 12 of 12 abort, each at its own scope (consoleProtoFuncCount @ ConsoleObject.cpp:235, consoleProtoFuncTime @ :326, and so on). After: 0 of 12.
  • console.takeHeapSnapshot: report snapshot parse failures instead of aborting bun#37070 reports and clears an exception inside Bun's takeHeapSnapshot hook because of exits :313 and :320.
  • The wklint baseline in Run wklint (JSC exception-check linter) during the Linux amd64 debug build #314 lists these 11 findings as 8 [console-client] entries. They can go after this change.

Simulated throws for each call under the validator (gdb hit count on JSC::ThrowScope::simulateThrow, 1,001 calls minus 101 calls, divided by 900). console.count("x"): 0 before and after. console.log("x"): 0 and 0. console.timeLog("l", {}): 5 and 5. console.assert(true): 0 and 0. console.assert(false): abort at the first call before, 0 after. In a validator build each of the 11 call sites now stores one flag byte (m_isReleased).

Release code. This file at the parent commit and at this commit, each compiled alone with clang 23.1.2 and the flags of the bun-webkit-linux-amd64-lto build (-O3, without -flto). consoleProtoFuncAssert: 522 to 404 bytes, 149 to 119 instructions. The path of console.assert(true) through it: 55 to 46 instructions, 8 to 7 conditional branches. The other 26 functions of the file: 0 differing objdump lines.

VM traps. RETURN_IF_EXCEPTION also handles pending VM traps. The one removed from assert was such a poll. for (let i = 0; i < n; i++) console.assert(true) compiles to loop_hint, check_traps, ..., call_ignore_result, so the loop polls on each iteration. Worker test: the worker posts one message and spins in for (;;) console.assert(true), the parent waits 300 ms and calls terminate(). Interleaved, 10 runs for each build: 10 of 10 terminate on both. Median 380 ms (241 to 491) before, 430 ms (237 to 550) after. The difference is inside the spread. I did not make a release build of Bun with this change.

@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Preview build of 9415508: autobuild-preview-pr-790-94155087

…oleClient call

Nine host functions in ConsoleObject.cpp declare a ThrowScope, call a
ConsoleClient hook, and return with no check and no release. A hook
that declares a ThrowScope of its own leaves a simulated throw pending
(Bun's messageWithTypeAndLevel does). With validateExceptionChecks the
destructor of the caller's scope then aborts:

    ERROR: Unchecked JS exception:
        This scope can throw a JS exception: messageWithTypeAndLevel
        But the exception was unchecked as of this scope: consoleProtoFuncAssert @ runtime/ConsoleObject.cpp:218

assert, count, countReset, profile, profileEnd, takeHeapSnapshot, time,
timeLog and timeEnd call scope.release() before the client call (12
call sites). The caller of the host function owns the check after that.

scope.release() is empty when exception scope verification is off, so
the code of a release build does not change.
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