[interp] Send CreateThread debugger event from the interpreter#129389
[interp] Send CreateThread debugger event from the interpreter#129389kotlarmilos wants to merge 1 commit into
Conversation
|
Tagging subscribers to 'os-ios': @vitek-karas, @kotlarmilos, @steveisok, @akoeplinger |
There was a problem hiding this comment.
Pull request overview
This PR updates CoreCLR’s interpreter/debugger integration so that interpreted threads can be properly announced to the managed debugger (CreateThread) and adds additional interpreter-emitted debug variable info for call return values.
Changes:
- Adds a per-thread
TSNC_DebuggerThreadStartSentbit and uses it to makeDebugger::ThreadStartedidempotent (avoid duplicate CreateThread events). - Calls into a new
DebugInterface::SendCreateThreadAtInterpreterEntryfromInterpExecMethodto ensure interpreted threads are announced under a debugger. - Extends the interpreter compiler’s
setVarsdebug info emission to appendCALL_RETURN_ILNUMentries for managed call sites with return values.
Show a summary per file
| File | Description |
|---|---|
| src/coreclr/vm/threads.h | Adds TSNC_DebuggerThreadStartSent thread-state bit to guard CreateThread event emission. |
| src/coreclr/vm/interpexec.cpp | Sends CreateThread from interpreter entry when a debugger is attached and the per-thread flag hasn’t been set. |
| src/coreclr/vm/dbginterface.h | Adds SendCreateThreadAtInterpreterEntry to the debugger interface. |
| src/coreclr/interpreter/compiler.h | Introduces INTERP_INST_FLAG_DBG_CALL_INSTRUCTION and InterpCallReturnInfo storage. |
| src/coreclr/interpreter/compiler.cpp | Collects per-call return info and appends CALL_RETURN_ILNUM entries to NativeVarInfo passed to setVars. |
| src/coreclr/debug/ee/debugger.h | Declares Debugger::SendCreateThreadAtInterpreterEntry. |
| src/coreclr/debug/ee/debugger.cpp | Implements interpreter-driven CreateThread send; makes ThreadStarted idempotent via the new TSNC bit. |
Copilot's findings
Comments suppressed due to low confidence (1)
src/coreclr/debug/ee/debugger.cpp:8873
- The new TSNC_DebuggerThreadStartSent guard makes ThreadStarted() permanently skip sending DB_IPCE_THREAD_ATTACH for a thread once it has fired. This looks problematic for detach/reattach scenarios (debugger.cpp handles DB_IPCE_DETACH_FROM_PROCESS and explicitly references re-attach): after a detach, existing threads would still have this TSNC bit set, so a subsequent re-attach would not be able to re-send CreateThread for those threads.
if (pRuntimeThread->HasThreadStateNC(Thread::TSNC_DebuggerThreadStartSent))
return;
pRuntimeThread->SetThreadStateNC(Thread::TSNC_DebuggerThreadStartSent);
// We just need to send a VMPTR_Thread. The RS will get everything else it needs from DAC.
//
_ASSERTE((g_pEEInterface->GetThread() &&
!g_pEEInterface->GetThread()->m_fPreemptiveGCDisabled));
_ASSERTE(ThreadHoldsLock());
DebuggerIPCEvent* ipce = m_pRCThread->GetIPCEventSendBuffer();
- Files reviewed: 7/7 changed files
- Comments generated: 1
dcbb856 to
6669499
Compare
There was a problem hiding this comment.
Copilot's findings
Comments suppressed due to low confidence (1)
src/coreclr/vm/interpexec.cpp:1370
- The new interpreter-entry debugger hook dereferences g_pDebugInterface without a runtime null-check. CORDebuggerAttached() is just a global flag and does not guarantee g_pDebugInterface is non-null; in retail builds the _ASSERTE is compiled out and this can become a null dereference. Nearby interpreter debugging code already uses an explicit (g_pDebugInterface != NULL) guard (e.g., InterpBreakpoint / INTOP_DEBUG_METHOD_ENTER).
ip = pExceptionClauseArgs->ip;
}
if (pFrame->pStack + pMethod->allocaSize > pThreadContext->pStackEnd)
{
pFrame->ip = ip;
HandleInterpreterStackOverflow(pInterpreterFrame);
UNREACHABLE();
}
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
| #ifdef DEBUGGING_SUPPORTED | ||
| if (CORDebuggerAttached()) | ||
| { | ||
| Thread *pCurThread = GetThread(); |
There was a problem hiding this comment.
The InterpThreadContext is a piece of thread specific data that needs to be initialized before we execute interpreted code. Seems like this logic belongs there, around GetOrCreateInterpThreadContext. You would also no longer need that flag.
There was a problem hiding this comment.
Updated, moved the once per thread gate onto InterpThreadContext and dropped the global TSNC flag.
There was a problem hiding this comment.
My point was that we could avoid doing these checks in InterpExecMethod which is a massive and hot method already. We could send the event when the context is created (which is when we are about to start executing interpreter code)
| if (!CORDebuggerAttached()) | ||
| return; | ||
|
|
||
| if (pRuntimeThread->HasThreadStateNC(Thread::TSNC_DebuggerThreadStartSent)) |
There was a problem hiding this comment.
You mentioned that this event should only be passed once. How does it work if the thread starts executing in some SPC.dll R2R code (which I would assume is the standard flow), which I assume sends that ThreadCreated event and then we later call this again when we first get into the interpreter code ? It is not clear to me whether the way we are doing this event handling is unified with the R2R/JIT path
There was a problem hiding this comment.
Both paths go through ThreadStarted -> THREAD_ATTACH, so the event should be unified. On Apple mobile the trace-call starter is disabled, so the interpreter is the only runtime to call into it. The only gap could be mixed mode where the starter works and a thread runs JIT before interpreter. I can add a small guard in ThreadStarted if this is a real scenario.
There was a problem hiding this comment.
Went ahead and improved it. ThreadStarted only sends the thread attach event once per thread, whether it's triggered by the JIT thread-starter or by the interpreter. So even in mixed mode, where a thread runs JIT code before hitting the interpreter, we won't get a duplicate.
There was a problem hiding this comment.
Come to think about it, given we would only debug interpreter code, the fact that r2r doesn't register it should be fine. And then JIT + Interpreter is probably not a configuration that will ever matter. I would be fine with leaving it as before, I have no strong opinion on it.
There was a problem hiding this comment.
Yes I agree, dropped it.
6669499 to
17b22ab
Compare
17b22ab to
49a94b3
Compare
49a94b3 to
dc9b4a3
Compare
dc9b4a3 to
77a20b5
Compare
For JIT-compiled code, the managed CreateThread debug event is driven by DebuggerThreadStarter: the VM only calls ThreadCreated, and the event is pulled lazily by a trace-call native patch at the thread's first managed instruction, which then invokes ThreadStarted. The interpreter executes bytecode rather than native stubs, so that patch never resolves, and the trace-call mechanism is additionally disabled on arm64-macOS. As a result, an interpreter thread never sends DB_IPCE_THREAD_ATTACH, the Right Side has no ICorDebugThread for it, and breakpoints stop the process with no stack trace, no stepping, and no visible breakpoint (most visible on the iOS simulator). Announce the thread directly from the interpreter the first time it runs interpreted code under a debugger: - Add Debugger::SendCreateThreadAtInterpreterEntry, which establishes the required SENDIPCEVENT_BEGIN/END context (debugger lock + preemptive GC mode) and calls ThreadStarted. - Track the once-per-thread state on the per-thread InterpThreadContext (m_threadStartEventSent) as a cheap gate so the interpreter avoids taking the debugger lock on every interpreted call. - Make Debugger::ThreadStarted idempotent via a per-thread TSNC_DebuggerThreadStartSent flag set by all send paths, so the JIT/R2R DebuggerThreadStarter and the interpreter cannot produce a duplicate CreateThread event for the same thread. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
77a20b5 to
4e66110
Compare
|
|
||
| virtual void ThreadStarted(Thread* pRuntimeThread) = 0; | ||
|
|
||
| virtual void SendCreateThreadAtInterpreterEntry(Thread* pRuntimeThread) = 0; |
There was a problem hiding this comment.
Not sure if it matters in this particular case, but I believe it is good practice to add new methods to the interface at the end, so that you don't change the order in the C++ object vtable for the already existing methods. Just so you don't risk breaking any backwards compatibility somewhere
Description
When debugging interpreted code, breakpoints stop the process but there are no locals. The cause is that the interpreter never sends the managed
CreateThreadevent, so the it has noICorDebugThreadfor the thread. For JIT, the VM only callsThreadCreated, and the event itself is pulled lazily byDebuggerThreadStartervia the trace-call mechanism when the thread hits its first managed instruction.This change has the interpreter notify the thread directly the first time it runs interpreted code under a debugger. A new per-thread
TSNC_DebuggerThreadStartSentflag guards the event to at most once per thread.InterpExecMethodcalls a newDebugger::SendCreateThreadAtInterpreterEntry, which establishes the requiredSENDIPCEVENT_BEGIN/ENDcontext and then invokes the existingThreadStarted.