-
Notifications
You must be signed in to change notification settings - Fork 5.3k
[WIP][clr-interp] Support for managed debugger breakpoints #123251
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
Tagging subscribers to this area: @steveisok, @tommcdon, @dotnet/dotnet-diag |
ed5ce4c to
dce1adc
Compare
- 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
There was a problem hiding this 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_SINGLESTEPopcode for step-over operations - Implements
InterpreterWalkerto 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 |
There was a problem hiding this 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.
src/coreclr/vm/interpexec.cpp
Outdated
| 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); |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
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.
| 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())); |
| #ifdef FEATURE_INTERPRETER | ||
|
|
||
| // Result of GetBreakpointInfo - combines opcode and step-out flag | ||
| struct BreakpointInfo | ||
| { | ||
| InterpOpcode originalOpcode; | ||
| bool isStepOut; | ||
| }; |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
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.
| // 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))); | ||
| } |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
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.
| // 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. | |
| } |
| { | ||
| // 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]; |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
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.
| { | |
| // 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; | |
| } |
src/coreclr/vm/interpexec.cpp
Outdated
| #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); |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
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.
src/coreclr/vm/interpexec.cpp
Outdated
| } else { | ||
| break; | ||
| } |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
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.
| } else { | |
| break; | |
| } | |
| } | |
| ip++; | |
| break; |
| // 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 |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
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.
| int InterpreterWalker::GetOpcodeLength(int32_t opcode) const | ||
| { | ||
| int len = s_interpOpLenWalker[opcode]; |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
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.
| } else { | ||
| break; | ||
| } | ||
| } |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
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.
| } | |
| } | |
| break; |
TODO: