Skip to content

JSModuleLoader: don't treat a TerminationException from resolve() as a resolution failure - #309

Closed
robobun wants to merge 4 commits into
mainfrom
robobun/module-loader-termination-assert
Closed

JSModuleLoader: don't treat a TerminationException from resolve() as a resolution failure#309
robobun wants to merge 4 commits into
mainfrom
robobun/module-loader-termination-assert

Conversation

@robobun

@robobun robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

When a worker is terminated while hostLoadImportedModule is inside resolve(), the NeedTermination trap surfaces as a TerminationException from resolve(). The existing code handles it like any other resolution error: it caches the termination string in m_resolutionFailures, calls promise->rejectWithCaughtException (whose TRY_CLEAR_EXCEPTION refuses to clear a termination and returns early), and then enters finishLoadingImportedModule with the termination still pending on the VM.

Seen in Bun's debug+ASAN CI at roughly 1 in 100 runs of new Worker("", { eval: true }) followed immediately by worker.terminate(). A calibrated sweep (terminating workers across the tail of their startup preload) surfaces three assertions on the unfixed build:

  1. scope.assertNoException() in continueDynamicImport (JSModuleLoader.cpp)
  2. UBSan null-pointer member call in JSObject::putDirect via reifyStaticProperty (Lookup.h)
  3. EXCEPTION_ASSERT(!scope.exception() || !result) in JSObject::getOwnPropertyDescriptor

Fix

hostLoadImportedModule: when the exception caught from resolve() is a TerminationException, return nullptr immediately so the caller's RETURN_IF_EXCEPTION unwinds, instead of caching it as a resolution failure and proceeding into FinishLoadingImportedModule. Both callers already check for a pending exception on the next line.

Branch composition

This branch is currently based on 4895f45dfbd0 (Bun's current WEBKIT_VERSION) with #282, #306 and #286 cherry-picked, so the preview build oven-sh/bun#34655 points at pulls in only the termination fixes and not the unrelated JSMicrotask/JSPromise refactors between 4895f45dfbd0 and main. #282 and #306 are already merged; #286 is Jarred's open PR that relaxes the continueDynamicImport/getOwnPropertyDescriptor/JSValue::get assertions for a pending TerminationException. The hostLoadImportedModule change in the final commit is the only new code here.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

JavaScriptCore now propagates termination during property lookup and static-property reification. Module resolution bypasses ordinary failure handling for termination exceptions, and dynamic import rejection permits termination to remain pending.

Changes

Termination-aware property reification and access

Layer / File(s) Summary
Static property callback reification
Source/JavaScriptCore/runtime/Lookup.*, Source/JavaScriptCore/runtime/JSObject.cpp
Static-property reification defers termination, propagates inspection exceptions, and avoids storing empty callback results.
Property lookup exception propagation
Source/JavaScriptCore/runtime/JSCJSValuePropertyInlines.h, Source/JavaScriptCore/runtime/JSObject.cpp
Property and descriptor lookups tolerate pending termination and return fallback values when exceptions are raised.

Module loader termination handling

Layer / File(s) Summary
Termination-aware module loading paths
Source/JavaScriptCore/runtime/JSModuleLoader.cpp
Module resolution bypasses resolution-failure handling for termination exceptions, and dynamic import rejection allows termination afterward.

Possibly related PRs

  • oven-sh/WebKit#282: Modifies the same static-property reification and lookup termination paths.

Suggested reviewers: webkit-commit-queue, constellation, geoffreygaren

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the fix but does not follow the required WebKit template or include the bug title/link and reviewer line. Rewrite it to match the template: add bug title, Bugzilla link, reviewed-by line, rationale, and per-file change bullets.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: handling TerminationException from resolve() as non-failure.

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 — targeted fix for a debug-assert abort when a TerminationException surfaces during module resolution.

What was reviewed:

  • Confirmed both callers of hostLoadImportedModule (loadModule and innerModuleLoading) check scope.exception() immediately after, so the new return nullptr unwinds correctly without leaking state.
  • vm.isTerminationException(...) and assertNoExceptionExceptTermination() are both established patterns (used in CyclicModuleRecord.cpp, JSModuleNamespaceObject.cpp, etc.).
  • The early return correctly skips addResolutionFailure, so the termination is not cached as a permanent resolution error.
Extended reasoning...

Overview

This PR touches a single file, Source/JavaScriptCore/runtime/JSModuleLoader.cpp, with two small changes: (1) in hostLoadImportedModule, when resolve() throws and the exception is a TerminationException, return nullptr immediately instead of treating it as a resolution failure (caching it and calling finishLoadingImportedModule with the termination still pending); and (2) in continueDynamicImport, relax scope.assertNoException() to scope.assertNoExceptionExceptTermination() after promise->reject(...).

Security risks

None. This is exception-handling control flow in the module loader for the worker-termination race. No new inputs, no auth/crypto/permission surfaces. The only behavioral difference in release builds is that a TerminationException no longer gets stashed in m_resolutionFailures — which is strictly more correct, since a termination is not a per-specifier resolution error that should be cached.

Level of scrutiny

Low-to-medium. The observable bug is a debug-only assertNoException() abort under ENABLE(EXCEPTION_SCOPE_VERIFICATION); release builds already unwind normally via the next RETURN_IF_EXCEPTION. The fix is 5 new lines plus a one-token assertion relaxation, both following idioms already present in this codebase (vm.isTerminationException(...) guards in ExceptionScope.h, assertNoExceptionExceptTermination() in CyclicModuleRecord.cpp / JSModuleNamespaceObject.cpp).

Other factors

I verified the PR description's claim that both callers handle the nullptr return: loadModule does RETURN_IF_EXCEPTION(scope, nullptr) on the next line, and innerModuleLoading does if (scope.exception()) { state->setDrainingInnerLoad(false); return; }. Since the TerminationException remains pending on the VM (it is checked, not cleared), both paths bail correctly. The early return also happens before addResolutionFailure, so nothing leaks into m_resolutionFailures. No prior reviews or outstanding comments on the PR.

@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
5113fb40 autobuild-preview-pr-309-5113fb40 2026-07-18 17:59:58 UTC
9cb28173 autobuild-preview-pr-309-9cb28173 2026-07-18 14:34:56 UTC

robobun added 2 commits July 18, 2026 17:11
… builder exceptions (#282)

* Defer termination across static-hashtable PropertyCallback builders

LazyPropertyCallback runs inside reifyStaticProperty -> setUpStaticFunctionSlot
-> getOwnPropertySlot, none of which check for exceptions after the builder.
When worker.terminate() arms the termination trap while a builder enters JS, the
trap fires inside the builder and the TerminationException stays pending after
it returns; getOwnPropertySlot then returns true with an exception pending and
trips EXCEPTION_ASSERT(!scope.exception() || !hasSlot) in JSValue::get /
JSObject::getOwnPropertyDescriptor.

LazyProperty::callFunc already wraps its initializer in DeferTerminationForAWhile
for the same reason. Do the same for the PropertyCallback arm of
reifyStaticProperty, covering both callers (setUpStaticFunctionSlot from
getOwnPropertySlot and reifyAllStaticProperties from deleteProperty).

* Move the defer to the call sites and propagate exceptions

Per review: keep the include out of Lookup.h. DeferTerminationForAWhile
now wraps the reifyStaticProperty call in setUpStaticFunctionSlot
(Lookup.cpp) and the reify loop in JSObject::reifyAllStaticProperties.

Also propagate a non-termination exception from a PropertyCallback
builder: reifyStaticProperty skips the putDirect when the callback
returns empty, setUpStaticFunctionSlot returns false (slot not found)
when an exception is pending so JSValue::get / getOwnPropertyDescriptor's
EXCEPTION_ASSERT(!scope.exception() || !result) holds, and
reifyAllStaticProperties leaves the remaining properties lazy.

* Use a ThrowScope instead of exceptionForInspection at the reify call sites

exceptionForInspection() is for assertions/diagnostics only (every other
use in JSC is inside ASSERT or #if ASSERT_ENABLED). It bypasses exception
scope verification because it doesn't clear m_needExceptionCheck.

Declare a ThrowScope and use RETURN_IF_EXCEPTION, matching the pattern at
JSGlobalObject.cpp tryInstallSpeciesWatchpoint / JSBoundFunction.cpp
nameSlow. In setUpStaticFunctionSlot the defer scope ends before the
check so a suspended termination that is re-thrown on defer exit is
observed and we return false, satisfying the caller's
EXCEPTION_ASSERT(!scope.exception() || !hasSlot).
ae5110d added DECLARE_THROW_SCOPE inside setUpStaticFunctionSlot and
reifyAllStaticProperties so a PropertyCallback builder that throws can be
detected and propagated. A ThrowScope's destructor unconditionally
simulates a throw to its caller, so this made both functions look
throwing to the exception-scope verifier. setUpStaticFunctionSlot runs on
every first lookup of a static hashtable property via getOwnPropertySlot,
and JSC-internal callers of that path (CodeBlock::finishCreation via
initializeTemplateObjects, among others) never check afterward, so with
validateExceptionChecks=1 they now crash even though nothing threw.
reifyAllStaticProperties has the same issue for JSObject::deleteProperty.

Keep the DeferTerminationForAWhile and the early-return-on-exception,
but read vm.exceptionForInspection() directly instead of declaring a
ThrowScope. That preserves the ae5110d behaviour (report the slot as
not found when the builder left an exception pending, bail from the
reify-all loop) without forcing a simulated throw onto callers that have
never had to handle one.
@robobun
robobun force-pushed the robobun/module-loader-termination-assert branch from 9cb2817 to bd5d12b Compare July 18, 2026 17:12
Jarred-Sumner and others added 2 commits July 18, 2026 17:15
DeferTermination throws the TerminationException from its destructor, after
a property-slot lookup has already succeeded. JSObject::get already tolerates
this (hasPendingTerminationException in its assertion); three sibling sites
did not and abort assertion builds when terminate() races script execution:

- JSValue::get (both overloads): same tolerance + RETURN_IF_EXCEPTION, hit
  via LLInt get_by_id during module evaluation
- JSObject::getOwnPropertyDescriptor: same, hit via Object.defineProperty
- JSModuleLoader::continueDynamicImport: assertNoException ->
  assertNoExceptionExceptTermination (rejectWithCaughtException cannot clear
  a termination, so it stays pending by design)

No exception is ever cleared; callers' existing checks unwind as usual.
Reproduced with a worker terminate() stress loop: 3/320 aborts before,
0/320 + 0/240 after.
…a resolution failure

When a worker is terminated while hostLoadImportedModule is inside
resolve(), the NeedTermination trap surfaces as a TerminationException.
Returning early lets the caller's RETURN_IF_EXCEPTION unwind instead of
caching the termination string in m_resolutionFailures and entering
FinishLoadingImportedModule with the termination still pending on the
VM.
@robobun
robobun force-pushed the robobun/module-loader-termination-assert branch from bd5d12b to 5113fb4 Compare July 18, 2026 17:15

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Source/JavaScriptCore/runtime/JSModuleLoader.cpp`:
- Around line 1031-1034: Update the comment immediately before
scope.assertNoExceptionExceptTermination() to describe the actual
promise->reject(...) path: promise rejection does not clear the ambient
TerminationException, while termination is returned before becoming a cached
resolution failure, and the caller’s exception check unwinds it. Remove the
inaccurate reference to rejectWithCaughtException.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 70358b8e-bb04-40cc-8f3f-2bae52565652

📥 Commits

Reviewing files that changed from the base of the PR and between 9cb2817 and 5113fb4.

📒 Files selected for processing (5)
  • Source/JavaScriptCore/runtime/JSCJSValuePropertyInlines.h
  • Source/JavaScriptCore/runtime/JSModuleLoader.cpp
  • Source/JavaScriptCore/runtime/JSObject.cpp
  • Source/JavaScriptCore/runtime/Lookup.cpp
  • Source/JavaScriptCore/runtime/Lookup.h

Comment on lines +1031 to +1034
// The abrupt completion may be a resolution failure caused by the
// TerminationException, which rejectWithCaughtException cannot clear;
// it stays pending in the VM and the caller's exception check unwinds.
scope.assertNoExceptionExceptTermination();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the comment to describe the actual rejection path.

Lines [1031]-[1034] call promise->reject(...), not rejectWithCaughtException(...). Additionally, termination is now returned before becoming a cached resolution failure. Clarify that promise rejection does not clear the ambient TerminationException; the caller’s exception check unwinds it.

Suggested wording
-        // The abrupt completion may be a resolution failure caused by the
-        // TerminationException, which rejectWithCaughtException cannot clear;
-        // it stays pending in the VM and the caller's exception check unwinds.
+        // Rejecting the promise does not clear a pending TerminationException;
+        // it remains pending for the caller's exception check to unwind.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// The abrupt completion may be a resolution failure caused by the
// TerminationException, which rejectWithCaughtException cannot clear;
// it stays pending in the VM and the caller's exception check unwinds.
scope.assertNoExceptionExceptTermination();
// Rejecting the promise does not clear a pending TerminationException;
// it remains pending for the caller's exception check to unwind.
scope.assertNoExceptionExceptTermination();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Source/JavaScriptCore/runtime/JSModuleLoader.cpp` around lines 1031 - 1034,
Update the comment immediately before scope.assertNoExceptionExceptTermination()
to describe the actual promise->reject(...) path: promise rejection does not
clear the ambient TerminationException, while termination is returned before
becoming a cached resolution failure, and the caller’s exception check unwinds
it. Remove the inaccurate reference to rejectWithCaughtException.

Comment on lines +1031 to +1034
// The abrupt completion may be a resolution failure caused by the
// TerminationException, which rejectWithCaughtException cannot clear;
// it stays pending in the VM and the caller's exception check unwinds.
scope.assertNoExceptionExceptTermination();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit: This comment references rejectWithCaughtException (which is called upstream in hostLoadImportedModule, not here — this site calls promise->reject()), and it describes a scenario — "resolution failure caused by the TerminationException" — that this PR's own hostLoadImportedModule fix now prevents from reaching continueDynamicImport. The relaxed assertion is still correct (since promise->reject() above can itself trip the NeedTermination trap), but consider rewording the comment to say that instead.

Extended reasoning...

The new comment above scope.assertNoExceptionExceptTermination() in continueDynamicImport reads:

The abrupt completion may be a resolution failure caused by the TerminationException, which rejectWithCaughtException cannot clear; it stays pending in the VM and the caller's exception check unwinds.

There are two accuracy issues with this wording in the context of the combined diff.

1. Wrong function referenced at this site. Line 1029 calls promise->reject(vm, (*exception)->value()), not rejectWithCaughtException. The rejectWithCaughtException reference points at the upstream resolution-error branch in hostLoadImportedModule (JSModuleLoader.cpp:663). A reader looking at this comment in isolation has to know it's describing a call in a different function.

2. The described scenario is now unreachable. The comment's causal chain is: resolve() throws TerminationExceptionrejectWithCaughtException can't clear it → finishLoadingImportedModule is entered with it pending → continueDynamicImport receives an Exception* completion. But this same PR adds, at JSModuleLoader.cpp:652–656:

if (vm.isTerminationException(resolutionError)) [[unlikely]]
    return nullptr;

which returns before attachErrorInfo / addResolutionFailure / rejectWithCaughtException / finishLoadingImportedModule. So a TerminationException from resolve() can no longer be cached in m_resolutionFailures nor flow into continueDynamicImport as the completion. Tracing the other Exception* paths into continueDynamicImport — the cached-resolution-failure branch at line 640 and the fetch-error branch at line 691 — both pass stored error values, not a pending TerminationException.

Step-by-step (post-fix):

  1. Worker termination fires while hostLoadImportedModule is inside resolve().
  2. resolve() returns with TerminationException pending; scope.exception() yields it.
  3. New guard: vm.isTerminationException(resolutionError)return nullptr.
  4. Caller's RETURN_IF_EXCEPTION unwinds. finishLoadingImportedModule / continueDynamicImport are never reached.

So the scenario the comment describes cannot happen anymore.

Why the relaxed assertion is still correct. promise->reject(vm, ...) at line 1029 can enter JS (reject handlers / HostPromiseRejectionTracker), and the NeedTermination trap can fire during that call, leaving a TerminationException pending on return. assertNoExceptionExceptTermination() correctly tolerates that. The code change is right; only the comment's justification is stale.

Context. Per the PR description, this comment was cherry-picked from #286, which was written before the hostLoadImportedModule fix existed — so it was accurate in isolation but is stale in the combined diff.

Suggested fix. Reword to something like:

// promise->reject() may enter JS and trip the NeedTermination trap; the
// TerminationException stays pending and the caller's exception check unwinds.

This is comment-wording only; no behavioral defect.

Comment on lines +541 to +545
// A callback that enters JS may return empty with an exception pending;
// the two callers (setUpStaticFunctionSlot / reifyAllStaticProperties)
// check and propagate, so don't put an empty value in the slot here.
if (!result) [[unlikely]]
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit: the comment says "the two callers" but reifyStaticProperties<ArrayType> at the bottom of this header is a third caller of reifyStaticProperty that neither wraps in DeferTerminationForAWhile nor checks vm.exceptionForInspection() afterwards. It's only invoked from WebCore-generated bindings so it's dead in Bun's JSCOnly build, but consider saying "the two JSC-side callers" so the comment doesn't mislead someone reading the full tree.

Extended reasoning...

What the comment claims vs. what's in the file

The new comment at Lookup.h:541-543 reads:

the two callers (setUpStaticFunctionSlot / reifyAllStaticProperties) check and propagate

However, reifyStaticProperty has three direct callers, not two:

  1. setUpStaticFunctionSlot (Lookup.cpp:66) — now wraps in DeferTerminationForAWhile and checks vm.exceptionForInspection() afterwards.
  2. JSObject::reifyAllStaticProperties (JSObject.cpp:2965) — same treatment.
  3. reifyStaticProperties<ArrayType> (Lookup.h:~569-580) — the template at the bottom of this same header, which loops over a HashTableValue array and calls reifyStaticProperty for each entry with no DeferTerminationForAWhile scope and no post-call exception check.

Step-by-step: what would happen on the third path

If a caller reached reifyStaticProperties<ArrayType> and one entry had PropertyAttribute::PropertyCallback whose builder entered JS and returned empty with an exception pending:

  1. reifyStaticProperty is called for entry N; the callback returns an empty JSValue with an exception on the VM.
  2. The new if (!result) return; guard fires (a strict improvement — before this PR it would have crashed in putDirect on an empty value).
  3. Control returns to the for loop in reifyStaticProperties<ArrayType>, which does not inspect vm.exceptionForInspection().
  4. The loop proceeds to entry N+1 and calls reifyStaticProperty again with an exception already pending on the VM — exactly the state the two other callers were hardened against.

Why this doesn't matter for Bun

grep confirms the reifyStaticProperties<ArrayType> template is only defined inside Source/JavaScriptCore — every invocation lives in Source/WebCore/bindings/scripts/test/JS/*.cpp finishCreation paths generated by the WebCore bindings generator. Bun builds with PORT=JSCOnly, which does not compile WebCore, so the third caller is dead code in this repository's build. There is no reachable behavioral bug here.

Why it's still worth a nit

The comment lives right next to the if (!result) guard and is meant to justify why returning early without a local exception check is safe. A reader looking at the whole header will see reifyStaticProperties<ArrayType> thirty lines below, notice it also calls reifyStaticProperty, and wonder why the comment doesn't mention it. If a JSC-side caller of the template were ever added, the comment would actively mislead them into thinking the exception is handled.

Suggested fix

Reword to something like:

the two JSC-side callers (setUpStaticFunctionSlot / reifyAllStaticProperties) check and propagate; the reifyStaticProperties<> template below is only reached from WebCore bindings.

or simply "the two exception-checking callers". No code change needed — the if (!result) guard is a strict improvement on all three paths.

@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #391, which landed the same hostLoadImportedModule fix (propagate the TerminationException from resolve() instead of caching it as a resolution failure). Closing.

@robobun robobun closed this Aug 7, 2026
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