Skip to content

[clr-interp] Support for breakpoints and stepping#123251

Draft
matouskozak wants to merge 18 commits intodotnet:mainfrom
matouskozak:interpreter-breakpoints
Draft

[clr-interp] Support for breakpoints and stepping#123251
matouskozak wants to merge 18 commits intodotnet:mainfrom
matouskozak:interpreter-breakpoints

Conversation

@matouskozak
Copy link
Member

@matouskozak matouskozak commented Jan 16, 2026

Summary

This PR adds debugger support for the CoreCLR interpreter, enabling IDE breakpoints and single-stepping functionality.

Key Changes

Breakpoint Support:

  • Enable breakpoints in interpreter via INTOP_BREAKPOINT opcode injection
  • Add IL offset 0 → IR offset 0 mapping for method entry breakpoints
  • Refactor ApplyPatch/UnapplyPatch for interpreter code patches
  • Add activation flag for interpreter patches (needed because opcode 0 is valid INTOP_RET)

Single-Stepping Support:

  • Add InterpreterStepHelper class to encapsulate step setup logic
  • Per-pframe context bypass address/opcode fields for resuming past breakpoints
  • Implement TrapInterpreterCodeStep for step-in/step-over/step-out
  • Call OnMethodEnter to notify debugger (needed for step-in on virtual calls)

Interpreter Compiler Changes:

  • Keep NOP instructions in debug builds (don't collapse them)

Stack Walking:

Testing

  • Verified IDE breakpoints work with interpreted code
  • Single-stepping (step-in, step-over, step-out) functional
  • No regressions to existing debugger support verified locally using diagnostictests

Notes/TODOs

  • Stack walking workaround needs to be reverted once proper fix is in place
  • m_interpActivated should be generalized to all patches (stop using opcode == 0 as "not active") [debugger] Replace PRDIsEmpty(opcode) activation check with explicit m_activated flag #124499
  • s_interpOpLen / opcode name tables in executioncontrol.cpp are debug logging aids — TODO remove
  • Long-term plan: Extract JIT-specific code into HardwareExecutionControl derived class, create InterpreterExecutionControl for interpreter support

@matouskozak matouskozak self-assigned this Jan 16, 2026
@matouskozak matouskozak added NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) area-Diagnostics-coreclr labels Jan 16, 2026
@matouskozak matouskozak force-pushed the interpreter-breakpoints branch from ed5ce4c to dce1adc Compare January 16, 2026 08:52
Copilot AI review requested due to automatic review settings January 30, 2026 10:59
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This work-in-progress pull request adds support for managed debugger breakpoints in the CoreCLR interpreter. The changes extend the existing user breakpoint support (e.g., Debugger.Break()) to support IDE breakpoints and enable setting breakpoints when the program is stopped.

Changes:

  • Adds interpreter single-step thread state flag and supporting methods
  • Introduces new INTOP_SINGLESTEP opcode for step-over operations
  • Implements InterpreterWalker to analyze interpreter bytecode for debugger stepping
  • Modifies breakpoint execution logic to distinguish between IDE breakpoints and step-out breakpoints
  • Enables JIT completion notifications for interpreter code
  • Pre-inserts IL offset 0 entry in the IL-to-native map to support method entry breakpoints

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
src/coreclr/vm/threads.h Adds TSNC_InterpreterSingleStep thread state flag and related methods
src/coreclr/vm/jitinterface.cpp Removes interpreter code exclusion from JITComplete notifications
src/coreclr/vm/interpexec.cpp Implements breakpoint and single-step handling with opcode replacement
src/coreclr/vm/codeman.h Adds IsInterpretedCode() helper method
src/coreclr/interpreter/intops.h Adds helper functions to classify interpreter opcodes
src/coreclr/interpreter/inc/intops.def Defines INTOP_SINGLESTEP opcode
src/coreclr/interpreter/compiler.cpp Pre-inserts IL offset 0 mapping for method entry breakpoints
src/coreclr/debug/ee/interpreterwalker.h Declares InterpreterWalker class for bytecode analysis
src/coreclr/debug/ee/interpreterwalker.cpp Implements bytecode walker for debugger stepping operations
src/coreclr/debug/ee/functioninfo.cpp Uses GetInterpreterCodeFromInterpreterPrecodeIfPresent for code address
src/coreclr/debug/ee/executioncontrol.h Defines BreakpointInfo structure and GetBreakpointInfo method
src/coreclr/debug/ee/executioncontrol.cpp Implements INTOP_SINGLESTEP patch support and breakpoint info retrieval
src/coreclr/debug/ee/controller.h Includes interpreterwalker.h header
src/coreclr/debug/ee/controller.cpp Implements TrapStep for interpreter using InterpreterWalker
src/coreclr/debug/ee/CMakeLists.txt Adds interpreterwalker source files to build

Copilot AI review requested due to automatic review settings January 30, 2026 11:19
Copy link
Contributor

Copilot AI left a comment

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 19 out of 19 changed files in this pull request and generated 9 comments.

{
// Indirect call (CALLVIRT, CALLI, CALLDELEGATE) - cannot determine target statically
// Use JMC backstop to catch method entry
// TODO: Could we do better? Why we can't use StubManagers to trace indirect calls?
Copy link
Member

Choose a reason for hiding this comment

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

You can StubManagers if there is one that recognizes the code pattern being used to make the indirect call. If the indirect call doesn't need too many instructions to reach the destination you could also enable single-stepping and get there that way.

_ASSERTE(pExecControl != NULL);
return pExecControl->ApplyPatch(patch);
}
IExecutionControl* pExecControl = codeInfo.GetJitManager()->GetExecutionControl();
Copy link
Member

Choose a reason for hiding this comment

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

I'm happy for this to be lower priority refactoring, but ideally we could demonstrate the ExecutionControl abstraction is a good one by having native code use it too. Right now we effectively have the native implementation of the abstraction inlined everywhere. As an end goal it would be nice to have a code path that looked similar to this:

bool DebuggerController::ApplyPatch(...)
{
   ...

   // no interpretter ifdef here
   IExecutionControl* pExecControl = g_pNativeCodeExecutionControl;
   EECodeInfo codeInfo((PCODE)patch->address);
   if(codeInfo.IsValid())
   {
       pExecControl = codeInfo.GetJitManager()->GetExecutionControl();
   }
   _ASSERTE(pExecControl != NULL);
   return pExecControl->ApplyPatch(patch);
}

Copy link
Member Author

Choose a reason for hiding this comment

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

I agree, that was my plan as well #120842. Do you prefer to make it in the same PR or as a separate one? (similar to #123251 (comment))

Copy link
Member

Choose a reason for hiding this comment

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

totally fine if its separate, just wanted to make sure we agreed on the direction 👍


#ifdef FEATURE_INTERPRETER
// For interpreter code, native offset 0 is within the bytecode header area and cannot
// have a breakpoint. Use the first sequence map entry's native offset instead.
Copy link
Member

Choose a reason for hiding this comment

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

Having a field called m_addrOfCode that doesn't point to the first instruction is unexpected and breaks the invariant all the other jitted code has. Typically in the runtime any type that is PCODE or methods/fields expressly named "Code" with a neutral type such CORDB_ADDRESS, TADDR, void* means a pointer to an opcode. When the runtime wants to point to a header in front of those opcodes we use a different explicit type like CodeHeader.

struct CodeHeader
{
    PTR_RealCodeHeader   pRealCodeHeader;
    ...
    TADDR                   GetCodeStartAddress()
    {
        return dac_cast<PCODE>(dac_cast<PTR_CodeHeader>(this) + 1);
    }

We should strongly consider using the address of the opcodes and not the address of the header everywhere we refer to the abstraction as being 'code' or 'bytecode'. In addition to the potential for the confusion to cause runtime bugs and interpreter special casing, these pointers also get exported outside the runtime via PerfMaps, ETW events, EventPipe events, ICorProfiler APIs, and ICorDebug APIs. Its entirely possible that some diagnostic tools will start looking at the memory present at the code pointer which means the header becomes part of the de-facto diagnostics contract for interpreter code and it is difficult to change. We usually try to keep these implicit interfaces between diagnostic tools and the runtime as free of extraneous implementation details as possible.

Copy link
Member Author

@matouskozak matouskozak Feb 4, 2026

Choose a reason for hiding this comment

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

That's a good point. I've created an issue for it #123998 and I'll do the changes in a separate PR because there might be a lot of places which needs adjusting. It might be worth doing this change + support for breakpoints in one PR + the single-stepping logic in another.

{
#ifdef FEATURE_INTERPRETER
// Interpreter patches don't need DebuggerPatchSkip - the interpreter
// uses GetOriginalOpcode() to read the saved opcode from the patch table
Copy link
Member

Choose a reason for hiding this comment

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

How does the interpreter know when it should be generating the breakpoint behavior vs generating the original opcode behavior? I think we'd want to have that as part of an explicit contract so that the debugger can control when it happens.

Copy link
Member Author

@matouskozak matouskozak Feb 5, 2026

Choose a reason for hiding this comment

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

I added the thread SetInterpreterBypass to set the bypass opcode if we need to (in ActivatePatchSkip).

CONSISTENCY_CHECK_MSGF(context != NULL, ("Can't apply ss flag to thread 0x%p b/c it's not in a safe place.\n", thread));
_ASSERTE(context != NULL);

#ifdef FEATURE_INTERPRETER
Copy link
Member

Choose a reason for hiding this comment

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

We should probably figure out how we want to handle single-stepping first, but this seems like another area similar to breakpoints where we might have IExecutionControl APIs that handle it differently depending on the code type.

patch->opcode = currentOpcode; // Save original opcode

// Check if this is a single-step patch by looking at the controller's thread's interpreter SS flag.
Thread* pThread = patch->controller->GetThread();
Copy link
Member

Choose a reason for hiding this comment

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

I think of this interface as being an abstraction that mirrors how the debugger would normally interact with hardware. By default it would be ApplyPatch(address) and the native implementation would be something like:

VirtualProtect(address, ...); // make it writable
CORDbgInsertBreakpoint(address);
VirtualProtect(address, ...); // make it readonly again

Maybe we'd make it a little more sophisticated so that ApplyPatch is also responsible for saving the old opcode in an output parameter. I would not expect this method to get access to the complete DebuggerPatch object or for the implementation to look at fields like patch->controller. If the debugger needs to do single stepping I'd expect either:

  1. The debugger uses an explicit API different from this one to turn single stepping on. The interpreter could use special opcodes as an implementation detail if it wanted to, but it would be responsible for doing all the bookkeeping on its own (without using DebuggerPatchTable).
  2. The debugger emulates single-stepping behavior using an IExecutionControl API that only has breakpoints. In that case the debugger would call this API to set a INTOP_BREAKPOINT instruction. Later when the interpretter sends back the BreakpointHit callback the debugger code would be responsible for looking up the patch at that address and interpretting the breakpoint as the completion of a single step operation. Other architectures do that single step emulation by copying the instruction to a separate buffer but it could also be done inline if the interpretter is going to have a special feature that enables executing the original opcode on a per-thread basis.

Copy link
Member Author

@matouskozak matouskozak Feb 6, 2026

Choose a reason for hiding this comment

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

The debugger emulates single-stepping behavior using an IExecutionControl API that only has breakpoints. In that case the debugger would call this API to set a INTOP_BREAKPOINT instruction.

Makes sense, I refactored the code to only use INTOP_BREAKPOINT.

Later when the interpretter sends back the BreakpointHit callback the debugger code would be responsible for looking up the patch at that address and interpretting the breakpoint as the completion of a single step operation.

I think this should happen naturally and get handled in

if (IsInRange(offset, m_range, m_rangeCount, &info) ||
ShouldContinueStep( &info, offset))
{
LOG((LF_CORDB, LL_INFO10000,
"Intermediate step patch hit at 0x%x\n", offset));
if (!TrapStep(&info, m_stepIn))
TrapStepNext(&info);
EnableUnwind(m_fp);
return TPR_IGNORE;
}
else
{
LOG((LF_CORDB, LL_INFO10000, "Step patch hit at 0x%x\n", offset));
// For a JMC stepper, we have an additional constraint:
// skip non-user code. So if we're still in non-user code, then
// we've got to keep going
DebuggerMethodInfo * dmi = g_pDebugger->GetOrCreateMethodInfo(module, md);
if ((dmi != NULL) && DetectHandleNonUserCode(&info, dmi))
{
return TPR_IGNORE;
}
StackTraceTicket ticket(patch);
PrepareForSendEvent(ticket);
return TPR_TRIGGER;
}
. I'm not yet sure if we will need to do something special to handle breakpoint (used for stepping) and breakpoint (regular) on debugger side for interpreter but this check should handle the notificaion when we the stepping operation is completed.

Other architectures do that single step emulation by copying the instruction to a separate buffer but it could also be done inline if the interpretter is going to have a special feature that enables executing the original opcode on a per-thread basis.

I've added a thread storage for the original opcode which gets retrieved during the interpreter execution loop and re-executed when needed.

Does this align with what you had in mind?

Copy link
Member

Choose a reason for hiding this comment

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

a thread storage for the original opcode which gets retrieved during the interpreter execution loop

You can have multiple threads running the same interpreter code. What happens when a second thread hits a breakpoint and the original code is saved in a thread local storage of some other thread?

Copy link
Member Author

Choose a reason for hiding this comment

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

My understanding is, that if non-stepping threads hits INTOP_BREAKPOINT, they would still call to debugger but they will NOT trigger MatchPatch flow which calls TriggerPatch which sends debugger notification (only the thread which is stepping should do that), instead it will jump to ActivatePatchSkip which sets the bypass opcode (reads it from the patch) and allows non-stepping threads to continue execution.

It's not the most efficient solution, we could optimize this by having some kind of book-keeping solution with {address, original opcode} on the side which would be accessible in the interpreter execution loop to fast track non-stepping threads bypass flow.

InterpBreakpoint(ip, pFrame, stack, pInterpreterFrame);
break;
Thread* pThread = GetThread();
bpInfo = execControl->GetBreakpointInfo(ip);
Copy link
Member

Choose a reason for hiding this comment

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

I think we want to get rid of this GetBreakpointInfo() call. Ideally at the interpreter layer all breakpoints will be handled uniformly. To get there:

  1. bpInfo.isStepOut - The interpreter shouldn't be responsible for figuring out what a breakpoint is used for. I see this flag is being used to control some IP adjustment implicitly via the FaultingExceptionFrame but I don't think we want the interpreter doing that either. Instead we can probably just make a contract that the interpreter always reports the IP as being the address where the breakpoint was set. If the debugger needs to sometimes adjust the IP and sometimes not it can be responsible to figure out which breakpoint addresses need that treatment after receiving the Breakpoint callback. It already has access to all the same info this GetBreakpointInfo() API is looking up. Fwiw, I don't yet understand why an adjustment is needed in this case or why it would be conditional, but if there is something conditional that needs to be done the debugger code seems like the right place for it.
  2. bpInfo.originalOpcode - We probably need a more generalized mechanism for the debugger to tell the interpreter when to execute the original opcode. For example we could have some special fields in the CONTEXT only used by interpreter that stores an address and an opcode. The meaning of those fields would be "if the opcode at address X is a breakpoint, execute opcode Y instead". This allows the debugger to enable the breakpoint bypass behavior at any time, not just after a breakpoint was previously hit. The debugger doesn't always skip breakpoints immediately after hitting them. We might need to define some explicit InterpretterCONTEXT or Get/SetXYZ() functions that define where these fields get placed in the CONTEXT data blob.

Copy link
Member Author

@matouskozak matouskozak Feb 4, 2026

Choose a reason for hiding this comment

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

For example we could have some special fields in the CONTEXT only used by interpreter that stores an address and an opcode. The meaning of those fields would be "if the opcode at address X is a breakpoint, execute opcode Y instead". This allows the debugger to enable the breakpoint bypass behavior at any time, not just after a breakpoint was previously hit. The debugger doesn't always skip breakpoints immediately after hitting them. We might need to define some explicit InterpretterCONTEXT or Get/SetXYZ() functions that define where these fields get placed in the CONTEXT data blob.

@noahfalk Do we need to keep multiple address + opcode pairs or is one enough? I'm thinking that we could just record the current address + opcode when we call ActivatePatchSkip but not sure if there is a scenario where this would break.

If we don't need more than one pair, I was thinking we could store it on the thread like thread->SetInterpreterBypass((const int32_t*)PC, (int32_t)patch->opcode); and then check in interpexec we would just get the opcode and execute it.

Copy link
Member

Choose a reason for hiding this comment

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

I'm ~75% confident that storing a single address will be sufficient

SetSP(&ctx, (DWORD64)pFrame);
SetFP(&ctx, (DWORD64)stack);
SetIP(&ctx, (DWORD64)ip);
SetIP(&ctx, (DWORD64)ip);
Copy link
Member

Choose a reason for hiding this comment

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

Its fine if its not yet implemented, but just wanted to give a heads up that eventually debugger scenarios are going to mutate the CONTEXT values inside the callback to do things like SetIP, changing to a different stack frame, or changing the value of a local variable. Some of these values will then need to be read back from the CONTEXT after the FirstChanceNativeException callback and have their new value honored by the interpreter when execution resumes.

Copy link
Contributor

Copilot AI left a comment

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 21 out of 21 changed files in this pull request and generated 8 comments.

Comment on lines +499 to +501
if (interpreterFrameAddr != 0)
{
Frame *pFrame = (Frame*)interpreterFrameAddr;
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The code casts interpreterFrameAddr to a Frame pointer without verifying that the address is valid or properly aligned. If the first arg register contains garbage data or was not properly set up, this could lead to undefined behavior when calling GetFrameIdentifier. Consider adding validation such as null checks or alignment checks before dereferencing the Frame pointer.

Suggested change
if (interpreterFrameAddr != 0)
{
Frame *pFrame = (Frame*)interpreterFrameAddr;
// Validate that the interpreter frame address is non-zero and suitably aligned
// before treating it as a Frame pointer.
if (interpreterFrameAddr != 0 && (interpreterFrameAddr % sizeof(void*)) == 0)
{
Frame *pFrame = reinterpret_cast<Frame *>(interpreterFrameAddr);

Copilot uses AI. Check for mistakes.
- New InterpreterWalker class decodes bytecode control flow for stepping
- Update TrapStep to use InterpreterWalker for interpreted code
- Add per-thread TSNC_InterpreterSingleStep flag for step tracking
- ApplyPatch now uses INTOP_SINGLESTEP vs INTOP_BREAKPOINT based on flag
- Handle INTOP_SINGLESTEP in interpreter execution loop
- needed for step-in support in virtual calls
Copilot AI review requested due to automatic review settings February 9, 2026 10:38
@dotnet-policy-service dotnet-policy-service bot added the linkable-framework Issues associated with delivering a linker friendly framework label Feb 9, 2026
@matouskozak matouskozak force-pushed the interpreter-breakpoints branch from e7b0222 to bd2a935 Compare February 9, 2026 10:42
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI review requested due to automatic review settings February 9, 2026 13:07
@matouskozak matouskozak force-pushed the interpreter-breakpoints branch from 18cd651 to c58d2b3 Compare February 9, 2026 13:07
Copy link
Contributor

Copilot AI left a comment

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 23 out of 23 changed files in this pull request and generated 3 comments.

{
LOG((LF_CORDB, LL_INFO1000, "InterpreterEC::ApplyPatch Patch already applied at %p\n",
patch->address));
return false;
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

InterpreterExecutionControl::ApplyPatch returns false when the current opcode is already INTOP_BREAKPOINT. Callers (e.g., DebuggerController::ActivatePatch) expect ApplyPatch to activate the patch or they will _ASSERTE(patch->IsActivated()). If the bytecode can already be patched (e.g., leftover patch, race, or external patching), this path can assert and/or leave the patch inactive. Consider treating this as success by marking the patch activated and retrieving the original opcode from the patch table (or otherwise ensuring patch->opcode is set consistently).

Suggested change
return false;
// Treat this as a successful activation; keep the existing opcode.
patch->opcode = currentOpcode;
patch->m_interpActivated = true;
return true;

Copilot uses AI. Check for mistakes.
Comment on lines +3594 to +3605
#ifdef FEATURE_INTERPRETER
// For interpreter code, single-stepping is emulated using breakpoints
// which are managed by the stepper, so no cleanup needed here.
if (context != NULL)
{
PCODE ip = GetIP(context);
EECodeInfo codeInfo(ip);
if (codeInfo.IsValid() && codeInfo.IsInterpretedCode())
{
LOG((LF_CORDB,LL_INFO1000, "DC::UnapplyTraceFlag: interpreter code at IP %p - no hardware flag to clear\n", ip));
return;
}
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

UnapplyTraceFlag returns early when the stopped context IP is interpreted code, which skips clearing the per-thread stepping state (MarkThreadForDebugStepping(thread, false)) and clearing the hardware single-step flag via UnsetSSFlag. If the trace flag was enabled before transitioning into interpreter code, this can leave the thread permanently marked as stepping and/or keep TF set, causing repeated single-step exceptions in unmanaged interpreter runtime code. Consider always clearing the stepping state (and clearing TF when a context is available) even when the current IP is interpreted code.

Copilot uses AI. Check for mistakes.
// * -------------------------------------------------------------------------
// Emulates single-stepping for interpreter code using breakpoint patches.

InterpreterSingleStepEmulator::InterpreterSingleStepEmulator(
Copy link
Member

Choose a reason for hiding this comment

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

The abstraction is a bit different than what I was thinking of at first, but we could keep the functionality and adjust the naming/code structure.

For a single step emulator I was thinking of what FEATURE_EMULATE_SINGLESTEP does in threads.h. The debugger calls SetSSFlag() or UnsetSSFlag() the same as it would when dealing with hardware single stepping. In particular the interface to enable the emulator doesn't provide any information about call targets or what broader stepping operation is in progress. Instead the contract is just to execute the next opcode and then raise a SINGLE_STEP_EXCEPTION afterwards.

The code here looks more like a helper that implements stepping through managed code. This approach should work fine as long as there is no alternate code path that reaches DebuggerController::EnableSingleStep() or SetSSFlag() when pointing at interpreter code. I think you already have an alternate solution for PatchSkipper, you don't need to support data breakpoints, and func-eval shouldn't need to re-enable single stepping if it wasn't enabled when the func-eval started. Assuming you don't find any other code paths that lead into the single-stepping code then I'd suggest refactor this code into TrapInterpreterCodeStep() or as a helper function called by that method. I wouldn't call this code a single step emulator. When describing the functionality in docs or comments we could say something like:

"The runtime doesn't support single-stepping, nor do we emulate it in a general-purpose way. Instead we we've implemented alternate solutions for debugger scenarios that traditionally rely upon it. For breakpoint skipping the interpreter allows executing an alternate opcode on a per-thread basis. For stepping we primarily use control flow prediction + breakpoints. For indirect call and branch instructions that are unpredictable in multi-threaded scenarios we emulate their effect on the current CONTEXT."

#ifdef FEATURE_INTERPRETER
// Interpreter opcodes can have value 0 (e.g., INTOP_RET), so we need a separate flag
// to track activation state since PRDIsEmpty() returns true for opcode 0.
// TODO: We should consider using the activated flag for all patches and stop using opcode == 0 as a special case for "not active".
Copy link
Member

@noahfalk noahfalk Feb 9, 2026

Choose a reason for hiding this comment

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

yeah, it would be better to do that for all patches rather than treating this an interpreter special case.

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed, I've created an issue for this #124499

int32_t m_interpBypassOpcode; // Original opcode to execute instead of INTOP_BREAKPOINT

public:
void SetInterpreterBypass(const int32_t* address, int32_t opcode)
Copy link
Member

Choose a reason for hiding this comment

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

Doing this per-thread rather than per-CONTEXT means that places which modify the current CONTEXT also need to save and restore this state as side-band information. Single-step emulation on hardware architectures that need it already does the same thing. Check out:

// On ARM/ARM64 the single step flag is per-thread and not per context. We need to make sure that the SS flag is cleared
// for the funceval, and that the state is back to what it should be after the funceval completes.
#ifdef FEATURE_EMULATE_SINGLESTEP
bool ssEnabled = pDE->m_thread->IsSingleStepEnabled();
if (ssEnabled)
pDE->m_thread->DisableSingleStep();
#endif
FuncEvalHijackRealWorker(pDE, pThread, &FEFrame);
#ifdef FEATURE_EMULATE_SINGLESTEP
if (ssEnabled)
pDE->m_thread->EnableSingleStep();
#endif

Copy link
Member Author

Choose a reason for hiding this comment

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

I've added it to the InterpMethodContextFrame which eventually gets packaged into the CONTEXT.

Replace INTOP_SINGLESTEP with per-frame bypass, extract InterpreterStepHelper

- Remove INTOP_SINGLESTEP opcode and per-thread single-step flag
  (TSNC_InterpreterSingleStep). Single-stepping is now fully
  breakpoint-based via control flow prediction.

- Add per-frame breakpoint bypass mechanism (InterpMethodContextFrame::
  SetBypass/HasBypass/ClearBypass). ActivatePatchSkip sets bypass on
  the frame via IExecutionControl::BypassPatch; interpreter checks it
  post-callback and executes the original opcode without re-hitting
  the breakpoint.

- Extract InterpreterStepHelper from TrapStep inline code into a
  reusable class with SetupStep() that analyzes bytecode and places
  breakpoint patches at predicted next instructions. TrapStep now
  delegates to TrapInterpreterCodeStep for interpreter code.

- Add m_interpActivated flag to DebuggerControllerPatch to track
  activation state independently of opcode value (INTOP_RET == 0
  makes PRDIsEmpty unreliable for interpreter patches).

- Simplify InterpBreakpoint: remove isStepOut parameter and
  FaultingExceptionFrame path. Remove GetBreakpointInfo from
  InterpreterExecutionControl.

- Add bounds assert in EmitBBCode for IL-to-native map.
@matouskozak matouskozak force-pushed the interpreter-breakpoints branch from c58d2b3 to 66141e7 Compare February 11, 2026 18:32
Copilot AI review requested due to automatic review settings February 11, 2026 19:34
Copy link
Contributor

Copilot AI left a comment

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 19 out of 19 changed files in this pull request and generated no new comments.

@matouskozak matouskozak changed the title [WIP][clr-interp] Support for managed debugger breakpoints [clr-interp] Support for breakpoints and stepping Feb 12, 2026
@matouskozak matouskozak removed the linkable-framework Issues associated with delivering a linker friendly framework label Feb 17, 2026
@matouskozak matouskozak removed the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label Feb 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants