Repository navigation
JIT: make sure the block a catch returns to generates an instruction - #135638
pcshrosbree wants to merge 7 commits into
Conversation
…re disabled When a catch ends with a thread abort pending, the runtime raises the abort again from the address the catch returns to and looks the handlers up for that address. fgOptimizeEmptyBlock keeps the block a catch returns to from being empty (it adds a GT_NO_OP) when the block is in a different EH region than its successor, so that the address stays inside the block's regions. That only runs when optimizing. In MinOpts and debuggable code the block falls into its successor without generating code, the address is the end of its regions, and the abort is dispatched as if it had been raised in the code that follows: a finally the catch's leave passes through does not run, and the handlers that see the abort differ from optimized code. Move the check into a helper and run it from fgPostImportationCleanup when optimizations are disabled. Optimized code is unchanged. Fixes dotnet#135637
|
Azure Pipelines: Successfully started running 5 pipeline(s). 11 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch |
|
@EgorBo PTAL. |
|
I don't think this belongs in the IR, especially not right after import. Checking
Optimized code has the same bug, I don't see why not (meaning this PR doesn't fix it). |
Whether the block a catch returns to generates an instruction is only known in codegen. The block can be empty, as in unoptimized code, or in optimized code when it is the first block of a try region (fgUpdateFlowGraph skips those), or it can contain only nodes that generate no code, such as GC.KeepAlive of an argument, which fgOptimizeEmptyBlock does not see as an empty block. Either way the address the catch returns to then belongs to the code that follows, and a thread abort raised again at that address reaches the wrong handlers. Do the check in genEmitEndBlock: if a BBJ_ALWAYS block that a catch returns to has emitted nothing, emit a nop when its jump to the next block is omitted, and otherwise do not mark the jump as a candidate for emitRemoveJumpToNextInst, which on xarch could remove it and leave the block empty again. Do not depend on the EH region of the next block: when that block emits nothing either, the address belongs to a later one. Remove the GT_NO_OP that fgPostImportationCleanup added when optimizations are disabled, and the one fgOptimizeEmptyBlock adds: it still keeps such a block, and codegen now emits the instruction, without a nop in front of a jump that the block keeps.
In optimized code the block a catch returns to can still generate no code: - KeepAliveAfterCatch and KeepAliveThenFinally: the block only calls GC.KeepAlive on an argument. The outer catch did not see the abort raised again after the inner catch, and the finally did not run. The inner try ends with a store rather than with the call, because a nop can follow a call at the end of a try (on x64 always, on arm64 for GC info) and then hides the problem. - CatchInLoop: the catch's try is the body of a loop. The catch ends up returning to the loop's head, an empty block followed by the try, so the abort was raised again inside the try and the same catch ran again. The methods are marked AggressiveOptimization so the default test run covers optimized code.
… issue Under the interpreter the outer catch does not see the abort raised again after the inner catch in LeaveOutOfOuterTry and CatchInLoop. That is dotnet#135871, so skip the test there with an ActiveIssue instead of folding the interpreter into the test's own condition.
|
Thanks @EgorBo, I've moved the check to You're right about optimized code too. The test now covers a continuation containing only I pushed the codegen change, the added test shapes, and an |
| <!-- Needed for CLRTestTargetUnsupported --> | ||
| <RequiresProcessIsolation>true</RequiresProcessIsolation> | ||
| <!-- ControlledExecution is supported on CoreCLR only --> | ||
| <CLRTestTargetUnsupported Condition="'$(RuntimeFlavor)' != 'coreclr'">true</CLRTestTargetUnsupported> |
There was a problem hiding this comment.
It is better to use ConditionalFact for this (nameof(PlatformDetection.IsCoreCLR)). RequiresProcessIsolation tests are much more expensive to run
There was a problem hiding this comment.
Done in 6dc56e4: the test now skips non-CoreCLR runtimes with ConditionalFact and runs in the merged Regressions runner. Since it aborts threads in that shared process, I ran the whole Regressions group with and without it on linux-x64, osx-arm64 and windows-x64: no other test changed outcome.
There was a problem hiding this comment.
Also removed the remaining NativeAotIncompatible in eef3171; the IsCoreCLR condition already excludes NativeAOT.
Skip it on runtimes other than CoreCLR with ConditionalFact instead of CLRTestTargetUnsupported, which needed RequiresProcessIsolation.
fgOptimizeEmptyBlock keeps an empty block that a catch returns to when
the block is in a different EH region than its successor, and adds a
GT_NO_OP to it. An earlier commit here removed that node, because
codegen now makes such a block emit an instruction. But the node also
keeps the block non-empty in the IR, and later flow optimizations depend
on that: fgOptimizeBranchToEmptyUnconditional redirects the branches to
an empty BBJ_ALWAYS block to its target.
On Wasm, fgWasmEhFlow adds, for each catch continuation, a resume pad
that branches to it. With the continuation empty, the fgUpdateFlowGraph
call in fgLateLiveness, after lowering, redirected the pad past it, so
the continuation was reachable only from the catch return, which
fgWasmControlFlow does not support ("NYI_WASM: Method has blocks only
reachable via EH"). Crossgen of System.Private.CoreLib for browser-wasm
failed that way, in IndentedTextWriter.WriteAsync and OutputTabsAsync.
Keep the GT_NO_OP, as before. Codegen still covers the catch-return
targets that fgOptimizeEmptyBlock does not handle.
|
The codegen rework broke the CoreCLR browser-wasm and wasi-wasm builds, so I've restored the With 98d74ae the net JIT change is only the check in |
ControlledExecution is supported on CoreCLR only. The test's ConditionalFact already checks PlatformDetection.IsCoreCLR, which is false on Mono and NativeAOT, so remove NativeAotIncompatible, the last project property that excluded the test.
When a catch block ends with a thread abort pending, the runtime raises the abort again from the address the catch returns to and looks the handlers up for that address (
EECodeManager::ResumeAfterCatch→COMPlusCheckForAbort→ThrowControlForThread, which dispatches from aFaultingExceptionFrameat the exact resume address). The JIT accounts for that:impImportLeaveplaces the block a catch returns to inside the EH regions that enclose the catch (see its comment onThreadAbortException), andfgOptimizeEmptyBlockkeeps that block when it is empty and in a different EH region than its successor, and adds aGT_NO_OPto it, "for re-raise of thread abort exceptions to work".That only works if the block generates an instruction, and whether it does is only known in codegen. When it generates none, the resume address belongs to the code that follows, and the abort is dispatched as if it had been raised there. This happens in unoptimized code (tier 0,
JITMinOpts, debuggable code), wherefgOptimizeEmptyBlockdoes not run, and in optimized code too:fgUpdateFlowGraphnever passes it tofgOptimizeEmptyBlock: a block whose only statement isGC.KeepAliveof an argument;fgUpdateFlowGraphskips (BBF_DONT_REMOVE): in a loop of try/catch inside another try, the catch returns to the loop head, an empty block that begins the outer try and falls into the inner one, so the abort is raised again inside the inner try and the same catch runs again;emitRemoveJumpToNextInstremoves because the blocks between it and its target generate no code (the step block ofLeaveOutOfOuterTrybelow, at tier 0).In the shapes of #135637 a
finallythe catch'sleavepasses through does not run, an enclosing catch does not see the abort, and atrythat starts right after the catch does.This change makes
genEmitEndBlockguarantee that the block generates an instruction: if aBBJ_ALWAYSblock that a catch returns to has emitted nothing since its label, it emits anopwhen the jump to the next block is omitted, and otherwise does not mark the jump as removable. The check does not depend on the EH region of the next block, because when that block emits nothing either, the address belongs to a later one: a variant that acted only when the next block, or the jump's target, was in another EH region left 7 catch-return targets with mismatched EH membership in the SuperPMI replay below (tier-0 instantiations ofSystem.Numerics.Tensors.Tests.Helpers.AssertEqualWithTolerance).fgOptimizeEmptyBlockstill adds itsGT_NO_OP: an earlier revision of this PR removed it, and Wasm crossgen then failed becausefgUpdateFlowGraphredirected the resume pad thatfgWasmEhFlowadds past the now empty block, leaving it reachable only from the catch return.What changes
Checked builds on linux-x64, the JIT from this branch against a JIT built at main 523a36f on the same runtime; three runs per configuration and shape, identical each time. The table uses standalone copies of the test's six shapes without
AggressiveOptimization, so that each configuration applies to them (the copy ofCatchInLoopalso appends a marker before itsbreak); "finally ran" is a call-freevolatilestore.JITMinOpts=1beforeTieredCompilation=0beforeCatchThenFinallycatch outer, finally not runcatch finally outerin all threeLeaveOutOfOuterTryinnerinnerinner outerin all threeConsecutiveTryinner new catchinner new catchinnerin all threeKeepAliveAfterCatchinnerinner outerin all threeKeepAliveThenFinallycatch outer, finally not runcatch finally outerin all threeCatchInLoopoutercatch outerin all threeAt tier 1 the two
KeepAliveshapes fail as withTieredCompilation=0and pass with this change. On osx-arm64 and windows-x64 the same shapes fail with a JIT without this change, except thatLeaveOutOfOuterTryis correct on arm64, which has no late emitter pass that removes this jump across empty blocks, and all pass with this change.In the tier-0 JitDump of
CatchThenFinally, the catch-return blockBB03is now a one-bytenopemitted in codegen at0x1B, and the finally's try range covers it (EH#1: try [0014..001C); before:[0014..001B), no code forBB03). InLeaveOutOfOuterTrythe step block keeps its two-bytejmp(EH#1: try [0014..001E); before,emitRemoveJumpToNextInstremoved it and the range was[0014..001C)). In optimized code the first three shapes compile as before; forKeepAliveAfterCatchthe block gets anopthat the enclosing try covers (EH#1: try [000E..001D); before:[000E..001C)), and inCatchInLoopthe catch returned to0x0F, the first instruction of its own try (EH#0: try [000F..0017)), and now returns to anopthere, with the try starting at0x10.Tests
Regressions/coreclr/GitHub_135637/test135637has the six shapes.KeepAliveAfterCatch,KeepAliveThenFinallyandCatchInLoopare markedAggressiveOptimization, so the default configuration compiles them optimized;JITMinOpts=1still exercises MinOpts. With the current JIT the test fails under the default configuration,JITMinOpts=1andTieredCompilation=0; with the first version of this PR it fails under the default configuration andTieredCompilation=0; with this change it passes in all three, on linux-x64, osx-arm64 and windows-x64 (Windows needs #135531, now merged, on machines with CET shadow stacks). Removing either half of the check makes the test fail: without thenopunder all three configurations (linux-x64 and osx-arm64), and with the jump left removable under the default configuration andJITMinOpts=1(atLeaveOutOfOuterTry, linux-x64).Under the interpreter the test is skipped with an
[ActiveIssue]for #135871: there the outer catch does not see the abort inLeaveOutOfOuterTryandCatchInLoop(measured with #135520 applied; without it, Checked builds stop at the assert of #135463).JIT/Methodical(Methodical_d2,_r2,_do,_ro,_others),JIT/jit64(jit64_5) andbaseservices/exceptionson linux-x64 under the default configuration,TieredCompilation=0andJITMinOpts=1, with the JIT before and after the change: no result differs. 74 of the 79 runner scripts pass with both JITs, and the same five (baseservices/exceptions/inproccrashreport/*) fail with both on that machine.To look for the problem more widely I used a local diagnostic (not part of this PR). For methods without hot/cold splitting, it compares each catch-return target's address after codegen with the reported try and handler ranges in the same funclet. Replaying the linux-x64 SuperPMI collections records 2,421 mismatches with the current JIT: 2,411 in unoptimized code, and 10 in optimized code in 6 methods. In the optimized cases inspected, including
WebSocketHandle.ConnectAsync, Kestrel'sSocketConnectionListener.AcceptAsyncand OpenTelemetry'sProtobufOtlpResourceSerializer.SerializeResourceToBytes, the catch returns to the first instruction of a try that does not contain the block. With this change there are no recorded mismatches. Over the EH test trees above the diagnostic records 724 mismatches under the default configuration and 738 underJITMinOpts=1with the current JIT, and none with this change.SuperPMI asm diffs, Checked JITs: on linux-x64, 1,591 of 3,267,793 contexts change, MinOpts +2,659 bytes and FullOpts +10 bytes, +2,669 in total; on linux-arm64 with the cross-compiled JIT, 1,488 of 3,124,627 contexts change, MinOpts +8,796 and FullOpts +32, +8,828 in total; browser-wasm has no replay failures and no code-size change. Tier-0 code size, jitting with
ReadyToRun=0(CoreLib and the repro program, three runs per JIT, linux-x64): of 2,468 tier-0 methods whose size is stable across the runs, 4 grow (the three shapes of the repro andSystem.Diagnostics.StackFrameHelper:InitializeSourceInfo, by one byte each exceptLeaveOutOfOuterTry, which keeps a two-bytejmp); withTieredCompilation=0the 958 compared methods are unchanged. On arm64 thenopis four bytes.Resolves #135637
Note
This PR was prepared with AI assistance (Anthropic Claude, checked by OpenAI Codex) at my request. I reviewed the change, the test and the measurements before opening it.