Skip to content

DFG: LiveCatchVariablePreservationPhase flushed the wrong handler's locals when one try range leads directly into another - #417

Open
robobun wants to merge 1 commit into
mainfrom
farm/a3a8c180/live-catch-handler-transition
Open

DFG: LiveCatchVariablePreservationPhase flushed the wrong handler's locals when one try range leads directly into another#417
robobun wants to merge 1 commit into
mainfrom
farm/a3a8c180/live-catch-handler-transition

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Found by fuzzing Bun. Fingerprint: DFGOSRAvailabilityAnalysisPhase.cpp(198).

Symptom

Debug/ASAN builds abort in the FTL compile of code whose using block's dispose call is not inlined. The fuzzer hit it in eval code; a plain function does the same once it reaches the FTL. Reduced repro (for (using r of [resource]) { try { r(); } catch {} } followed by a hot loop, evaluated with indirect eval):

DFG ASSERTION FAILED: Live bytecode local not available: operand = loc21, availabilityMap = {locals = arg0:FlushedJSValue/Unavailable loc9:ConflictingFlush/D@247 ... loc20:ConflictingFlush/D@86 loc22:ConflictingFlush/D@117; heap = }, origin = bc#188
dfg/DFGOSRAvailabilityAnalysisPhase.cpp(198) : ...::validateNode(Node *, LocalOSRAvailabilityCalculator &)

Release builds miscompile instead. loc21 here is the hasError ("body threw") flag of the disposal code emitted by BytecodeGenerator::emitUsingBodyScope. Once the enclosing function is optimized (the DFG tier is enough), a dispose method that throws after the body also threw yields the dispose method's plain Error instead of a SuppressedError carrying both errors, so the body's exception is silently dropped. For the scenario in JSTests/stress/using-dispose-throw-after-body-throw-in-jit.js the interpreter returns a SuppressedError and optimized code on current main returns Error("dispose 0").

Cause

LiveCatchVariablePreservationPhase::handleBlockForTryCatch walks the nodes of a block, looks up the handler covering each node's origin and, when that handler changes, calls flushEverything to flush the locals live at the head of the handler being left. The lookup lambda (catchHandler) also refilled liveAtCatchHead with the liveness of whatever handler it found, and it ran before flushEverything. So on a transition from one handler straight into another, the flushes were computed from the handler being entered. Only transitions into "no handler" behaved, because the lambda leaves liveAtCatchHead alone in that case.

Ordinary try/catch does not expose this: TryNode emits the jump over the catch block before it pops the try range, so a block ending at the range boundary ends on a node that is still inside the range and gets the block-end flush for the right handler. The using disposal code is laid out differently: emitUsingBodyScope emits the dispose call, ends the synthesized catch's range (trySlotEnd is an emitted label, hence a jump target and a DFG block boundary) and only then emits the jump. The block holding the dispose call therefore ends with a synthesized Jump whose origin is the first bytecode after the range, which belongs to the enclosing handler (the for-of's synthesized finally in the fuzzer case, the user's try/catch in the function case). hasError is read only by the synthesized catch, so nothing else in the graph keeps it alive; without the flush it gets no Phi at the merge after the "seed pendingError with the body's exception" branch and is unavailable at the dispose call's exception exit.

Graph after parsing for the reduced repro, block with the dispose call. Before:

Call(...)                                   bc#195, ExitsForExceptions
Flush(loc11) Flush(this)                    bc#200   liveness of the for-of finally, not of the synthesized catch
Jump(#23)                                   bc#200
Flush(loc11) Flush(this)                    block end

After:

Call(...)                                   bc#195
Flush(loc9) Flush(loc10) Flush(loc11) Flush(loc18) Flush(loc19) Flush(loc20) Flush(loc21) Flush(this)   bc#200
Jump(#23)                                   bc#200
Flush(loc11) Flush(this)                    block end

Change

catchHandler now only records which handler covers an origin, plus that handler's catch CodeOrigin. When the handler changes, the block is flushed for the old handler first and the liveness of the new one is computed afterwards. The file is identical to upstream main at this point, so the same fix applies upstream.

Two stress tests: for-using-dispose-call-live-catch-locals-ftl-validation.js is the fuzzer shape and runs with --validateGraph=1, so it fails on release builds as well; using-dispose-throw-after-body-throw-in-jit.js checks the SuppressedError behaviour after the function is optimized. On current main the first one hits the assertion above and the second one hits the same assertion on a debug build (wrong result on a release build); both pass with the fix.

Verification

Compiled the modified UnifiedSource-dfg-9 bundle with the flags recorded in the 447082ab debug-asan prebuilt's compile_commands.json, replaced that member in the prebuilt's libJavaScriptCore.a and relinked Bun's debug-asan binary against it. With that binary the original fuzzer input and both stress tests run to completion (the eval now goes through its regular FTL compile instead of aborting), and the SuppressedError scenario matches the interpreter at both the DFG and FTL tiers. The unmodified binary still fails all three. Bun's test suite will run against this PR's preview tarballs from the Bun PR that pins them; I will link it here.

@coderabbitai

coderabbitai Bot commented Aug 12, 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: 6 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

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: f7b7f598-f623-4af8-b2ae-5e679c3e0b5e

📥 Commits

Reviewing files that changed from the base of the PR and between eeab040 and ece09c1.

📒 Files selected for processing (3)
  • JSTests/stress/for-using-dispose-call-live-catch-locals-ftl-validation.js
  • JSTests/stress/using-dispose-throw-after-body-throw-in-jit.js
  • Source/JavaScriptCore/dfg/DFGLiveCatchVariablePreservationPhase.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.

I reviewed this PR and didn't find any bugs. Because it changes a DFG compiler phase that governs which locals stay available across exception exits, a human look from someone familiar with JSC's OSR-exit invariants would still be worthwhile.

What was reviewed:

  • The reordering: liveAtCatchHead now stays pinned to the handler being left until after flushEverything, and is only recomputed for the newly entered handler afterwards — the block-end flush and the SetLocal/SetArgument path still see liveness for currentExceptionHandler.
  • cachedCatchOrigin is only read via computeLiveAtCatchHead, which runs only when catchHandler just returned non-null on a cache miss (a cache hit always yields newHandler == currentExceptionHandler), so it is never consumed stale or uninitialized.
  • Both new stress tests exercise the described handler-to-handler transition (for-of finally / user try-catch enclosing the synthesized dispose-call handler).
Extended reasoning...

Overview

The PR fixes an ordering bug in DFGLiveCatchVariablePreservationPhase::handleBlockForTryCatch. Previously, the catchHandler lookup lambda both resolved the handler for a node's origin and eagerly refilled liveAtCatchHead with that handler's liveness. When the loop then detected a handler transition and called flushEverything for the handler being left, liveAtCatchHead had already been overwritten with the liveness of the handler being entered, so the wrong locals were flushed. The fix splits the lookup from the liveness computation: catchHandler now only records the target CodeOrigin in cachedCatchOrigin, and a new computeLiveAtCatchHead lambda is invoked after the flush and after currentExceptionHandler is updated. Two stress tests are added covering the FTL validation assertion and the observable SuppressedError miscompile.

Security risks

None in the traditional sense (no auth, parsing, or untrusted-input surface). The risk profile is JIT correctness: a mistake here could leave locals unavailable at an OSR exit and produce wrong values after deopt. The change strictly increases the set of locals flushed at handler-to-handler transitions (it now flushes the inner handler's live set, which is what the exit actually needs) and does not remove any existing flush, so it should be monotonically safer than before.

Level of scrutiny

High. This is a DFG optimization phase that directly affects OSR-exit availability across all JIT-compiled code with nested exception handlers. Although the diff is small (~20 lines net) and the reasoning in the PR description is thorough and matches my reading of the code, JIT phase invariants are subtle enough that a reviewer with JSC/DFG expertise should confirm the sequencing is sound in all handler-transition shapes (including inlined frames, since cachedCatchOrigin carries the inlineCallFrame through).

Other factors

  • I traced the cache-hit path: a hit on cachedCodeOrigin returns the same handler as the immediately preceding call, so newHandler == currentExceptionHandler and computeLiveAtCatchHead is not invoked; therefore cachedCatchOrigin is never read stale.
  • The block-end flushEverything still sees the correct liveAtCatchHead because it is only refilled when currentExceptionHandler is reassigned.
  • The author verified the fix against a debug-asan build (both the fuzzer input and the two new tests pass; unmodified build fails all three), and notes the file is identical to upstream WebKit so the fix is upstreamable.
  • No prior human or bot review comments to consider beyond a CodeRabbit rate-limit notice.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Bun side, pinned to this PR's preview build (autobuild-preview-pr-417-fbb76610): oven-sh/bun#37941. It adds the same two stress tests as jsc-stress fixtures; with the preview tarball both pass and the full jsc-stress suite is 117/117 on a debug build, while both fail on the engine without this change.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
ece09c13 autobuild-preview-pr-417-ece09c13 2026-08-18 06:40:35 UTC
9984a386 autobuild-preview-pr-417-9984a386 2026-08-17 00:44:06 UTC
bc08a6e1 autobuild-preview-pr-417-bc08a6e1 2026-08-16 11:31:25 UTC
fbb76610 autobuild-preview-pr-417-fbb76610 2026-08-12 22:20:00 UTC

@robobun
robobun force-pushed the farm/a3a8c180/live-catch-handler-transition branch from fbb7661 to bc08a6e Compare August 16, 2026 11:01

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

I reviewed this PR and didn't find any bugs. The reordering of liveAtCatchHead population relative to flushEverything looks correct and the analysis is thorough, but since this changes OSR-exit liveness handling in a DFG compiler phase — where a mistake is a silent miscompile — a human look from someone familiar with this phase would still be worthwhile.

What was reviewed:

  • Traced the handler-transition loop before/after: flushEverything now reads the old handler's liveAtCatchHead, and computeLiveAtCatchHead runs only after currentExceptionHandler is reassigned.
  • Checked the cachedCodeOrigin fast-path: a cache hit implies newHandler == currentExceptionHandler, so a stale cachedCatchOrigin is never consumed.
  • Confirmed the SetLocal/SetArgument path and the block-end flushEverything still see a liveAtCatchHead that matches currentExceptionHandler.
Extended reasoning...

Overview

The PR fixes an ordering bug in DFGLiveCatchVariablePreservationPhase::handleBlockForTryCatch. Previously the catchHandler lookup lambda refilled liveAtCatchHead as a side effect before the caller decided whether to flush for the outgoing handler, so a direct handler→handler transition flushed the incoming handler's live set instead of the outgoing one. The fix splits the lookup from the liveness computation: catchHandler now only records the handler and its catch CodeOrigin, and a new computeLiveAtCatchHead lambda is invoked after the flush, once currentExceptionHandler has been updated. Two stress tests cover the fuzzer-found FTL validation crash and the observable SuppressedError miscompile.

Security risks

None in the classic sense (no auth/crypto/input parsing). The risk profile here is JIT-correctness: a wrong flush set can leave a bytecode local unavailable at an exception exit, which in release builds is a silent wrong-result miscompile rather than a crash. That is exactly the class of bug being fixed, and it is also the class of bug a regression here would introduce.

Level of scrutiny

High. This is a DFG compiler phase that directly governs which locals are kept alive for OSR exit into catch handlers. The diff is small (~20 lines of logic movement) and I traced every consumer of liveAtCatchHead (flushEverything, the SetLocal/SetArgument branch, and the block-end flush) to confirm each still sees a bitmap that corresponds to the current currentExceptionHandler. I also checked the origin == cachedCodeOrigin early-return: on a cache hit the returned handler equals the one from the previous iteration, so the transition branch is not entered and cachedCatchOrigin is not read. The fix is strictly more conservative than before (it never flushes fewer locals than the old code on any path), so the failure mode of a mistake would be over-flushing rather than under-flushing.

Other factors

The PR description is unusually detailed, includes before/after DFG graph dumps, and the author verified against a relinked debug-asan build plus the full jsc-stress suite (117/117) via the linked Bun PR. The file is noted as identical to upstream WebKit, so this is upstreamable. All of that raises confidence, but DFG/FTL exception-handling liveness is subtle enough that I'm deferring rather than auto-approving — a JSC reviewer should sanity-check the interaction with inlined frames and the block-boundary reasoning around emitUsingBodyScope.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 16, 2026
…ized code

DFG's LiveCatchVariablePreservationPhase flushed the locals of the handler
being entered instead of the handler being left when one try range led
directly into another. The dispose call emitted for a `using` block ends its
synthesized catch range right at the call, inside the enclosing handler, so
the "body threw" flag that only the synthesized catch reads was never flushed.
FTL compiles of such code failed OSR availability validation, and optimized
code restored the flag as undefined when a dispose method threw after the
body threw, dropping the body's error instead of reporting a SuppressedError.

Pins oven-sh/WebKit#417's preview build and adds both shapes as jsc-stress
fixtures.
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (0cbb4a1, which is what Bun currently pins) with no changes to the diff; preview for this head is autobuild-preview-pr-417-bc08a6e1. Checked both stress tests against the jsc shells in the debug-asan prebuilts: they assert on 0cbb4a1 and pass on this preview. oven-sh/bun#37941 now pins this preview on top of current Bun main.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

A second fuzzer input hit the same assertion with the (trivial) dispose method inlined into the dispose call and the function made hot through recursion; same phase and same local, and this branch's preview engine compiles it fine. A deterministic fixture for that shape was added on the Bun side (oven-sh/bun#37941), nothing to change here.

@robobun
robobun force-pushed the farm/a3a8c180/live-catch-handler-transition branch from bc08a6e to 9984a38 Compare August 17, 2026 00:08
Comment on lines +194 to +205
if (newHandler != currentExceptionHandler) {
// liveAtCatchHead still describes the handler we are leaving. Flush for it before
// switching over to the liveness of the handler we are entering, otherwise a
// transition straight from one handler into another (e.g. leaving a try range that
// is nested inside another one) flushes the outer handler's locals instead of the
// inner handler's.
if (currentExceptionHandler)
flushEverything(node->origin, nodeIndex);
currentExceptionHandler = newHandler;
if (newHandler)
computeLiveAtCatchHead();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 The transition is now keyed on the HandlerInfo* pointer alone, so computeLiveAtCatchHead() is skipped when the same handler entry is reached via a different inlineCallFrame — recursive inlining puts the same baseline CodeBlock at two depths, so the same try range returns the identical HandlerInfo* for both, but the catch head's CodeOrigin (and hence its operand liveness) differs. The pre-PR code refreshed liveAtCatchHead on every cache-miss lookup so it stayed in sync for this case; keying the transition on cachedCatchOrigin (or the (HandlerInfo*, InlineCallFrame*) pair) instead of the pointer alone restores that and also makes the transition flush fire, which is what you want anyway.

Extended reasoning...

What changed. Before this PR, catchHandler() refilled liveAtCatchHead inline on every cache-miss lookup that found a handler. After this PR the refill is factored into computeLiveAtCatchHead() and gated on newHandler != currentExceptionHandler — a HandlerInfo* pointer comparison. cachedCatchOrigin is still updated inside catchHandler() on every cache miss, but liveAtCatchHead is only recomputed when the caller sees the pointer change.

Why the pointer can stay the same while the catch origin changes. handlerForBytecodeIndex() returns a pointer into the baseline CodeBlock's m_rareData->m_exceptionHandlers. With recursive inlining (maximumInliningRecursion defaults to 2, and functions containing op_catch are inlinable — DFGByteCodeParser handles inlined op_catch at ~line 9774 rather than refusing), the outer and inner inline frames share the same baseline CodeBlock*, so the same try range yields the identical HandlerInfo* at both depths. But the catch head's CodeOrigin differs by InlineCallFrame*, and forAllLocalsAndTmpsLiveInBytecode() remaps each local through inlineCallFrame->stackOffset and walks the caller chain, so {handler->target, ICF_outer} and {handler->target, ICF_inner} produce different operand bitmaps.

Step-by-step trace. Take function f() { try { f(); } catch {} } with the recursive call inlined once. inlineCall() does not allocate a new block at callee entry (parseCodeBlock() reuses m_currentBlock on its first iteration), and a try-range start is not a jump target, so one DFG block can contain, in order:

  1. Outer-frame try-body nodes at {bc#X, ICF=null}. catchHandler finds H in the root baseline block; cachedCatchOrigin = {H->target, null}. newHandler != currentExceptionHandler (null → H), so computeLiveAtCatchHead() runs and liveAtCatchHead describes the outer frame's locals.
  2. Inlined prologue / op_enter at {bc#0, innerICF}. bc#0 is outside every try range, so catchHandler walks up to the direct caller and again finds H with cachedCatchOrigin = {H->target, null}. Same pointer, no recompute — still correct.
  3. Inner-frame try-body nodes at {bc#Y, innerICF}. Cache miss; catchHandler finds H directly in the inner frame's baseline block (same CodeBlock* as the root) and sets cachedCatchOrigin = {H->target, innerICF}. But it returns the same H, so newHandler == currentExceptionHandler and computeLiveAtCatchHead() is skipped. liveAtCatchHead is still keyed to {H->target, null}; the inner frame's locals (mapped to higher machine-local indices via innerICF->stackOffset) are all false in it.

From step 3 onward, the SetLocal check at line 210 and the block-end flushEverything at line 228 both consult the stale outer-frame bitmap, so inner-frame locals live at the inner catch head get no Flush. An exception from the inner try body OSR-exits to the inner catch with those locals unavailable — exactly the failure class this PR is fixing (DFGOSRAvailabilityAnalysisPhase.cpp(198) assertion in debug, wrong restored value in release).

Regression vs. pre-PR. The old code recomputed liveAtCatchHead inside the lambda at step 3 regardless of whether the returned pointer changed, so the SetLocal path and the block-end flush saw the correct inner-frame bitmap. The pre-PR code already failed to flushEverything at a same-pointer transition — that gap is pre-existing and not what this comment is about — but it did keep liveAtCatchHead in sync for everything after the transition; the new code no longer does.

Fix. Key the transition on the catch CodeOrigin rather than the HandlerInfo* alone: e.g. remember cachedCatchOrigin before calling catchHandler() and compare the before/after values (treating the null-handler case as an invalid origin), or compare the (HandlerInfo*, InlineCallFrame*) pair. That also makes the mid-block flushEverything fire at the outer→inner boundary, which is the desired behaviour since the inner catch's live set is what the exception exit needs from that point on.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 17, 2026
…ized code

DFG's LiveCatchVariablePreservationPhase flushed the locals of the handler
being entered instead of the handler being left when one try range led
directly into another. The dispose call emitted for a `using` block ends its
synthesized catch range right at the call, inside the enclosing handler, so
the "body threw" flag that only the synthesized catch reads was never flushed.
FTL compiles of such code failed OSR availability validation, and optimized
code restored the flag as undefined when a dispose method threw after the
body threw, dropping the body's error instead of reporting a SuppressedError.

Pins oven-sh/WebKit#417's preview build and adds both shapes as jsc-stress
fixtures.
@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto c6cfe90 (Bun main's current pin) so the preview stays usable from oven-sh/bun#37941; diff unchanged. Preview for this head: autobuild-preview-pr-417-9984a386.

@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

A third fuzzer input hit the same assertion, this time the eval shape from the description again (for (using v of [resource]) { try { SharedArrayBuffer.call(); } catch (e) {} } followed by an endless loop). The c6cfe90 debug-asan jsc shell asserts on it in the FTL compile of the eval code; the shell from this PR's preview (9984a38) compiles it. It is the case for-using-dispose-call-live-catch-locals-ftl-validation.js already covers, so no change here.

…the handler being entered

LiveCatchVariablePreservationPhase walks each block and, whenever a node's
origin is covered by a different exception handler than the previous node,
inserts Flushes for the locals live at the head of the handler it is leaving.
The handler lookup lambda also refilled liveAtCatchHead with the liveness of
the handler it found, so on a transition straight from one handler into
another the flushes were computed from the handler being entered instead of
the one being left. Transitions into no handler at all were unaffected, which
is why ordinary try/catch (whose jump over the catch block sits inside the try
range) did not hit this.

The disposal code emitted for `using` ends the try range of its synthesized
catch right after the dispose call, so the block containing that call ends
with a synthesized Jump whose origin already belongs to the enclosing handler.
The "body threw" flag, which only the synthesized catch reads, was therefore
never flushed or given a Phi and was unavailable at the dispose call's
exception exit: FTL compiles of such code fail OSR availability validation,
and optimized code restores the flag as undefined when a dispose method
throws after the body threw, so the body's error is dropped instead of being
reported through a SuppressedError.

Keep the lookup free of side effects, flush for the old handler first, and
only then compute the liveness of the new one.
@robobun
robobun force-pushed the farm/a3a8c180/live-catch-handler-transition branch from 9984a38 to ece09c1 Compare August 18, 2026 04:40
@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto eeab040 (Bun main's current pin, after #455 and #463); the patch is unchanged and DFGLiveCatchVariablePreservationPhase.cpp did not change in the upstream merge. The eeab040 debug-asan jsc shell still asserts on both stress tests here, so this is still needed after the upgrade. Preview for this head will be autobuild-preview-pr-417-ece09c13, for oven-sh/bun#37941.

return null;
}

for (let i = 0; i < 20000; ++i) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Both new stress tests hardcode their warmup iteration counts (20000 here and 2000000 at for-using-dispose-call-live-catch-locals-ftl-validation.js:15) instead of using testLoopCount. JSTests/README.md requires new tests to use testLoopCount so they tier up where that matters and stay under the 200ms budget in no-JIT configurations; it is a global on the jsc shell's GlobalObject, so it is visible inside indirect eval (or can be interpolated via ${testLoopCount} since the eval string is already a template literal).

Extended reasoning...

What the issue is

JSTests/README.md (pulled into JSTests/CLAUDE.md via @README.md) states, under "Adding Tests":

New tests are required to adhere to the following rules:

  1. Tests must run in under 200ms in all configurations. …
  2. Use testLoopCount or wasmTestLoopCount to control how many iterations a test runs. The jsc CLI sets these based on the configuration of the test, so tests iterate enough to tier up where that matters and exit early where it doesn't.

Both new stress tests in this PR hardcode their tier-up iteration counts instead:

  • JSTests/stress/using-dispose-throw-after-body-throw-in-jit.js:44for (let i = 0; i < 20000; ++i)
  • JSTests/stress/for-using-dispose-call-live-catch-locals-ftl-validation.js:15for (let i = 0; i < 2000000; ++i) { } inside the eval string

Why testLoopCount applies here

testLoopCount is set by the jsc shell as a direct property on the global object (jsc.cpp:674, via putDirect) and is scaled per configuration (jsc.cpp:662-666): it defaults to max(10000, thresholdForFTLOptimizeAfterWarmUp() * 3) when the FTL is on, drops to ~1000 with --useDFGJIT=0, and to ~100 with --useBaselineJIT=0. Both tests already pass --useConcurrentJIT=0, so testLoopCount iterations are sufficient to reach the FTL in the configurations where that is possible.

For the eval test specifically: the loop lives inside (0, eval)(...), but indirect eval executes in the global scope, so the testLoopCount global is visible there. The eval string is also already a template literal, so ${testLoopCount} interpolation works if you prefer to bake the number in.

Step-by-step: why the hardcoded counts are a problem

Take for-using-dispose-call-live-catch-locals-ftl-validation.js under the no-jit variant that run-jsc-stress-tests runs for every stress test:

  1. run-jsc-stress-tests launches the test with --useBaselineJIT=0 (among other flags). The jsc shell computes testLoopCount = clampLoopCount(100, thresholdForJITAfterWarmUp() * 3) — on the order of 100.
  2. The test ignores that and runs for (let i = 0; i < 2000000; ++i) { } in the LLInt. Two million empty iterations in the interpreter is well over the 200ms budget from rule maybe upgrade #1, and none of it is useful because there is no FTL to compile the eval code in this configuration.
  3. Multiply across the several no-JIT / no-LLInt variants and this adds measurable dead time to every stress run.

using-dispose-throw-after-body-throw-in-jit.js is less extreme (20k iterations of a real function call), but the same reasoning applies: in the no-jit variant those 20k warmup calls never tier run() up, so the loop is doing nothing the test needs while still counting against the 200ms budget.

Why nothing else prevents this

Neither file has a //@ skip if … or //@ runDefault directive that would exclude the no-JIT variants, so run-jsc-stress-tests runs them in every configuration. The --useConcurrentJIT=0 / --validateGraph=1 options passed via //@ requireOptions do not change which variants run.

Impact

This is a test-hygiene / repository-convention issue, not a correctness bug in the DFG fix. The tests still reproduce the bug and still pass with the fix; they just spend more wall-clock than necessary in configurations where the JIT is disabled and diverge from the ~2200 existing stress tests that use testLoopCount.

Fix

// using-dispose-throw-after-body-throw-in-jit.js:44
for (let i = 0; i < testLoopCount; ++i) {

// for-using-dispose-call-live-catch-locals-ftl-validation.js:15
    for (let i = 0; i < ${testLoopCount}; ++i) { }
// or, since indirect eval sees globals:
    for (let i = 0; i < testLoopCount; ++i) { }

robobun added a commit to oven-sh/bun that referenced this pull request Aug 18, 2026
…ized code

DFG's LiveCatchVariablePreservationPhase flushed the locals of the handler
being entered instead of the handler being left when one try range led
directly into another. The dispose call emitted for a `using` block ends its
synthesized catch range right at the call, inside the enclosing handler, so
the "body threw" flag that only the synthesized catch reads was never flushed.
FTL compiles of such code failed OSR availability validation, and optimized
code restored the flag as undefined when a dispose method threw after the
body threw, dropping the body's error instead of reporting a SuppressedError.

Pins oven-sh/WebKit#417's preview build and adds both shapes as jsc-stress
fixtures.
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