Skip to content

ThreadAbortException raised again after a catch can skip a finally and reaches different handlers in tier-0 and optimized code #135637

Closed

Description

@pcshrosbree

TL;DR: A ThreadAbortException raised again after a catch is dispatched from the address the catch returns to, and in tier-0 code that address can coincide with the end of the enclosing try regions because the block there emits no code. The abort then reaches different handlers than in optimized code, and in one shape a finally that the catch's leave passes through does not run. This issue gives three measured repro shapes and two possible fixes; the intended semantics should be agreed before choosing.

A ThreadAbortException that is raised again after a catch block has swallowed it is dispatched from the address the catch funclet returns to. In tier-0 code that address can coincide with the end of the enclosing try regions, because the block the catch returns to emits no code. The re-raised abort then reaches different handlers than in optimized code, and in one shape a finally the catch's leave passes through does not run at all.

Reproduction

Three shapes, each run under ControlledExecution.Run with the abort requested while RunInfiniteLoop spins. Handlers append to a string; three volatile bool fields are set by call-free stores after the catch, so the printed line does not depend on whether a call after the catch gets to run.

using System;
using System.Runtime;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;

#pragma warning disable SYSLIB0046

public static class AbortReraise
{
    private static volatile bool s_ready;
    private static volatile int s_counter;
    private static string s_trace;
    private static volatile bool s_finallyRan;
    private static volatile bool s_newTryRan;
    private static volatile bool s_afterRan;

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void RunInfiniteLoop()
    {
        s_ready = true;
        while (true)
        {
            if ((++s_counter & 0xfffff) == 0)
            {
                Thread.Sleep(0);
            }
        }
    }

    // The catch's leave passes through a finally before reaching the code after the try/finally.
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void CatchThenFinally()
    {
        try
        {
            try
            {
                RunInfiniteLoop();
            }
            catch (ThreadAbortException)
            {
                s_trace += "catch ";
            }
            finally
            {
                s_finallyRan = true;
                s_trace += "finally ";
            }
        }
        catch (ThreadAbortException)
        {
            s_trace += "outer ";
        }

        s_afterRan = true;
    }

    // The catch's leave target is outside the enclosing try.
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void LeaveOutOfOuterTry()
    {
        try
        {
            try
            {
                RunInfiniteLoop();
            }
            catch (ThreadAbortException)
            {
                s_trace += "inner ";
                goto Done;
            }
        }
        catch (ThreadAbortException)
        {
            s_trace += "outer ";
        }

    Done:
        s_afterRan = true;
    }

    // The code after the catch is the start of another try.
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void ConsecutiveTry()
    {
        try
        {
            RunInfiniteLoop();
        }
        catch (ThreadAbortException)
        {
            s_trace += "inner ";
        }

        try
        {
            s_newTryRan = true;
            s_trace += "new try ";
        }
        catch (ThreadAbortException)
        {
            s_trace += "new catch ";
        }

        s_afterRan = true;
    }

    private static void Run(string name, Action action)
    {
        s_trace = "";
        s_ready = false;
        s_finallyRan = false;
        s_newTryRan = false;
        s_afterRan = false;

        var cts = new CancellationTokenSource();
        Task.Run(() =>
        {
            while (!s_ready)
            {
                Thread.Sleep(10);
            }
            cts.Cancel();
        });

        Exception exception = null;
        try
        {
            ControlledExecution.Run(action, cts.Token);
        }
        catch (Exception e)
        {
            exception = e;
        }

        Console.WriteLine($"{name}: trace=\"{s_trace}\" finallyRan={s_finallyRan} newTryRan={s_newTryRan} afterRan={s_afterRan} exception={exception?.GetType().Name ?? "none"}");
    }

    public static int Main()
    {
        Run(nameof(CatchThenFinally), CatchThenFinally);
        Run(nameof(LeaveOutOfOuterTry), LeaveOutOfOuterTry);
        Run(nameof(ConsecutiveTry), ConsecutiveTry);
        return 100;
    }
}

Measured on a Checked linux-x64 build of main at 6ff62b1 with PR #135520 applied; that PR's only runtime change is inside InterpExecMethod, which the compiled code in these runs does not execute. Three runs per configuration, identical each time. Every run ends with OperationCanceledException from ControlledExecution.Run and afterRan=False.

configuration CatchThenFinally LeaveOutOfOuterTry ConsecutiveTry
default (tier 0) catch outer, finallyRan=False inner inner new catch, newTryRan=False
DOTNET_JITMinOpts=1 same same same
DOTNET_TieredPGO=0 same same same
DOTNET_TieredCompilation=0 catch finally outer, finallyRan=True inner outer inner
TieredCompilation=0 + JitEnableFinallyCloning=0 same as TieredCompilation=0 same same

So with tier-0 code the finally in CatchThenFinally never executes, although control leaves its try (the abort propagates to the outer catch); with optimized code it does. The other two shapes show the same split between the two compilation modes in the opposite directions.

What the generated code looks like

From DOTNET_JitDump of the three methods (Checked JIT), the catch-return target block and the native EH table:

CatchThenFinally, tier 0. The catch funclet (BB02) returns to BB03, the leave block, which is in the finally-protected try region. BB03 emits no code: the next instruction group starts at the same offset and is the callfinally block BB11, which the JIT placed in the outer try region, outside the finally's try.

IG02 offs=0x000014 size=0x0007 BB01    ; call RunInfiniteLoop (inner try)
IG03 offs=0x00001B size=0x0006 BB11    ; callfinally
EH#0: try [0014..001B) handled by [002E..006E) (class: 100000B)   ; inner catch
EH#1: try [0014..001B) handled by [006E..00AB) (finally)
EH#2: try [0014..0021) handled by [00AB..00EB) (class: 100000B)   ; outer catch

The resume address is 0x1B: the end of EH#0 and EH#1 (exclusive), inside EH#2 only. EECodeManager::ResumeAfterCatch asks COMPlusCheckForAbort with that address and resumes into ThrowControlForThread, which links a FaultingExceptionFrame with the resume context and sets CONTEXT_EXCEPTION_ACTIVE on it; the stack walker turns that flag into hasFaulted for the frame (StackFrameIterator::Init), and SfiInit subtracts STACKWALK_CONTROLPC_ADJUST_OFFSET only for frames that have not faulted, so the EH lookup uses the resume address as it is. The dispatch finds the outer catch, the finally is not in scope, and the callfinally at 0x1B never executes.

CatchThenFinally, optimized. BB03 emits one byte, a nop, at 0x17, and the finally's try extends over it:

IG02 offs=0x000010 size=0x0007 BB01
IG03 offs=0x000017 size=0x0001 BB03    ; nop
IG04 offs=0x000018 size=0x0006 BB11    ; callfinally
EH#0: try [0010..0017) handled by [0034..0070) (class: 100000B)
EH#1: try [0010..0018) handled by [0070..011B) (finally)
EH#2: try [0010..001E) handled by [011B..0157) (class: 100000B)

The resume address 0x17 is inside EH#1, so the second pass runs the finally on the way to the outer catch.

LeaveOutOfOuterTry is the same pattern: the step block the leave returns to (BB08, in the outer try) is empty in tier 0 (EH#1: try [0014..001C), resume 0x1C) and a one-byte nop in optimized code (EH#1: try [0004..000D), resume 0x0C), so the outer catch sees the abort only in optimized code. In ConsecutiveTry, tier 0 lays the second try out starting at the resume address (EH#0: try [0014..001B), EH#1: try [001B..0052)), so the re-raised abort lands inside the second try and its catch runs; optimized code has the nop at 0x17 and the second try starts at 0x18.

Where this could be fixed

Two approaches for the owners to weigh: the JIT could keep the block a catch returns to from being empty when it ends an enclosing region, as the optimized layouts above do with their nop, so that the resume address stays inside the regions that block belongs to; or the EH lookup convention for a re-raised abort could be reconsidered (treating the resume address as a return address, for instance), which changes handler membership in the optimized layouts too (an adjusted address can land in the inner try that was just handled) and would need validation. The intended semantics should be agreed before choosing. I am happy to work on either.

The measurements above cover JIT-compiled code only.

Note

This issue was prepared with AI assistance (Anthropic Claude, checked by OpenAI Codex) at my request. I reviewed the reproduction and the measurements before filing.

Activity

  1. added
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    on Oct 12, 2026
  2. dotnet-policy-service commented on Oct 12, 2026

    @dotnet-policy-service
    Contributor

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

  3. removed
    untriagedNew issue has not been triaged by the area owner
    on Oct 13, 2026
  4. added this to the 12.0.0 milestone on Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions