-
Notifications
You must be signed in to change notification settings - Fork 52
DFG: LiveCatchVariablePreservationPhase flushed the wrong handler's locals when one try range leads directly into another #417
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| //@ requireOptions("--useExplicitResourceManagement=1", "--useConcurrentJIT=0", "--validateGraph=1") | ||
|
|
||
| // Found by fuzzing. The FTL compile of this eval code used to fail OSR | ||
| // availability validation ("Live bytecode local not available") on the local | ||
| // that the synthesized catch handler around the dispose call reads: the try | ||
| // range of that handler ends at a block boundary inside the enclosing for-of | ||
| // handler, and LiveCatchVariablePreservationPhase flushed the outer handler's | ||
| // locals instead of the inner handler's when it crossed from one to the other. | ||
|
|
||
| (0, eval)(` | ||
| const resource = { [Symbol.dispose]() { } }; | ||
| for (using r of [resource]) { | ||
| try { r(); } catch (e) { } | ||
| } | ||
| for (let i = 0; i < 2000000; ++i) { } | ||
| `); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| //@ requireOptions("--useExplicitResourceManagement=1", "--useConcurrentJIT=0") | ||
|
|
||
| // The disposal code emitted for a `using` block keeps "did the body throw" in a | ||
| // local that is only read by the synthesized catch handler wrapped around the | ||
| // dispose call. That handler has never run by the time the function is | ||
| // optimized, so nothing in the compiled code reads the local and it is only | ||
| // kept alive for the exception exit of the dispose call. The try range of the | ||
| // synthesized catch ends right at that call, inside the range of the enclosing | ||
| // handler, so LiveCatchVariablePreservationPhase used to switch to the outer | ||
| // handler's liveness before flushing for the inner one and dropped the local. | ||
| // The exit then restored it as undefined and the body's error was lost instead | ||
| // of being reported through a SuppressedError. | ||
|
|
||
| function shouldBe(actual, expected) { | ||
| if (actual !== expected) | ||
| throw new Error(`Expected ${expected} but got ${actual}`); | ||
| } | ||
|
|
||
| const state = { throwOnDispose: false }; | ||
|
|
||
| // Distinct executables so the dispose call site becomes megamorphic and stays a | ||
| // real call instead of being inlined (inlining splits the block and hides the bug). | ||
| const resources = []; | ||
| for (let i = 0; i < 16; ++i) { | ||
| resources.push({ | ||
| state, | ||
| [Symbol.dispose]: new Function(`if (this.state.throwOnDispose) throw new Error("dispose ${i}");`), | ||
| }); | ||
| } | ||
|
|
||
| function run(resource, bodyShouldThrow) { | ||
| try { | ||
| { | ||
| using r = resource; | ||
| if (bodyShouldThrow) | ||
| throw new Error("body"); | ||
| } | ||
| } catch (e) { | ||
| return e; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| for (let i = 0; i < 20000; ++i) { | ||
|
Check warning on line 44 in JSTests/stress/using-dispose-throw-after-body-throw-in-jit.js
|
||
| const error = run(resources[i % resources.length], i & 1); | ||
| shouldBe(error === null, !(i & 1)); | ||
| } | ||
|
|
||
| state.throwOnDispose = true; | ||
| const error = run(resources[0], 1); | ||
| shouldBe(error instanceof SuppressedError, true); | ||
| shouldBe(error.error.message, "dispose 0"); | ||
| shouldBe(error.suppressed.message, "body"); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -120,6 +120,7 @@ class LiveCatchVariablePreservationPhase { | |
|
|
||
| HandlerInfo* cachedHandlerResult; | ||
| CodeOrigin cachedCodeOrigin; | ||
| CodeOrigin cachedCatchOrigin; | ||
| auto catchHandler = [&] (CodeOrigin origin) -> HandlerInfo* { | ||
| ASSERT(origin); | ||
| if (origin == cachedCodeOrigin) | ||
|
|
@@ -133,13 +134,7 @@ class LiveCatchVariablePreservationPhase { | |
| InlineCallFrame* inlineCallFrame = origin.inlineCallFrame(); | ||
| CodeBlock* codeBlock = m_graph.baselineCodeBlockFor(inlineCallFrame); | ||
| if (HandlerInfo* handler = codeBlock->handlerForBytecodeIndex(bytecodeIndexToCheck)) { | ||
| liveAtCatchHead.fill(false); | ||
|
|
||
| BytecodeIndex catchBytecodeIndex = BytecodeIndex(handler->target); | ||
| m_graph.forAllLocalsAndTmpsLiveInBytecode(CodeOrigin(catchBytecodeIndex, inlineCallFrame), [&] (Operand operand) { | ||
| liveAtCatchHead.operand(operand) = true; | ||
| }); | ||
|
|
||
| cachedCatchOrigin = CodeOrigin(BytecodeIndex(handler->target), inlineCallFrame); | ||
| cachedHandlerResult = handler; | ||
| break; | ||
| } | ||
|
|
@@ -156,6 +151,14 @@ class LiveCatchVariablePreservationPhase { | |
| return cachedHandlerResult; | ||
| }; | ||
|
|
||
| // Liveness at the head of the handler most recently returned by catchHandler(). | ||
| auto computeLiveAtCatchHead = [&] { | ||
| liveAtCatchHead.fill(false); | ||
| m_graph.forAllLocalsAndTmpsLiveInBytecode(cachedCatchOrigin, [&] (Operand operand) { | ||
| liveAtCatchHead.operand(operand) = true; | ||
| }); | ||
| }; | ||
|
|
||
| Operands<VariableAccessData*> currentBlockAccessData(OperandsLike, block->variablesAtTail, nullptr); | ||
|
|
||
| auto flushEverything = [&] (NodeOrigin origin, unsigned index) { | ||
|
|
@@ -188,9 +191,18 @@ class LiveCatchVariablePreservationPhase { | |
|
|
||
| { | ||
| HandlerInfo* newHandler = catchHandler(node->origin.semantic); | ||
| if (newHandler != currentExceptionHandler && currentExceptionHandler) | ||
| flushEverything(node->origin, nodeIndex); | ||
| currentExceptionHandler = newHandler; | ||
| 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(); | ||
| } | ||
|
Comment on lines
+194
to
+205
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 The transition is now keyed on the Extended reasoning...What changed. Before this PR, Why the pointer can stay the same while the catch origin changes. Step-by-step trace. Take
From step 3 onward, the Regression vs. pre-PR. The old code recomputed Fix. Key the transition on the catch |
||
| } | ||
|
|
||
| if (currentExceptionHandler && (node->op() == SetLocal || node->op() == SetArgumentDefinitely || node->op() == SetArgumentMaybe)) { | ||
|
|
||
There was a problem hiding this comment.
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 (
20000here and2000000atfor-using-dispose-call-live-catch-locals-ftl-validation.js:15) instead of usingtestLoopCount.JSTests/README.mdrequires new tests to usetestLoopCountso 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 intoJSTests/CLAUDE.mdvia@README.md) states, under "Adding Tests":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:44—for (let i = 0; i < 20000; ++i)JSTests/stress/for-using-dispose-call-live-catch-locals-ftl-validation.js:15—for (let i = 0; i < 2000000; ++i) { }inside the eval stringWhy
testLoopCountapplies heretestLoopCountis set by the jsc shell as a direct property on the global object (jsc.cpp:674, viaputDirect) and is scaled per configuration (jsc.cpp:662-666): it defaults tomax(10000, thresholdForFTLOptimizeAfterWarmUp() * 3)when the FTL is on, drops to ~1000with--useDFGJIT=0, and to ~100with--useBaselineJIT=0. Both tests already pass--useConcurrentJIT=0, sotestLoopCountiterations 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 thetestLoopCountglobal 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.jsunder theno-jitvariant thatrun-jsc-stress-testsruns for every stress test:run-jsc-stress-testslaunches the test with--useBaselineJIT=0(among other flags). The jsc shell computestestLoopCount = clampLoopCount(100, thresholdForJITAfterWarmUp() * 3)— on the order of100.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.using-dispose-throw-after-body-throw-in-jit.jsis less extreme (20k iterations of a real function call), but the same reasoning applies: in theno-jitvariant those 20k warmup calls never tierrun()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//@ runDefaultdirective that would exclude the no-JIT variants, sorun-jsc-stress-testsruns them in every configuration. The--useConcurrentJIT=0/--validateGraph=1options passed via//@ requireOptionsdo 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