Skip to content

[Interpreter] Raise a pending thread abort again after a catch handler - #135520

Closed
pcshrosbree wants to merge 5 commits into
dotnet:mainfrom
pcshrosbree:interp-abort-after-catch
Closed

pcshrosbree wants to merge 5 commits into
dotnet:mainfrom
pcshrosbree:interp-abort-after-catch

Conversation

@pcshrosbree

@pcshrosbree pcshrosbree commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #135463.

When interpreted code catches a ThreadAbortException, the abort has to be raised again at the end of the catch handler. The interpreter does that in the ResumeAfterCatchException handler of InterpExecMethod, and it had two problems there:

  1. It called COMPlusCheckForAbort without setting up Thread::m_OSContext, which COMPlusCheckForAbort leaves to its caller. Thread::ReadyForAsyncException asserts that the context points at managed code and walks the stack from it. EECodeManager::ResumeAfterCatch sets it up for compiled code. Now the interpreter fills it from the interpreter frame at the resume IP, with InterpreterFrame::SetContextToInterpMethodContextFrame.
  2. It dispatched the abort from inside the C++ catch block. When a frame of the same InterpExecMethod catches that abort, the ResumeAfterCatchException that resumes it is thrown while the catch block is still active, so the try block does not catch it (Checked: assert pFrame->PtrNextFrame() == NULL in exceptionhandling.cpp). Now the catch block sets a flag and the check runs at the top of the try block.

The abort is still raised as a return address at the resume IP, as before, so a try block that starts right at the resume point does not cover it. That matches optimized JIT code, which resumes on a nop in front of such a try block.

Tests:

  • New src/tests/Regressions/coreclr/GitHub_135463: the abort caught in the method, caught again by its caller, and kept pending at the end of a catch inside a finally. It checks the exception and which blocks ran.
  • ControlledExecutionTests.CancelInTryAndExitCatchNormally is enabled again. CancelItselfOutsideOfTryCatchFinally stays disabled: under DOTNET_InterpMode=1 it fails for a different reason, an abort that compiled code raises again through ThrowControlForThread and that is then resumed in an interpreted frame. I'll open a separate issue for that.

Validation on linux-x64, osx-arm64 and windows-x64 (Checked):

main this PR
test135463, JIT pass pass
test135463, DOTNET_InterpMode 1/2/3, with and without TieredCompilation=0 assert in ReadyForAsyncException pass
ControlledExecutionTests (9 tests), JIT 9 pass 9 pass
ControlledExecutionTests (9 tests), DOTNET_InterpMode=1 process dies (same assert) 9 pass

On windows-x64 I also ran ControlledExecutionTests one test per process under DOTNET_InterpMode 2 and 3, with a local workaround for #135535, which otherwise stops the tests during discovery. On main, CancelInTryAndExitCatchNormally hits the same assert. With this PR, all 9 pass.

The new test also passes under the JIT with TieredCompilation=0, JITMinOpts, JitStress 1 and 2, ReadyToRun=0, and GCStress 0x3 and 0xC. Each part of the change was taken out in turn and the test fails under the interpreter every time: without the m_OSContext setup it hits the assert above, without recording the resume IP it hangs, and with the check left in the catch block it hits the PtrNextFrame assert.

Note

This PR was prepared with AI assistance (Anthropic Claude, reviewed by OpenAI Codex). The builds and runs it cites were done at the PR's base commit.

COMPlusCheckForAbort expects its caller to set up Thread::m_OSContext for
the place where the execution resumes; the interpreter did not, so
ReadyForAsyncException asserted. The abort was also dispatched from inside
the C++ catch block that handles ResumeAfterCatchException, so when a frame
of the same InterpExecMethod caught it, the next ResumeAfterCatchException
was not caught. Do the check at the top of the try block instead, with
m_OSContext describing the interpreter frame at the resume IP.

Add a regression test and enable
ControlledExecutionTests.CancelInTryAndExitCatchNormally again.

Fixes dotnet#135463
@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Oct 9, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli
See info in area-owners.md if you want to be subscribed.

It aborts threads in a few scenarios and is a niche case.
Comment thread src/coreclr/vm/interpexec.cpp Outdated
INSTALL_UNWIND_AND_CONTINUE_HANDLER;

if (resumedAfterCatch)
{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ideally, we should have no special checks on this execution path and keep it as simple as possible, just so we can have the best perf possible, even if it is just a var init and a check for it.

In the context of this PR, isn't this implementation basically an implicit rethrow of the abort exception ? Sounds like we could avoid resuming altogether and throw the abort exception earlier, while executing the catch from EH, same as a rethrow I believe is processed. Maybe we could add a check for thread abort somewhere around INTOP_LEAVE_CATCH. cc @janvorli

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed on the principle, and it was a little worse than one init and one test per entry: EXIT_FRAME re-enters the try through goto MAIN_LOOP on every return from a non-recursive interpreted call, so the flag was tested about once per interpreted call, not once per entry. 35f8910 takes it off that path entirely. To answer the second half properly, on Checked linux-x64 I also prototyped the INTOP_LEAVE_CATCH version and ran both through the same tests, so here is what I found.

Why it is not a rethrow. A rethrow re-raises the caught exception from inside the handler while its dispatch is still in flight. The abort re-raise is defined to happen after the catch has completed and its EH state is gone, at the resume point: CallCatchFunclet calls the funclet, then ExInfo::PopExInfos, then ResumeAfterCatch, and COMPlusCheckForAbort relies on that order. For a non-rude abort it returns early while GetThrowable() is non-null, which it is for the whole catch funclet, and ReadyForAbort rejects a context classified inside a catch handler on the aborting thread itself (TAStackCrawlCallBackWorker skips non-finally clauses only when the aborter is another thread). So a check at INTOP_LEAVE_CATCH that calls the existing helper never fires: with a trace line at the raise site it raised zero times in every run, and CancelInTryAndExitCatchNormally fails because the code after the catch runs.

Making it fire needs the parent InterpreterFrame plumbed into ExceptionClauseArgs for the resume-point context, a variant of the check without the pending-throwable clause, and a different walk start frame (the funclet's frames sit below the resume SP, so the existing computation lands on the owning InterpreterFrame and StackFrameIterator::Init asserts). With all of that the abort does get raised from the funclet, and then it is caught by the same catch again: the leavecatch island sits at the try's end offset, the new first pass starts from there, the handler runs again, raises again, and the native stack overflows after about four hundred nested dispatches. The same happens if the check is reduced to IsAbortRequested(). Where an enclosing try exists, its catch takes the abort first; the shape, with the traces as measured:

try
{
    try { RunInfiniteLoop(); }
    catch (ThreadAbortException) { s_trace += "inner catch "; goto Done; }
}
catch (ThreadAbortException) { s_trace += "outer catch "; }
Done:
s_after = true;
JIT (tiered) flag at the try top (previous revision) check on the resume path (this revision) raise at INTOP_LEAVE_CATCH
trace inner catch inner catch inner catch inner catch outer catch outer catch ... until the watchdog fires

This revision matches the tiered JIT in that example, but other JIT configurations also differ from each other there (with tiering off the outer catch runs), so I would not call it a universal compiled-code comparison. What does hold from the code is that raising before the funclet returns bypasses the handler's normal post-return callbacks and keeps each re-raise nested on the native stack, and the loop above shows the dispatch has no notion of a completed handler to skip. I favour keeping the check at the resume point; the funclet prototype and its plumbing are on hand if you want to look at that route anyway.

What this revision does instead. The catch block for ResumeAfterCatchException only records the resume SP and IP and jumps to a label outside any try, which does the unwind as before, describes the resume point in m_OSContext, and calls the NOTHROW COMPlusCheckForAbort. If the abort is due it jumps to a second, cold try with its own dispatcher and unwind handler, calling DispatchManagedException(kThreadAbortException); that block's catch funnels back into the same label, so a further catch in the same InterpExecMethod that swallows the abort again is handled the same way. The resumedAfterCatch local and its test are gone; the main loop's entry and return paths are what they were before the first revision of this PR. The abort is still raised at the resume point after the EH state has been popped, where EECodeManager::ResumeAfterCatch raises it for compiled code.

Measured on Checked runtimes: test135463's three cases pass under the JIT and DOTNET_InterpMode 1, 2 and 3 on linux-x64, osx-arm64 and windows-x64, and ControlledExecutionTests passes under modes 1, 2 and 3 on linux-x64, 1 and 3 on osx-arm64 and 1 on windows-x64, with Linux interpreter marker counts confirming the new raise site. On linux-x64, in interpreter modes 1 and 3, nine abort shapes (the one above, catch inside finally, catch then finally, abort requested during a catch, a filter on an enclosing catch, consecutive trys, the stack-trace shape, and the test's three) give identical traces for the previous revision and this one in every cell. On the entry-path cost, in a linux-x64 Release build the instruction path from an interpreted return back to the first opcode dispatch goes from 37 to 28 instructions, and the function-entry path is unchanged within one instruction (register allocation moved); on linux-x64 Checked and Release, InterpMode 1 with tiering disabled, a 20M-iteration interpreted call/return loop and a 2M-iteration native-to-interpreter entry loop show median differences between the two revisions within the run-to-run spread, so I would not claim a measurable win, only that nothing abort-specific executes there any more.

One thing the comparison turned up about the test itself: under InterpMode 2 and 3 the s_trace += "unreachable " after each catch is an interpreted String.Concat, and a runtime that never re-raised at the end of the catch still passed those three cases there, consistent with a safepoint of that call raising the pending abort before the store (measured with the check removed, Checked linux-x64; the same runtime fails under mode 1 and in ControlledExecutionTests). 7e70fe0 records that point with a call-free store and asserts on it; with the re-raise removed the test now fails in all tested interpreter configurations on Checked linux-x64.

For completeness I also tried raising from InterpreterCodeManager::ResumeAfterCatch directly, mirroring EECodeManager: the interpreter's frame chain is unwound only in InterpExecMethod's catch, so the new first pass starts at the original throw site, the same catch handles the abort again, and CatchInsideOfFinally runs its finally twice.

Note

This reply and the two commits were prepared with AI assistance (Anthropic Claude, checked by OpenAI Codex) at my request. I reviewed the change and the measurements before posting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A clarification of the LeaveOutOfOuterTry comparison in my previous comment, where this revision matched the tiered JIT and I noted that the JIT configurations differ from each other there. The match with tier-0 code was real, but that tier-0 result comes from a JIT issue I have since reported as #135637, with a proposed fix in #135638: in tier 0 the block the catch returns to generates no code, so the abort is dispatched as if it had been raised in the code after the enclosing try. With #135638, tier 0, DOTNET_JITMinOpts=1 and DOTNET_TieredCompilation=0 all give inner outer. That is also the behaviour the JIT intends: when a catch's leave exits an enclosing try that has a catch, impImportLeave makes the catch return to a step block inside that try, and its comment says why (importer.cpp):

If a try/catch catches a ThreadAbortException (which might be because it catches a parent, e.g. System.Exception), and the catch doesn't call System.Threading.Thread::ResetAbort(), then when the catch returns to the VM, the VM will automatically re-raise the ThreadAbortException. When it does this, it uses the target address of the catch return as the new exception address. That is, the re-raised exception appears to occur at the catch return address. If this exception return address skips an enclosing try/catch that catches ThreadAbortException, then the enclosing try/catch will not catch the exception, as it should.

[...]

Note that this pattern isn't theoretical: it occurs in ASP.NET, in IL code generated by the Roslyn C# compiler.

Compared with the JIT built with #135638, the interpreter with this PR differs on that shape: inner under DOTNET_InterpMode=1 and 3, where the JIT gives inner outer (Checked linux-x64; builds of this PR's runtime change at 35f8910 and at the previous revision 08e3dfb, each with a trace line added at the raise site; three runs per mode and build, all identical). The other two shapes of #135637 match the fixed JIT (catch finally outer, with the finally run, and inner).

From the source and an InterpDump, the difference comes from where the interpreter compiler sends the catch, not from this PR's raise. CreateLeaveChainIslandBasicBlocks creates a leave island for each catch handler and each finally-protected try that a leave exits, and none for an enclosing try protected by a catch, so the inner catch's leavecatch goes straight to the leave target, past the outer try. DOTNET_InterpDump=LeaveOutOfOuterTry with DOTNET_InterpMode=1 and this PR (excerpt, comments mine):

0018: IR_0004: call           [0 <- 16], .AbortReraise:RunInfiniteLoop()
0028: IR_0008: br             [nil <- nil], IR_000c
0030: IR_000a: leavecatch     [nil <- nil], IR_0010    ; end of the inner catch
0038: IR_000c: br             [nil <- nil], IR_0010
0040: IR_000e: leavecatch     [nil <- nil], IR_0010    ; end of the outer catch
0048: IR_0010: ldc.i4         [0 <- nil], 1            ; Done:
...
  try [IR_0004(0), IR_000a(7)) catch handler [IR_001c(7), IR_0035(1e))
  try [IR_0004(0), IR_000e(20)) catch handler [IR_0035(20), IR_004e(37))

The outer try is [IR_0004, IR_000e). The inner catch resumes at IR_0010, and the instruction just before that is the outer catch's leavecatch at IR_000e, already outside the range, so for this shape the outer catch is skipped whether handlers are looked up at the resume address or just before it.

I don't think this should hold up this PR, and I don't know whether the interpreter means to follow the JIT here. If it does, a step island inside the enclosing try for the catch to return to, as the JIT has, looks like the natural shape. I'm happy to open an issue for it or try a change, whichever you prefer. Until then the regression test in #135638 runs on jitted code only, for this reason.

Note

This reply was prepared with AI assistance (Anthropic Claude, checked by OpenAI Codex) at my request. I reviewed it before posting.

The check that raises a pending thread abort again after an interpreted
catch handler ran at the top of InterpExecMethod's try block, behind a flag
set when a frame resumed. That try block is entered on every interpreter
entry and on every return from a non-recursive interpreted call (EXIT_FRAME
jumps back to MAIN_LOOP), so the flag was tested about once per interpreted
call.

Move the check back to the resume path. The catch block for
ResumeAfterCatchException only records the resume SP and IP and jumps to
RESUME_AFTER_CATCH, outside any try block, which unwinds the interpreter
frames, describes the resume point in Thread::m_OSContext and calls
COMPlusCheckForAbort, which does not throw. If the abort is due, it jumps to
ABORT_AFTER_CATCH, a try block of its own that dispatches the
ThreadAbortException; its catch funnels back into RESUME_AFTER_CATCH, so a
further catch in the same InterpExecMethod that swallows the abort again is
handled the same way. The entry and return paths are what they were before
the check was added, and the abort is still raised at the resume point
after the EH state has been popped, as EECodeManager::ResumeAfterCatch does
for compiled code.
The three cases recorded "reached the code after the catch" with
s_trace += "unreachable ", which is a call to String.Concat. Under
DOTNET_InterpMode 2 and 3 the cases passed on a runtime that did not raise
the abort again at the end of the catch, consistent with a safepoint of
that call raising the pending abort before the store. Replace the store
with a volatile bool set without a call and assert that it stayed false;
with the re-raise removed the test now fails in all tested interpreter
configurations on a Checked linux-x64 runtime.
janvorli pushed a commit that referenced this pull request Oct 14, 2026
…#135531)

Fixes #135530.

On windows-x64 with shadow stacks, a Checked runtime asserts in
`CallCatchFunclet` when a thread abort that the runtime raises again at
the end of a catch handler is caught by another handler in the same
method.

`EECodeManager::ResumeAfterCatch` sends such a thread to
`ThrowControlForThread`, and `RedirectForThrowControl` records in the
`FaultingExceptionFrame` the SSP at the point where the thread was
redirected. That frame was interrupted rather than stopped at a call, so
the slot below its SSP does not hold its `ControlPC`, and the assert's
last clause fails when the catch handler is in that frame. The SSP
points at the frame's return address, which is what resuming the frame
needs. The assert now also accepts a handler frame that has faulted.
Release builds are unchanged.

Test: new `src/tests/Regressions/coreclr/GitHub_135530`, the case from
the issue. It is disabled under the interpreter until #135520 (fixes
#135463) is in; the interpreter fails it for that reason, not this one.

Validation (Checked):

| | main | this PR |
|---|---|---|
| windows-x64 (CET enabled), JIT | assert in `CallCatchFunclet` | pass |
| windows-x64, JIT with `TieredCompilation=0`, `JITMinOpts`, `JitStress`
1 and 2, `ReadyToRun=0`, `GCStress` 0x3 and 0xC | | pass |
| linux-x64 and osx-arm64, JIT, same configurations | pass | pass (the
assert is Windows x64 only) |

The other probes from the issue investigation also print the same on
windows-x64 as on linux-x64 with this change, including an abort raised
again right in front of a try block that catches it.

> [!NOTE]
> This PR was prepared with AI assistance (Anthropic Claude, reviewed by
OpenAI Codex). The builds and runs it cites were done at the PR's base
commit.
janvorli pushed a commit that referenced this pull request Oct 15, 2026
`GC/API/GC/GetGeneration` fails intermittently on CoreCLR legs because
an object allocated just before `GC.Collect()` is still in generation 0
afterwards:

```text
Running test: GC/API/GC/GetGeneration/GetGeneration.dll
ObjectTest Passed!
0 0
arrayTest Failed!
failTest Passed!

Test for GetGeneration() FAILED!
```

#126108 removed this test's `RequiresProcessIsolation`, which had been
there for `GCStressIncompatible`; that skip became a `[SkipOnCoreClr]`
attribute. Since then the test has run in-process in the merged `GC`
runner, after other tests. #132076 restored isolation for `Collect`,
`Collect0` and `Collect1`, whose generation assertions had started
failing after the same change. It did not include `GetGeneration`. In
#132291, janvorli said the most likely reason is the same as for the
Collect tests and that the fix would be the same as #132076. This PR
adds that change to `GetGeneration.csproj`: the same property group,
with the same comment.

I chose not to make the test tolerate an object that stays in gen0.
`objectTest` and `arrayTest` exist to check that a full collection
promotes a live, newly allocated object, so accepting "0 0" would remove
the check rather than fix it. The other tests in the directory that call
`GetGeneration` (`GetGenerationWR`, `GetGenerationWR2`,
`GetGeneration_box`, `GetGeneration_fail`) do not assert promotion, so I
left them in the merged runner.

Validation used one existing local linux-x64 Checked runtime, with the
priority-0 GC test tree built at this PR's parent and at this commit.
The runtime was the control build from the earlier #135520
investigation, not an unmodified main build; it was not rebuilt at
either of these commits, since this change touches only a test project.

- Before the change, the merged runner runs
`GC/API/GC/GetGeneration/GetGeneration.dll` in-process. After it, the
runner reports `GC/API/GC/GetGeneration/GetGeneration.cmd` and runs the
generated `GetGeneration.sh` in a child process, as it already does for
`Collect0` and `Collect1`. The test's result name in CI changes
accordingly, from `.dll` to `.cmd`.
- I ran the full merged `GC` runner from each tree 200 times,
interleaved, with 12 runs in parallel and the same corerun arguments as
the plain CI work item reported in #132291. All 53 tests passed in every
run of both sets. Isolation adds one test process per invocation. The
median runner time was 98.25 s with the change and 97.65 s without;
these concurrent local timings do not establish its CI cost. These runs
do not demonstrate a reduction in failures. The earlier investigation
described in my comment on #132291 recorded one `GetGeneration` failure
in 400 full merged-runner runs, 200 on each of two runtime builds used
to investigate #135520, both without this isolation change.
- To check the mechanism, I loaded the merged-runner `GetGeneration.dll`
into a host process and called `TestEntryPoint` 30,000 times (three runs
of 10,000), resetting the test's static counter between calls. Before
each call, the host pinned 200 arrays of 1,000 bytes, ran one collection
while they were held, released them, and pinned 200 new ones. 56 of the
calls failed, and the first failure in each run printed `ObjectTest
Passed!`, `0 0`, `arrayTest Failed!`, as in CI. The same host without
the pins had no failures in 30,000 calls, and pinning only right before
each call had none in 6,000. This shows one way that pins and prior
collections in the same process can produce this failure; it does not
establish whether pinned objects caused the CI failures or where they
came from. With the change, the test runs in its own process, which pins
held by the runner process cannot affect.

Fixes #132291

cc @kkokosa, since #132291 is assigned to you.

> [!NOTE]
> This change was prepared with AI assistance (Anthropic Claude, checked
by OpenAI Codex) at my request. I reviewed the change and the
measurements before opening.
@janvorli

Copy link
Copy Markdown
Member

@pcshrosbree I guess I can see the problem with the leave island you have shown. I think my original idea was that the catch leave island would be the resume after catch target. So the catch would end with INTOP_LEAVE_CATCH pointing to that island and that island would contain plain INTOP_BR. Then we would resume after catch there and rethrowing of the thread abort exception from that place would still be covered by the right try block.
So I've essentially flipped the INTOP_LEAVE_CATCH and INTOP_BR at the end of the catch and the catch island.
I guess that then what @BrzVlad has suggested in #135520 (comment) would work.

@pcshrosbree

pcshrosbree commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

@janvorli @BrzVlad, the flip gives the layout you describe. On its own, though, it makes this PR loop, so the abort raise has to change with it. I prototyped two ways to do that.

Which do you want: raise at the resume point, or raise at leavecatch? This PR already raises on the resume path: the first option keeps that and marks the frame faulting first, and the second moves the raise to the end of the catch. And should the flip land in this PR or separately? If the flip lands after this PR as it stands, it needs a faulting fix like the resume-point option's, or it loops. The leavecatch raise, as prototyped, needs the flip.

Option What it changes, relative to this PR's head (7e70fe0) Test outcome Size
Flip alone (not a candidate) the flip, with this PR's existing raise on the resume path unchanged loops n/a
Raise at the resume point the flip, plus pInterpreterFrame->SetIsFaulting(true); before this PR's existing dispatch, as INTOP_THROW and INTOP_RETHROW do; the rest of this PR stays passes, with the coverage limits below 2 files, +11/−4
Raise at leavecatch the flip, plus raising the abort at INTOP_LEAVE_CATCH (BrzVlad's suggestion), and the plumbing it requires passes, with the coverage limits below 7 files, +97/−39

I prototyped all three on Checked runtimes on linux-x64, osx-arm64 and windows-x64. Sizes exclude trace lines.

Two other observations relevant to the choice:

  • Stack trace. I checked this with a test case where an outer catch catches the re-raised abort. For the first frame of that abort's stack trace, the leavecatch raise reports a location inside the catch handler. The resume-point raise and this PR's head report the try's closing brace, and the JIT reports the call inside the try. The second frame agrees in all of them.
  • Dispatch path. Neither option adds an instruction on the return path to the opcode dispatch (Checked disassembly). I draw no performance conclusion from Checked timings.

The flip

In both test cases from #135871, the catch now ends with leavecatch to its island. The island holds br and lies inside the enclosing try, so the catch resumes inside the outer try. Only opcodes swap: in the compared dumps, no IR offset or EH range moves.

Why the flip alone loops

With the flip alone, test135463 hangs in all six of its interpreter configurations (the seventh runs under the JIT), 8 of 10 abort test cases loop, and one more runs a finally twice, on all three platforms.

My reading of the code, consistent with the traces (I did not trace the lookup itself): the resume path clears the frame's faulting flag, so handlers are looked up at the instruction before the island. When the island comes right after a try body, that instruction is the try's last one, so the same catch takes the abort again. Marking the frame faulting before the raise, which is what the resume-point option adds, removes the loops.

What the leavecatch raise needs

With the flip, the loop from my 10-12 comment is gone, because the leavecatch now ends the handler. The other two obstacles from that comment remain:

  1. These runs do not use a rude abort. The existing COMPlusCheckForAbort never raises at INTOP_LEAVE_CATCH, because the thread's throwable is set for the whole funclet. So the option needs a variant of the check without the pending-throwable clause. Most of the option's +51 lines in excep.cpp are that variant, an adapted copy of COMPlusCheckForAbort.
  2. It needs more plumbing:
    • m_OSContext has to describe the resume point in the parent frame, which means passing the parent InterpreterFrame in ExceptionClauseArgs;
    • ReadyForAsyncException needs a start-frame fix. If I leave out only that fix, StackFrameIterator asserts (m_crawl.pFrame > pOwning).

The leavecatch raise also removes this PR's resume-path check and the ABORT_AFTER_CATCH block.

Test results

On all three platforms, the two candidates:

  • pass test135463 in all seven configurations;
  • pass the nine enabled ControlledExecutionTests, except under InterpMode 2 and 3 on windows-x64, where xunit discovery hits a pre-existing Thread::IsObjRefValid assert (with this PR's head too);
  • give the expected traces under InterpMode 1 and 3 in the ten abort test cases and both nested-catch test cases. This includes the two [Interpreter] A thread abort raised again after a catch skips the catch of the enclosing try #135871 test cases that this PR's head misses.

I also tested a try/finally inside a catch with a leave out of both, and an empty catch. For each, this PR's head and the two candidates match the Linux and macOS JIT reference on all three platforms. With the flip, the empty catch's handler is load.exception; leavecatch.

I have not tested Release runtimes.

Other findings

I'll update the PR to the option you choose and add the #135871 test cases to its test.

Note

This reply was prepared with AI assistance (Anthropic Claude, checked by OpenAI Codex) at my request. I reviewed the measurements before posting. No commits are pushed with it; the prototypes are local.

<CLRTestPriority>1</CLRTestPriority>
<!-- Needed for CLRTestTargetUnsupported -->
<RequiresProcessIsolation>true</RequiresProcessIsolation>
<!-- ControlledExecution is supported on CoreCLR only -->

@jkotas jkotas Oct 16, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Use ConditionalFact instead

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 0125aaa.

ControlledExecution is supported on CoreCLR only. Skip the test with
PlatformDetection.IsCoreCLR, which is false on Mono and NativeAOT, instead
of CLRTestTargetUnsupported and NativeAotIncompatible. Neither is set any
more, so the test no longer needs its own process and runs in the merged
Regressions runner: the abort is confined to ControlledExecution.Run,
which resets it before returning.
@BrzVlad

BrzVlad commented Oct 16, 2026

Copy link
Copy Markdown
Member

I think it would be easier to see the 2 implementations side by side, rethrowing at resume point vs rethrowing during catch leave, while also addressing any other abort related issues (like #135871). Unless @janvorli has a strong preference. It's getting painful to follow this text.

@janvorli

Copy link
Copy Markdown
Member

I agree with @BrzVlad that it is hard to reason about a combination of changes. Maybe you could create a new PR with the flip and extra changes needed with that so we can easily see it in its completeness.

And I've realized that re-raising thread abort should really happen at the resume point, so that it behaves like JITted code. After the flip, the INTOP_LEAVE_CATCH is in the catch itself and that place is not protected by any try region (which is intentional).

@pcshrosbree

Copy link
Copy Markdown
Contributor Author

@janvorli, @BrzVlad: #135939 has the whole change in one commit based on main: the flip, the abort raised again at the resume point with the frame marked faulting, and the tests, including both cases from #135871. I'll close this PR in its favour.

I had also prepared the two versions side by side as draft PRs, one raising the abort at the resume point and one at the INTOP_LEAVE_CATCH that ends the catch. I can post those as well if you'd still like to compare them.

jkotas added a commit that referenced this pull request Oct 19, 2026
`Thread::ReadyForAsyncException` refuses to raise a thread abort inside
a `finally` or `catch` handler. Its walk, `TAStackCrawlCallBackWorker`,
moves the offset of every non-active frame back by one before comparing
it with the EH clauses, because the frame's control PC is a return
address.

On wasm, the control PC of a ReadyToRun frame is a virtual IP instead.
`fgWasmVirtualIP` gives each region of a method a number and stores the
current region's number in the frame before each call. A new region
starts at every try, handler and filter boundary, so every call in the
first region of a handler reports exactly the handler's start. Moved
back by one, that is the previous region, outside the handler. With the
default ReadyToRun CoreLib, `ControlledExecution.Run` then gets the
abort raised again inside its own `finally` and `catch
(ThreadAbortException)` handlers, at their first call through an import
cell, and throws `ThreadAbortException` instead of
`OperationCanceledException`.

`SfiInit` and `SfiNext` already skip the adjustment for virtual IPs.
This change does the same in `TAStackCrawlCallBackWorker`. A virtual IP
never needs the adjustment: it names the region of the call in progress,
and EH boundaries are region boundaries. For this walk, which checks
only handler ranges, the adjustment changes the answer only at a
handler's first region, where it is wrong; crossing a try boundary does
not change handler membership. Active frames were never adjusted.
Interpreter frames are not virtual IPs and keep the adjustment.

The second commit lets the `ControlledExecutionTests` that need no
second thread run on the single-threaded wasm legs. The whole class was
conditioned on `IsMultithreadingSupported`, so none of it ran there. The
condition is now on the four tests that cancel from `Task.Run`.
`CancelBeforeRun` covers this fix. #135520 re-enables
`CancelInTryAndExitCatchNormally`, which also uses `Task.Run`; whichever
of the two lands second needs to add the condition to it.

Contributes to #135914. Token cancellation from inside
`Run` still reaches the second problem: after CTS's ReadyToRun catch,
the end-of-catch abort check asserts on wasm in Checked builds.

> [!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.

---------

Co-authored-by: Jan Kotas <jkotas@microsoft.com>
davidwrighton pushed a commit to davidwrighton/runtime that referenced this pull request Oct 21, 2026
…otnet#135638)

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
a `FaultingExceptionFrame` at the exact resume address). The JIT
accounts for that: `impImportLeave` places the block a catch returns to
inside the EH regions that enclose the catch (see its comment on
`ThreadAbortException`), and `fgOptimizeEmptyBlock` keeps that block
when it is empty and in a different EH region than its successor, and
adds a `GT_NO_OP` to 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), where `fgOptimizeEmptyBlock` does not
run, and in optimized code too:

- the block can contain nodes that generate no code, so
`fgUpdateFlowGraph` never passes it to `fgOptimizeEmptyBlock`: a block
whose only statement is `GC.KeepAlive` of an argument;
- the block can be the first block of a try region, which
`fgUpdateFlowGraph` skips (`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;
- on xarch, the block's only instruction can be a jump that
`emitRemoveJumpToNextInst` removes because the blocks between it and its
target generate no code (the step block of `LeaveOutOfOuterTry` below,
at tier 0).

In the shapes of dotnet#135637 a `finally` the catch's `leave` passes through
does not run, an enclosing catch does not see the abort, and a `try`
that starts right after the catch does.

This change makes `genEmitEndBlock` guarantee that the block generates
an instruction: if a `BBJ_ALWAYS` block that a catch returns to has
emitted nothing since its label, it emits a `nop` when 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 of
`System.Numerics.Tensors.Tests.Helpers.AssertEqualWithTolerance`).
`fgOptimizeEmptyBlock` still adds its `GT_NO_OP`: an earlier revision of
this PR removed it, and Wasm crossgen then failed because
`fgUpdateFlowGraph` redirected the resume pad that `fgWasmEhFlow` adds
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 of `CatchInLoop` also
appends a marker before its `break`); "finally ran" is a call-free
`volatile` store.

| shape | tier 0 before | `JITMinOpts=1` before | `TieredCompilation=0`
before | after |
|---|---|---|---|---|
| `CatchThenFinally` | `catch outer`, finally not run | same as tier 0 |
correct | `catch finally outer` in all three |
| `LeaveOutOfOuterTry` | `inner` | `inner` | correct | `inner outer` in
all three |
| `ConsecutiveTry` | `inner new catch` | `inner new catch` | correct |
`inner` in all three |
| `KeepAliveAfterCatch` | correct | correct | `inner` | `inner outer` in
all three |
| `KeepAliveThenFinally` | correct | correct | `catch outer`, finally
not run | `catch finally outer` in all three |
| `CatchInLoop` | correct | the catch runs 3 times | the catch runs 3
times, then `outer` | `catch outer` in all three |

At tier 1 the two `KeepAlive` shapes fail as with `TieredCompilation=0`
and pass with this change. On osx-arm64 and windows-x64 the same shapes
fail with a JIT without this change, except that `LeaveOutOfOuterTry` is
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 block
`BB03` is now a one-byte `nop` emitted in codegen at `0x1B`, and the
finally's try range covers it (`EH#1: try [0014..001C)`; before:
`[0014..001B)`, no code for `BB03`). In `LeaveOutOfOuterTry` the step
block keeps its two-byte `jmp` (`EH#1: try [0014..001E)`; before,
`emitRemoveJumpToNextInst` removed it and the range was `[0014..001C)`).
In optimized code the first three shapes compile as before; for
`KeepAliveAfterCatch` the block gets a `nop` that the enclosing try
covers (`EH#1: try [000E..001D)`; before: `[000E..001C)`), and in
`CatchInLoop` the catch returned to `0x0F`, the first instruction of its
own try (`EH#0: try [000F..0017)`), and now returns to a `nop` there,
with the try starting at `0x10`.

### Tests

`Regressions/coreclr/GitHub_135637/test135637` has the six shapes.
`KeepAliveAfterCatch`, `KeepAliveThenFinally` and `CatchInLoop` are
marked `AggressiveOptimization`, so the default configuration compiles
them optimized; `JITMinOpts=1` still exercises MinOpts. With the current
JIT the test fails under the default configuration, `JITMinOpts=1` and
`TieredCompilation=0`; with the first version of this PR it fails under
the default configuration and `TieredCompilation=0`; with this change it
passes in all three, on linux-x64, osx-arm64 and windows-x64 (Windows
needs dotnet#135531, now merged, on machines with CET shadow stacks). Removing
either half of the check makes the test fail: without the `nop` under
all three configurations (linux-x64 and osx-arm64), and with the jump
left removable under the default configuration and `JITMinOpts=1` (at
`LeaveOutOfOuterTry`, linux-x64).

Under the interpreter the test is skipped with an `[ActiveIssue]` for
dotnet#135871: there the outer catch does not see the abort in
`LeaveOutOfOuterTry` and `CatchInLoop` (measured with dotnet#135520 applied;
without it, Checked builds stop at the assert of dotnet#135463).

`JIT/Methodical` (`Methodical_d2`, `_r2`, `_do`, `_ro`, `_others`),
`JIT/jit64` (`jit64_5`) and `baseservices/exceptions` on linux-x64 under
the default configuration, `TieredCompilation=0` and `JITMinOpts=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's `SocketConnectionListener.AcceptAsync` and OpenTelemetry's
`ProtobufOtlpResourceSerializer.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 under `JITMinOpts=1` with 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 and
`System.Diagnostics.StackFrameHelper:InitializeSourceInfo`, by one byte
each except `LeaveOutOfOuterTry`, which keeps a two-byte `jmp`); with
`TieredCompilation=0` the 958 compared methods are unchanged. On arm64
the `nop` is four bytes.

Resolves dotnet#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.

---------

Co-authored-by: Egor Bogatov <egorbo@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-CodeGen-Interpreter-coreclr community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Interpreter] Thread abort re-raised at the end of an interpreted catch: Thread::m_OSContext is not set up

4 participants