Skip to content

Conversation

@matouskozak
Copy link
Member

@matouskozak matouskozak commented Jan 16, 2026

  • Extends existing support for user breakpoint to regular IDE breakpoints
  • Add support for adding breakpoints when program is stopped
  • Insert IL offset 0 poiting to IR 0 to allow setting breakpoints at method entry (opening '{')

TODO:

  • Single-stepping for interpreter

@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
@dotnet-policy-service
Copy link
Contributor

Tagging subscribers to this area: @steveisok, @tommcdon, @dotnet/dotnet-diag
See info in area-owners.md if you want to be subscribed.

@matouskozak matouskozak force-pushed the interpreter-breakpoints branch from ed5ce4c to dce1adc Compare January 16, 2026 08:52
- 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
Copilot AI review requested due to automatic review settings January 30, 2026 10:59
- needed for step-in support in virtual calls
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.

Comment on lines 1063 to 1071
printf("Single-step at IP %p\n", ip);
fflush(stdout);
#endif // DEBUG
if (pThread != NULL && pThread->IsInterpreterSingleStepEnabled())
{
#ifdef DEBUG
// This thread is single-stepping - trigger the event
printf("Single-step triggered for thread %d\n", pThread->GetThreadId());
fflush(stdout);
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

Similar to the INTOP_BREAKPOINT case, debug printf statements at lines 1063-1064 and 1069-1071 should be converted to use the LOG macro for consistency with the rest of the codebase's logging infrastructure.

Suggested change
printf("Single-step at IP %p\n", ip);
fflush(stdout);
#endif // DEBUG
if (pThread != NULL && pThread->IsInterpreterSingleStepEnabled())
{
#ifdef DEBUG
// This thread is single-stepping - trigger the event
printf("Single-step triggered for thread %d\n", pThread->GetThreadId());
fflush(stdout);
LOG((LF_INTERP, LL_INFO100, "Single-step at IP %p\n", ip));
#endif // DEBUG
if (pThread != NULL && pThread->IsInterpreterSingleStepEnabled())
{
#ifdef DEBUG
// This thread is single-stepping - trigger the event
LOG((LF_INTERP, LL_INFO100, "Single-step triggered for thread %d\n", pThread->GetThreadId()));

Copilot uses AI. Check for mistakes.
Comment on lines 17 to +24
#ifdef FEATURE_INTERPRETER

// Result of GetBreakpointInfo - combines opcode and step-out flag
struct BreakpointInfo
{
InterpOpcode originalOpcode;
bool isStepOut;
};
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The header file uses InterpOpcode type but doesn't include the header that defines it. While this works when included from files that already have the interpreter headers included (like interpexec.cpp), it violates header self-containment principles and could cause compilation errors if this header is included elsewhere. Add #include "../../interpreter/inc/intopsshared.h" after line 15 or before the BreakpointInfo struct definition to ensure the header is self-contained.

Copilot uses AI. Check for mistakes.
Comment on lines +75 to +99
// Check if there is already a breakpoint patch at this address
uint32_t currentOpcode = *(uint32_t*)patch->address;
if (currentOpcode == INTOP_BREAKPOINT || currentOpcode == INTOP_SINGLESTEP)
{
LOG((LF_CORDB, LL_INFO1000, "InterpreterEC::ApplyPatch Patch already applied at %p\n",
patch->address));
return false;
}

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();
if (pThread != NULL && pThread->IsInterpreterSingleStepEnabled())
{
*(uint32_t*)patch->address = INTOP_SINGLESTEP;
LOG((LF_CORDB, LL_INFO10000, "InterpreterEC::ApplyPatch SingleStep inserted at %p, saved opcode 0x%x (%s)\n",
patch->address, patch->opcode, GetInterpOpName(patch->opcode)));
}
else
{
*(uint32_t*)patch->address = INTOP_BREAKPOINT;
LOG((LF_CORDB, LL_INFO10000, "InterpreterEC::ApplyPatch Breakpoint inserted at %p, saved opcode 0x%x (%s)\n",
patch->address, patch->opcode, GetInterpOpName(patch->opcode)));
}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The bytecode patch operation at lines 76-99 has a potential race condition. The sequence of reading the current opcode (line 76), checking it (line 77), saving it (line 84), and writing the patch (line 90 or 96) is not atomic. If two threads try to apply patches at the same address simultaneously, or if one thread is executing the code while another is patching it, the results could be unpredictable. Consider using InterlockedCompareExchange or a similar atomic operation to ensure the patch is applied atomically. Additionally, ensure proper memory barriers are in place so that the patch is visible to all threads before execution continues.

Suggested change
// Check if there is already a breakpoint patch at this address
uint32_t currentOpcode = *(uint32_t*)patch->address;
if (currentOpcode == INTOP_BREAKPOINT || currentOpcode == INTOP_SINGLESTEP)
{
LOG((LF_CORDB, LL_INFO1000, "InterpreterEC::ApplyPatch Patch already applied at %p\n",
patch->address));
return false;
}
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();
if (pThread != NULL && pThread->IsInterpreterSingleStepEnabled())
{
*(uint32_t*)patch->address = INTOP_SINGLESTEP;
LOG((LF_CORDB, LL_INFO10000, "InterpreterEC::ApplyPatch SingleStep inserted at %p, saved opcode 0x%x (%s)\n",
patch->address, patch->opcode, GetInterpOpName(patch->opcode)));
}
else
{
*(uint32_t*)patch->address = INTOP_BREAKPOINT;
LOG((LF_CORDB, LL_INFO10000, "InterpreterEC::ApplyPatch Breakpoint inserted at %p, saved opcode 0x%x (%s)\n",
patch->address, patch->opcode, GetInterpOpName(patch->opcode)));
}
// Use an atomic compare-exchange loop to apply the patch safely.
volatile LONG* pOpcode = reinterpret_cast<volatile LONG*>(patch->address);
// Check if this is a single-step patch by looking at the controller's thread's interpreter SS flag.
Thread* pThread = patch->controller->GetThread();
const bool isSingleStep = (pThread != NULL) && pThread->IsInterpreterSingleStepEnabled();
while (true)
{
// Atomically read the current opcode with a full memory barrier.
uint32_t currentOpcode = static_cast<uint32_t>(InterlockedCompareExchange(pOpcode, 0, 0));
// If there is already a breakpoint or single-step at this address, do not apply another patch.
if (currentOpcode == INTOP_BREAKPOINT || currentOpcode == INTOP_SINGLESTEP)
{
LOG((LF_CORDB, LL_INFO1000, "InterpreterEC::ApplyPatch Patch already applied at %p\n",
patch->address));
return false;
}
LONG newOpcode = isSingleStep
? static_cast<LONG>(INTOP_SINGLESTEP)
: static_cast<LONG>(INTOP_BREAKPOINT);
// Attempt to atomically replace the current opcode with the new one.
uint32_t originalOpcode = static_cast<uint32_t>(InterlockedCompareExchange(
pOpcode,
newOpcode,
static_cast<LONG>(currentOpcode)));
// If the value we observed is still current, we successfully applied the patch.
if (originalOpcode == currentOpcode)
{
patch->opcode = currentOpcode; // Save original opcode
if (isSingleStep)
{
LOG((LF_CORDB, LL_INFO10000, "InterpreterEC::ApplyPatch SingleStep inserted at %p, saved opcode 0x%x (%s)\n",
patch->address, patch->opcode, GetInterpOpName(patch->opcode)));
}
else
{
LOG((LF_CORDB, LL_INFO10000, "InterpreterEC::ApplyPatch Breakpoint inserted at %p, saved opcode 0x%x (%s)\n",
patch->address, patch->opcode, GetInterpOpName(patch->opcode)));
}
break;
}
// Another thread modified the opcode concurrently; retry with the new value.
}

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +211
{
// Method slot is at ip[3] for all direct call opcodes
LOG((LF_CORDB, LL_INFO10000, "InterpreterWalker::GetCallTarget: method slot=%d\n", m_ip[3]));
int32_t methodSlot = m_ip[3];
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The GetCallTarget method accesses m_ip[3] and m_pInterpMethod->pDataItems[methodSlot] without bounds checking. If the instruction format is invalid or if methodSlot is out of bounds for the pDataItems array, this could cause an out-of-bounds access. Consider adding validation to ensure methodSlot is within valid bounds before accessing pDataItems. You can check against the data items count if it's available in InterpMethod, or add assertions to catch invalid values during debugging.

Suggested change
{
// Method slot is at ip[3] for all direct call opcodes
LOG((LF_CORDB, LL_INFO10000, "InterpreterWalker::GetCallTarget: method slot=%d\n", m_ip[3]));
int32_t methodSlot = m_ip[3];
{
// Validate that the instruction format is large enough to contain ip[3]
int opcode = m_opcode;
if (opcode < 0 || opcode >= (int)(sizeof(s_interpOpLenWalker) / sizeof(s_interpOpLenWalker[0])))
{
LOG((LF_CORDB, LL_INFO10000, "InterpreterWalker::GetCallTarget: invalid opcode=%d for direct call\n", opcode));
return NULL;
}
if (s_interpOpLenWalker[opcode] <= 3)
{
LOG((LF_CORDB, LL_INFO10000, "InterpreterWalker::GetCallTarget: opcode=%d has insufficient length for method slot\n", opcode));
return NULL;
}
// Method slot is at ip[3] for all direct call opcodes
LOG((LF_CORDB, LL_INFO10000, "InterpreterWalker::GetCallTarget: method slot=%d\n", m_ip[3]));
int32_t methodSlot = m_ip[3];
if (methodSlot < 0)
{
LOG((LF_CORDB, LL_INFO10000, "InterpreterWalker::GetCallTarget: negative method slot=%d\n", methodSlot));
return NULL;
}
#ifdef _DEBUG
_ASSERTE((size_t)methodSlot < m_pInterpMethod->cDataItems);
#endif
if ((size_t)methodSlot >= m_pInterpMethod->cDataItems)
{
LOG((LF_CORDB, LL_INFO10000, "InterpreterWalker::GetCallTarget: method slot=%d out of range (count=%zu)\n",
methodSlot, m_pInterpMethod->cDataItems));
return NULL;
}

Copilot uses AI. Check for mistakes.
Comment on lines 1028 to 1048
#ifdef DEBUG
printf("Breakpoint at IP %p, original opcode %u, isStepOut=%d\n", ip, bpInfo.originalOpcode, bpInfo.isStepOut);
fflush(stdout);
#endif // DEBUG

if (pThread != NULL && pThread->IsInterpreterSingleStepEnabled())
{
#ifdef DEBUG
// This thread is single-stepping - trigger the event
printf("We hit a breakpoint while single-stepping for thread %d\n", pThread->GetThreadId());
fflush(stdout);
#endif // DEBUG
bpInfo.isStepOut = false;
}

InterpBreakpoint(ip, pFrame, stack, pInterpreterFrame, STATUS_BREAKPOINT, bpInfo.isStepOut);
if (!bpInfo.isStepOut)
{
#ifdef DEBUG
printf("Executing original opcode %u at IP %p\n", bpInfo.originalOpcode, ip);
fflush(stdout);
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

Debug printf statements are left in production code paths (lines 1029-1030, 1037-1038, 1047-1048). While they are guarded by #ifdef DEBUG, they use printf/fflush which can have performance implications and are not consistent with the runtime's logging infrastructure. Consider removing these debug prints or converting them to use the LOG macro like the rest of the debugger code (e.g., LOG((LF_CORDB, LL_INFO10000, "..."))) for consistency and better control over logging levels.

Copilot uses AI. Check for mistakes.
Comment on lines 1052 to 1054
} else {
break;
}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

In the INTOP_BREAKPOINT case, when bpInfo.isStepOut is true, the code breaks out of the switch without incrementing ip. This means the next iteration will execute the same INTOP_BREAKPOINT instruction again, potentially causing an infinite loop. The step-out breakpoints should advance ip after the breakpoint handling, similar to how the non-step-out case uses goto to re-execute the original opcode. Consider adding ip++ before the break statement in the else branch at line 1052-1054.

Suggested change
} else {
break;
}
}
ip++;
break;

Copilot uses AI. Check for mistakes.
Comment on lines +5681 to +5692
// For FEATURE_INTERPRETER, the SP can also point to interpreter stack memory which is outside native stack bounds.
// Skip validation in these cases as the interpreter uses heap-allocated frame structures.
// TODO: Figure this out without disabling the check entirely.
#if !defined(TARGET_WASM) && !defined(FEATURE_INTERPRETER)
if (pRD->SP && pRD->_pThread)
{
#ifndef NO_FIXED_STACK_LIMIT
_ASSERTE(pRD->_pThread->IsExecutingOnAltStack() || PTR_VOID(pRD->SP) >= pRD->_pThread->GetCachedStackLimit());
#endif // NO_FIXED_STACK_LIMIT
_ASSERTE(pRD->_pThread->IsExecutingOnAltStack() || PTR_VOID(pRD->SP) < pRD->_pThread->GetCachedStackBase());
}
#endif // !TARGET_WASM
#endif // !TARGET_WASM && !FEATURE_INTERPRETER
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

Disabling the SP validation check for all FEATURE_INTERPRETER builds is overly broad and reduces debug coverage. The check should only be skipped when actually in interpreter frames, not for all code when the interpreter feature is enabled. Consider checking if the current frame is an interpreter frame (e.g., using EECodeInfo::IsInterpretedCode on the current IP) before skipping the validation, similar to how the TARGET_WASM case is handled. This would preserve the debug check for JIT code while still allowing interpreter frames to work correctly.

Copilot uses AI. Check for mistakes.
Comment on lines +73 to +75
int InterpreterWalker::GetOpcodeLength(int32_t opcode) const
{
int len = s_interpOpLenWalker[opcode];
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The GetOpcodeLength function at line 75 accesses s_interpOpLenWalker[opcode] without bounds checking. If the opcode value is corrupted or out of range (e.g., due to memory corruption or a bug in the interpreter), this could lead to an out-of-bounds array access. Consider adding a bounds check before the array access, similar to how InterpOpNameLocal checks bounds at lines 36-37. For example: if (opcode < 0 || opcode >= sizeof(s_interpOpLenWalker)/sizeof(s_interpOpLenWalker[0])) return 1; or similar error handling.

Copilot uses AI. Check for mistakes.
} else {
break;
}
}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The INTOP_SINGLESTEP handler is missing a case label separator, causing it to fall through from the INTOP_BREAKPOINT case. While both cases have similar handling logic, the fallthrough is unintentional based on the code structure. Add a break; statement at line 1055 after the closing brace of INTOP_BREAKPOINT to prevent unintentional fallthrough, or add a comment explicitly documenting why the fallthrough is intentional if that's the design.

Suggested change
}
}
break;

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-Diagnostics-coreclr NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant