Bump WebKit: console functions release their ThrowScope before the console hook

Draft
RO
1mo ago
+19-1

Problem

  • A failing console.assert aborts a debug or ASAN build under BUN_JSC_validateExceptionChecks=1 (CI's ASAN lane sets it): But the exception was unchecked as of this scope: consoleProtoFuncAssert @ runtime/ConsoleObject.cpp:218.
  • consoleProtoFuncAssert (JavaScriptCore) holds a ThrowScope across Bun's console hook and returns with no check or release. The hook can throw and has its own scope since #40410.
  • Eight more console functions have this shape (11 exits), inert while their hooks declare no scope.

Fix

  • oven-sh/WebKit#790: assert holds no scope. The eight others call scope.release() before the hook, so their caller owns the check.
  • WEBKIT_VERSION is that PR's preview build (the pin plus one commit). Draft until the pin can move to the merged commit.
  • Verified: test/js/bun/jsc/exception-checks.test.ts (4 new rows, each exits 134 on the current pin). Also the console suites under the validator.

Background

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

Downsides

  • 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 at both pins.
  • None found in release code: assert shrinks from 522 to 404 bytes, the other eight are identical.
Notes

Builds. "Current pin" is 0c06faadf65bf8e8c8ad3a5a8aca83e1e9ed653f. The preview is autobuild-preview-pr-790-cc38fc87: 1 commit on top of the pin, Source/JavaScriptCore/runtime/ConsoleObject.cpp +11/-3, no other file. The preview release has the same 42 assets as the release of the pin. All runs below are debug ASAN builds of this branch on Linux x64 that differ only in the WebKit build.

Fail-before on the current pin, one of the four rows:

error: expect(received).toEqual(expected)
  {
-   "exitCode": 0,
-   "stdout": "returned",
-   "unchecked": [],
+   "exitCode": 134,
+   "stdout": "",
+   "unchecked": [
+     "This scope can throw a JS exception: messageWithTypeAndLevel @ unified/../../../src/jsc/bindings/ConsoleObject.cpp:38",
+     "But the exception was unchecked as of this scope: consoleProtoFuncAssert @ vendor/WebKit/Source/JavaScriptCore/runtime/ConsoleObject.cpp:218",
+   ],
  }
(fail) console.assert with a falsy condition
 4 pass
 4 fail

With the preview build: 8 pass, 0 fail.

Forms. Each run 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 }). 9 of 9 exit 134 on the current pin. 0 of 9 on the preview. console.assert(true) exits 0 on both.

The 11 exits that no test can hold today. count, countReset, profile (2), profileEnd (2), takeHeapSnapshot (2), time, timeLog, timeEnd. Their Bun hooks declare no ThrowScope, so nothing marks a check after they return.

  • scripts/jsc-exception-lint on the JavaScriptCore 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, not in this PR: a ThrowScope with RELEASE_AND_RETURN in each of the eight Bun hooks. One snippet for each exit, each alone under the validator. 12 of 12 abort on the current pin, each at its own scope (consoleProtoFuncCount @ ConsoleObject.cpp:235, consoleProtoFuncTime @ :326, and so on). 0 of 12 abort on the preview.

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 sibling call sites now also stores one flag byte (m_isReleased).

Release code. ConsoleObject.cpp of the pin and of the WebKit PR, each compiled as one file 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. I did not make a release build of Bun with the preview.

VM traps. RETURN_IF_EXCEPTION also handles pending VM traps (termination, the debugger, the watchdog). The removed one in 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) on the current pin, 430 ms (237 to 550) on the preview. The difference is inside the spread.

Other suites on the preview build, with BUN_JSC_validateExceptionChecks=1. test/js/web/console/, test/js/bun/console/ and test/js/bun/jsc/exception-checks.test.ts: 109 pass, 1 skip, 2 todo, 0 fail. Node's test/js/node/test/parallel/test-console-*.js: 16 of 16 exit 0.

Not changed.

  • console.takeHeapSnapshot() still aborts under the validator. Its cause is in Bun: JSC__JSGlobalObject__generateHeapSnapshot @ src/jsc/bindings/bindings.cpp:4108 is unchecked in src/jsc/JSValue.rs:1091. The WebKit change only closes the two exits in consoleProtoFuncTakeHeapSnapshot that #37070 works around.
  • console.assert(false, "x") prints x. Node.js prints Assertion failed: x (#19953).

[policy-decision:webkit] gate passed · iteration 0 · 2 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/bun/jsc/exception-checks.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/bun/jsc/exception-checks.test.ts
bun test v1.4.4 (367d939d9)

test/js/bun/jsc/exception-checks.test.ts:
(pass) Bun.deepEquals with one argument [1279.79ms]
(pass) console.assert with a falsy condition [1043.65ms]
(pass) process.exitCode assigned a rope string [1109.72ms]
(pass) process.kill with an unknown rope signal name [1100.83ms]
(pass) process.umask with a rope string [1143.90ms]
(pass) console.assert called by native code [472.93ms]
(pass) console.assert with a falsy condition and a message [664.78ms]
(pass) console.assert with a message whose custom inspect function throws [1455.06ms]

 8 pass
 0 fail
 8 expect() calls
Ran 8 tests across 1 file. [11.28s]
Exit: 0
diff hotspot
scripts/build/deps/webkit.ts             |  2 +-
 test/js/bun/jsc/exception-checks.test.ts | 18 ++++++++++++++++++
 2 files changed, 19 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                      reads  edits  tests
scripts/build/deps/webkit.ts                  1      1      7
test/js/bun/jsc/exception-checks.test.ts      1      1      7