Skip to content

inspector: release throw scope before tail-calling impl in injected-script prototype host functions - #328

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/injected-script-host-release-and-return
Jul 24, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
robobun/injected-script-host-release-and-return

Conversation

@robobun

@robobun robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Every host function in JSInjectedScriptHostPrototype.cpp and JSJavaScriptCallFramePrototype.cpp declares a ThrowScope (for the dynamicDowncast type error) and then tail-calls into a JSInjectedScriptHost / JSJavaScriptCallFrame member. Ten of those members (evaluateWithScopeExtension, getInternalProperties, iteratorEntries, queryInstances, queryHolders, weakMapEntries, weakSetEntries, getOwnPrivatePropertySymbols, getOwnPrivatePropertyMethods, isPromiseRejectedWithNativeGetterTypeError, plus JSJavaScriptCallFrame::evaluateWithScopeExtension/scopeDescriptions/scopeChain) declare their own ThrowScope, so under validateExceptionChecks=1 the first Runtime.evaluate over an inspector connection aborts:

ERROR: Unchecked JS exception:
    This scope can throw a JS exception: evaluateWithScopeExtension @ JSInjectedScriptHost.cpp:120
        (ExceptionScope::m_recursionDepth was 6)
    But the exception was unchecked as of this scope: jsInjectedScriptHostPrototypeFunctionEvaluateWithScopeExtension @ JSInjectedScriptHostPrototype.cpp:275
        (ExceptionScope::m_recursionDepth was 5)

Wrap every return JSValue::encode(castedThis->...) in RELEASE_AND_RETURN(scope, ...) so the outer scope is released before the callee may create its own. Applied uniformly to all 31 sites in both files to keep the pattern consistent; no behavioural change for callees that do not declare a scope.

The same code is present in upstream WebKit.

Needed for oven-sh/bun#31823, which currently exempts its inspector.open() fixture from exception-check validation to work around this.

…cript prototype host functions

Every host function in JSInjectedScriptHostPrototype.cpp and
JSJavaScriptCallFramePrototype.cpp declares a ThrowScope (for the
dynamicDowncast type error) and then tail-calls into a
JSInjectedScriptHost / JSJavaScriptCallFrame member that itself
declares a ThrowScope. Under validateExceptionChecks=1 this aborts
with 'Unchecked JS exception' because the outer scope is never
released before the inner one is created.

Observed in Bun with any Runtime.evaluate over an inspector
connection:

  ERROR: Unchecked JS exception:
      This scope can throw a JS exception: evaluateWithScopeExtension @ JSInjectedScriptHost.cpp:120
      But the exception was unchecked as of this scope: jsInjectedScriptHostPrototypeFunctionEvaluateWithScopeExtension @ JSInjectedScriptHostPrototype.cpp:275

Wrap every tail return in RELEASE_AND_RETURN(scope, ...) so the
outer scope is released before the callee may create its own.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c815235f-4fd3-4e0d-b0f1-c9f09d18a0c1

📥 Commits

Reviewing files that changed from the base of the PR and between 81ea857 and 31913c3.

📒 Files selected for processing (2)
  • Source/JavaScriptCore/inspector/JSInjectedScriptHostPrototype.cpp
  • Source/JavaScriptCore/inspector/JSJavaScriptCallFramePrototype.cpp

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

@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.

LGTM — mechanical application of the standard RELEASE_AND_RETURN idiom to 31 identical tail-call sites.

What was reviewed:

  • Confirmed RELEASE_AND_RETURN(scope, expr) expands to scope.release(); return expr; (ExceptionScope.h:151) — releasing before the callee runs is the correct fix for the validateExceptionChecks=1 unchecked-scope abort.
  • Each site has no throwing work between the dynamicDowncast null check and the tail call, so releasing early cannot mask a real unchecked exception.
  • Pattern matches ~400 existing uses across JSC; no runtime behaviour change in release builds.
Extended reasoning...

Overview

This PR modifies two inspector prototype files (JSInjectedScriptHostPrototype.cpp and JSJavaScriptCallFramePrototype.cpp), rewriting 31 host-function tail returns from return JSValue::encode(castedThis->foo(...)) to RELEASE_AND_RETURN(scope, JSValue::encode(castedThis->foo(...))). Every changed function has the identical shape: declare a ThrowScope, dynamicDowncast the receiver, throw a TypeError on failure, otherwise tail-call into the impl.

Security risks

None. The change only affects JSC's debug-time exception-scope validation bookkeeping. scope.release() marks the outer scope as intentionally not checking for exceptions because responsibility is delegated to the callee; the actual VM exception state and propagation are untouched. No new inputs, no new control flow, no allocation changes.

Level of scrutiny

Low. This is a purely mechanical transformation applying a well-established JSC macro (405 existing occurrences of this exact RELEASE_AND_RETURN(scope, JSValue::encode(...)) form across 97 files). The macro definition in ExceptionScope.h is trivial: release the scope, then return the expression. In release builds where exception-check validation is compiled out, this is effectively a no-op. In debug builds with validateExceptionChecks=1, it fixes a false-positive abort that fires when the callee (e.g. evaluateWithScopeExtension) declares its own ThrowScope.

Other factors

  • The PR applies the change uniformly to all 31 sites (not just the ~13 whose callees currently declare a ThrowScope), which is the right call for consistency and future-proofing — releasing before a callee that doesn't throw is harmless.
  • I checked that in every function nothing can throw between DECLARE_THROW_SCOPE and the tail call other than the already-handled throwVMTypeError branch, so the release cannot hide a genuine unchecked exception.
  • No prior human reviews or outstanding comments on the PR; the only timeline entry is a coderabbit rate-limit notice.
  • The motivation (unblocking validateExceptionChecks=1 for Bun's inspector tests) is clearly stated and the fix is the canonical one.

robobun added a commit to oven-sh/bun that referenced this pull request Jul 24, 2026
… too

test/expectations.txt quarantined test/cli/inspect/inspect.test.ts on
ASAN as a TIMEOUT ever since ASAN CI was enabled (d8a69d6). That
timeout is this exact bug: the describe("websocket") tests send
Runtime.evaluate with bunEnv (which inherits the runner's
BUN_JSC_validateExceptionChecks=1 on ASAN), the inspectee SIGABRTs
before replying, and the unawaited .resolves assertion then hangs. With
the WebKit fix those tests pass, so remove the quarantine.

ENABLE_EXCEPTION_SCOPE_VERIFICATION is (ASSERT_ENABLED || ASAN_ENABLED),
and the build forces assertions on for ASAN, so the explicit regression
test should run on release-asan as well as debug; gate on
(!isDebug && !isASAN) instead of just !isDebug.

Also wire the post-open WebSocket error event to rejection, and add a
source-lint test that fails when WEBKIT_VERSION is an
autobuild-preview-* tag so a preview pin cannot be merged to main by
accident (it is the merge gate for this PR until oven-sh/WebKit#328
lands and the pin is swapped to the merged sha).
@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
31913c3e autobuild-preview-pr-328-31913c3e 2026-07-24 00:35:46 UTC

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