Skip to content

[clr-interp] Skip INTOP_CALL_FINALLY in DebuggerStepper::ShouldContinueStep#126954

Closed
kotlarmilos wants to merge 1 commit into
dotnet:mainfrom
kotlarmilos:interp-followup-jmc
Closed

[clr-interp] Skip INTOP_CALL_FINALLY in DebuggerStepper::ShouldContinueStep#126954
kotlarmilos wants to merge 1 commit into
dotnet:mainfrom
kotlarmilos:interp-followup-jmc

Conversation

@kotlarmilos

@kotlarmilos kotlarmilos commented Apr 15, 2026

Copy link
Copy Markdown
Member

Description

INTOP_CALL_FINALLY is internal interpreter EH machinery (it dispatches a finally clause) and has no source-language counterpart, so the user should never stop on it during stepping. Without this skip, step-over across a try/finally block under the interpreter incorrectly stops at the synthesized CALL_FINALLY instruction.

The skip lives in DebuggerStepper::ShouldContinueStep so the existing "transparent" mechanism that already handles other internal stops handles this case too.

Copilot AI review requested due to automatic review settings April 15, 2026 17:10
@kotlarmilos kotlarmilos changed the title [Interpreter] Fix JMC stepping and CallEntryPoint frame filtering [clr-ios] Fix JMC stepping and CallEntryPoint frame filtering Apr 15, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes interpreter-specific debugging behaviors around JMC stepping, stack walking, and func-eval/breakpoint handling so that interpreter behavior matches JIT expectations and avoids redundant debugger callbacks.

Changes:

  • Add interpreter-aware stepping logic to skip internal INTOP_CALL_FINALLY boundaries during JMC stepping.
  • Filter Environment.CallEntryPoint from debugger stack walks (in-proc and DAC) to match JIT visibility behavior.
  • Refine interpreter breakpoint + func-eval handling (pre-callback bypass checks, bypass save/restore around func-eval, and allow SetIP to change interpreter IP).

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/Frames/DebuggerEval.cs Rename contract flag to EvalUsesHijack for interpreting func-eval behavior in stack walking.
src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/BaseFrameHandler.cs Use EvalUsesHijack to decide whether to update context from func-eval frame.
src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/X86FrameHandler.cs Same as BaseFrameHandler, with x86-specific context copying.
src/coreclr/vm/interpexec.cpp Interpreter breakpoint handler updates (pre/post bypass checks, pending interpreter func-eval execution, SetIP support).
src/coreclr/vm/dbginterface.h Add ExecutePendingInterpreterFuncEval hook for interpreter breakpoint handler.
src/coreclr/vm/datadescriptor/datadescriptor.inc CDAC descriptor rename: EvalDuringExceptionEvalUsesHijack.
src/coreclr/debug/ee/interpreterwalker.h Add InterpreterWalker::IsCallFinally() helper.
src/coreclr/debug/ee/interpreterwalker.cpp Implement IsCallFinally().
src/coreclr/debug/ee/funceval.cpp Replace m_evalDuringException checks with m_evalUsesHijack.
src/coreclr/debug/ee/frameinfo.cpp Filter Environment.CallEntryPoint in debugger stack walk for interpreter mode.
src/coreclr/debug/ee/debugger.h Rename state flag and skip bp-segment executability check when not using hijack.
src/coreclr/debug/ee/debugger.cpp Interpreter-aware func-eval setup (skip executable heap allocation for interpreter) and expose ExecutePendingInterpreterFuncEval.
src/coreclr/debug/ee/controller.cpp Stepper logic to skip INTOP_CALL_FINALLY boundaries when stepping interpreted code.
src/coreclr/debug/daccess/dacdbiimplstackwalk.cpp DAC stack walk filtering for Environment.CallEntryPoint under interpreter.
Comments suppressed due to low confidence (1)

src/coreclr/debug/ee/debugger.cpp:14436

  • In the interpreter func-eval path, bpInfoSegmentRX is NULL, but DebuggerEval::Init() is called before m_evalUsesHijack is flipped to false. Because the ctor currently initializes m_evalUsesHijack to !fInException (true for non-exception interpreter evals), Init() will dereference m_bpInfoSegment and can crash. Set m_evalUsesHijack correctly before calling Init (e.g., initialize it in the ctor from both fInException and whether bpInfoSegmentRX is non-null, or delay Init() until after the interpreter-specific flag update), and consider adding an assert/guard in Init to ensure m_bpInfoSegment is non-null when m_evalUsesHijack is true.
    // Create a DebuggerEval to hold info about this eval while its in progress. Constructor copies the thread's
    // CONTEXT.
    DebuggerEval *pDE = new (interopsafe, nothrow) DebuggerEval(filterContext, pEvalInfo, fInException, bpInfoSegmentRX);

    if (pDE == NULL)
    {
        return E_OUTOFMEMORY;
    }
    else if (!pDE->Init())
    {
        // We fail to change the m_breakpointInstruction field to PAGE_EXECUTE_READWRITE permission.
        return E_FAIL;
    }

@kotlarmilos kotlarmilos added the os-ios Apple iOS label Apr 15, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'os-ios': @vitek-karas, @kotlarmilos, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.

@steveisok

Copy link
Copy Markdown
Member

@kotlarmilos would it make sense to drop the #ifdef FEATURE_INTERPRETER guards around
the two CallEntryPoint filter hunks (in frameinfo.cpp::DebuggerWalkStackProc
and dacdbiimplstackwalk.cpp::UnwindStackWalkFrame) so the filter applies on
JIT targets too?

The comment "to match JIT behavior" suggests the JIT is expected to hide
CallEntryPoint — but in practice [UnmanagedCallersOnly] disables inlining
(UCO methods must have a real, callable entry point), so the frame is visible
to ICorDebug on JIT targets as well. We're seeing it in
dotnet/dotnet-diagnostictests on linux-x64, win-x64, win-x86, and osx-arm64
JIT legs in build
2955987,
adding one frame below Program.Main.

@kotlarmilos

Copy link
Copy Markdown
Member Author

would it make sense to drop the #ifdef FEATURE_INTERPRETER guards around
the two CallEntryPoint filter hunks (in frameinfo.cpp::DebuggerWalkStackProc
and dacdbiimplstackwalk.cpp::UnwindStackWalkFrame) so the filter applies on
JIT targets too?

Yes, that makes sense. Let me resolve the conflicts and invoke the tests

Copilot AI review requested due to automatic review settings April 24, 2026 15:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

src/coreclr/debug/ee/debugger.cpp:14299

  • In the interpreter func-eval path bpInfoSegmentRX is intentionally NULL, but DebuggerEval currently sets m_evalUsesHijack = !fInException in the constructor. That means for interpreter non-exception evals (fInException == false) m_evalUsesHijack remains true when pDE->Init() runs, and Init() will dereference m_bpInfoSegment (NULL) in the executability assert/check. Set m_evalUsesHijack correctly before calling Init() (e.g., pass a "uses hijack" flag into the constructor or defer Init() until after the interpreter branch sets the flag).
    // Create a DebuggerEval to hold info about this eval while its in progress. Constructor copies the thread's
    // CONTEXT.
    DebuggerEval *pDE = new (interopsafe, nothrow) DebuggerEval(filterContext, pEvalInfo, fInException, bpInfoSegmentRX);

    if (pDE == NULL)
    {
        return E_OUTOFMEMORY;
    }
    else if (!pDE->Init())
    {
        // We fail to change the m_breakpointInstruction field to PAGE_EXECUTE_READWRITE permission.
        return E_FAIL;
    }

Comment thread src/coreclr/vm/vars.cpp
Comment thread src/coreclr/inc/dacvars.h
Comment thread src/coreclr/debug/daccess/dacdbiimplstackwalk.cpp Outdated
Comment thread src/coreclr/debug/daccess/dacdbiimplstackwalk.cpp Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot's findings

  • Files reviewed: 17/17 changed files
  • Comments generated: 1

Comment thread src/coreclr/debug/ee/debugger.cpp
@kotlarmilos kotlarmilos force-pushed the interp-followup-jmc branch 2 times, most recently from 219a535 to 8b7798f Compare April 27, 2026 13:45
Copilot AI review requested due to automatic review settings April 27, 2026 13:49
@kotlarmilos kotlarmilos force-pushed the interp-followup-jmc branch from 8b7798f to 0067d0e Compare April 27, 2026 13:49
@kotlarmilos kotlarmilos force-pushed the interp-followup-jmc branch 2 times, most recently from 8b7798f to 2a7ae82 Compare April 27, 2026 13:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot's findings

  • Files reviewed: 21/21 changed files
  • Comments generated: 1

Comment thread src/coreclr/vm/vars.cpp
Copilot AI review requested due to automatic review settings April 29, 2026 08:01
@kotlarmilos kotlarmilos force-pushed the interp-followup-jmc branch from 5c0e81d to 3fc0ef9 Compare April 29, 2026 08:01
@kotlarmilos kotlarmilos changed the title [interp] Skip INTOP_CALL_FINALLY in stepper and filter CallEntryPoint from debugger stack walks [interp] Skip INTOP_CALL_FINALLY in stepper and filter CallEntryPoint from debugger stack walks Apr 30, 2026
@kotlarmilos kotlarmilos changed the title [interp] Skip INTOP_CALL_FINALLY in stepper and filter CallEntryPoint from debugger stack walks [interp] Make stepper transparent for INTOP_CALL_FINALLY and hide Environment.CallEntryPoint from debugger walks Apr 30, 2026
@kotlarmilos kotlarmilos changed the title [interp] Make stepper transparent for INTOP_CALL_FINALLY and hide Environment.CallEntryPoint from debugger walks [interp] Skip INTOP_CALL_FINALLY in stepper and filter CallEntryPoint from debugger stack walks Apr 30, 2026
Comment thread src/coreclr/debug/ee/controller.cpp Outdated
Comment thread src/coreclr/debug/ee/frameinfo.cpp Outdated
Copilot AI review requested due to automatic review settings May 4, 2026 13:47
@kotlarmilos kotlarmilos force-pushed the interp-followup-jmc branch from f04d4ed to e6386b5 Compare May 4, 2026 13:47
@kotlarmilos kotlarmilos changed the title [interp] Skip INTOP_CALL_FINALLY in stepper and filter CallEntryPoint from debugger stack walks [interp] Skip INTOP_CALL_FINALLY in DebuggerStepper::ShouldContinueStep May 4, 2026
@kotlarmilos kotlarmilos force-pushed the interp-followup-jmc branch 2 times, most recently from 626749a to 73f6ceb Compare May 4, 2026 13:53
@kotlarmilos kotlarmilos requested review from noahfalk and tommcdon May 4, 2026 13:54
@kotlarmilos kotlarmilos marked this pull request as ready for review May 4, 2026 13:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot's findings

  • Files reviewed: 1/1 changed files
  • Comments generated: 1

Comment thread src/coreclr/debug/ee/controller.cpp Outdated
@kotlarmilos kotlarmilos requested a review from janvorli May 4, 2026 14:32
@kotlarmilos kotlarmilos added this to the 11.0.0 milestone May 4, 2026
@kotlarmilos kotlarmilos changed the title [interp] Skip INTOP_CALL_FINALLY in DebuggerStepper::ShouldContinueStep [clr-interp] Skip INTOP_CALL_FINALLY in DebuggerStepper::ShouldContinueStep May 4, 2026
@kotlarmilos kotlarmilos force-pushed the interp-followup-jmc branch from 73f6ceb to 0cb387a Compare May 4, 2026 15:19
int32_t op = walker.GetOpcode();
bool isInterpEH = (op == INTOP_CALL_FINALLY) || (op == INTOP_LEAVE_CATCH);

if (!isInterpEH && op == INTOP_DEBUG_SEQ_POINT && walker.GetSkipIP() != NULL)

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.

If stepping can only happen at places where we generate debug seq points, wouldn't it make more sense to simply not emit these opcodes in the first place. Is there a reason why we would want to generate them just so that we always ignore them afterwards ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The skip isn't about ignoring INTOP_DEBUG_SEQ_POINTs, it's about the EH dispatch opcodes. INTOP_CALL_FINALLY and INTOP_LEAVE_CATCH live in compiler-generated EH dispatch blocks with ilOffset = -1 and don't get their own IL to native map entries, but the stepper still lands on them because the entry for the leave maps to the start of that EH dispatch block.

The op == INTOP_DEBUG_SEQ_POINT is defensive check here, if it can't happen we should drop it.

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.

JIT has matching concept of a call that calls finally. I would like to understand why it doesn't require extra treatment. Isn't the problem actually the fact that we generate the seq point on the INTOP_CALL_FINALLY while we should not?

@kotlarmilos kotlarmilos May 11, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The stepper handles call to finally in JIT because it is recognized as a call to funclet via IsAddrWithinMethodIncludingFunclet or fCallingIntoFunclet in BBJ_CALLFINALLY. The interpreter doesn't use funclets, so that path doesn't apply.

My understanding on the seq points is that we don't actually emit them on INTOP_CALL_FINALLY. The IL to native map only gets entries from instructions whose ilOffset is > 0, and the EH dispatch block has offset -1. The IL leave itself is compiled to an INTOP_BR in the user's source BB, and the INTOP_BR jumps into the EH dispatch block.

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.

The interpreter doesn't use funclets, so that path doesn't apply.

Interepreter actually does use funclets, but they are interpreted. If you look at the generated code though, the layout of the code matches what JIT would generate. It seems that IsAddrWithinMethodIncludingFunclet would work for the interpreted code too.

My understanding on the seq points is that we don't actually emit them on INTOP_CALL_FINALLY

So I am not sure then why the debugger stops on the instruction. But I am not an expert on how the single stepping behaves at this level.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Interepreter actually does use funclets, but they are interpreted.

You are right, they are funclets at the bytecode level. Looking at the walker, I noticed that INTOP_CALL_FINALLY and INTOP_LEAVE_CATCH are classified as WALK_BRANCH:

    else if (m_opcode == INTOP_CALL_FINALLY || m_opcode == INTOP_LEAVE_CATCH)
    {
        // Exception handling branches
        m_type = WALK_BRANCH;
        m_nextIP = GetBranchTarget();
        LOG((LF_CORDB, LL_INFO10000, "InterpreterWalker::Decode: WALK_BRANCH (EH) to %p\n", m_nextIP));
    }

Should it be WALK_CALL since it is a direct call to a funclet?

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.

It seems it would make sense to mark the INTOP_CALL_FINALLY as WALK_CALL. The INTOP_LEAVE_CATCH though it just a regular branch, so it seems this one should stay.

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.

It seems that IsAddrWithinMethodIncludingFunclet would work for the interpreted code too.

I don't see it getting called right now? For interpretter stepping I expect the call chain looks like:

InterpreterWalker::Init
InterpreterStepHelper::SetupStep
DebuggerStepper::TrapInterpreterCodeStep
DebuggerStepper::TrapStep

IsAddrWithinMethodIncludingFunclet gets called in DebuggerStepper::TrapStep on the non-interpreter part of the code path whereas the interpreter case veers off into TrapInterpreterCodeStep.

Of course even if IsAddrWithinMethodIncludingFunclet isn't being called now we could always change the code to start invoking it within SetupStep. Doing so seems like it adds unneeded complexity though. Ultimately our goal is that step over operations should trace into the code for finally clauses but shouldn't trace into normal method calls. For jitted code we use the same assembly call opcode for both so extra logic is necessary to discriminate which call instructions represent funclet/finally and which represent the normal method call. For the interpreter it doesn't sound like we have that ambiguity. INTOP_FINALLY is already distinct from INTOP_CALL so we can report it as WALK_BRANCH up-front rather than reporting it as WALK_CALL, then classifying the called IP as a funclet, then treating WALK_CALL on a funclet as if we had specified WALK_BRANCH.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The defensive guard this PR adds in ShouldContinueStep doesn't seem to fire in real use case. We can introduce it later if we start hitting scenarios where the stepper stops on EH dispatch without source mapping

Copilot AI review requested due to automatic review settings May 11, 2026 20:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

Comment thread src/coreclr/debug/ee/interpreterwalker.cpp Outdated
Comment thread src/coreclr/debug/ee/controller.cpp
…ueStep

INTOP_CALL_FINALLY and INTOP_LEAVE_CATCH are interpreter EH dispatch opcodes
with no source-language line. The stepper's instruction walker can land on them
when stepping over an IL leave at the end of a try block, producing a confusing
stop in the user's debugger. Treat them as transparent in ShouldContinueStep so
the stepper walks past them to the next real source line.

Empirically Step Over from the last try line now lands directly on the first
finally statement instead of pausing on the synthetic dispatch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kotlarmilos kotlarmilos force-pushed the interp-followup-jmc branch from 3035fbf to 4848633 Compare May 12, 2026 16:30
@kotlarmilos

Copy link
Copy Markdown
Member Author

Closing PR per #126954 (comment)

@github-actions github-actions Bot locked and limited conversation to collaborators Jun 12, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants