Skip to content

[interp] Send CreateThread debugger event from the interpreter#129389

Open
kotlarmilos wants to merge 1 commit into
dotnet:mainfrom
kotlarmilos:interp-debugger-createthread
Open

[interp] Send CreateThread debugger event from the interpreter#129389
kotlarmilos wants to merge 1 commit into
dotnet:mainfrom
kotlarmilos:interp-debugger-createthread

Conversation

@kotlarmilos

@kotlarmilos kotlarmilos commented Jun 14, 2026

Copy link
Copy Markdown
Member

Description

When debugging interpreted code, breakpoints stop the process but there are no locals. The cause is that the interpreter never sends the managed CreateThread event, so the it has no ICorDebugThread for the thread. For JIT, the VM only calls ThreadCreated, and the event itself is pulled lazily by DebuggerThreadStarter via 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_DebuggerThreadStartSent flag guards the event to at most once per thread. InterpExecMethod calls a new Debugger::SendCreateThreadAtInterpreterEntry, which establishes the required SENDIPCEVENT_BEGIN/END context and then invokes the existing ThreadStarted.

Copilot AI review requested due to automatic review settings June 14, 2026 15:08
@kotlarmilos kotlarmilos added the os-ios Apple iOS label Jun 14, 2026
@kotlarmilos kotlarmilos added this to the 11.0.0 milestone Jun 14, 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.

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

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_DebuggerThreadStartSent bit and uses it to make Debugger::ThreadStarted idempotent (avoid duplicate CreateThread events).
  • Calls into a new DebugInterface::SendCreateThreadAtInterpreterEntry from InterpExecMethod to ensure interpreted threads are announced under a debugger.
  • Extends the interpreter compiler’s setVars debug info emission to append CALL_RETURN_ILNUM entries 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

Comment thread src/coreclr/interpreter/compiler.h
Copilot AI review requested due to automatic review settings June 14, 2026 15:21
@kotlarmilos kotlarmilos force-pushed the interp-debugger-createthread branch 2 times, most recently from dcbb856 to 6669499 Compare June 14, 2026 15: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

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

@kotlarmilos kotlarmilos marked this pull request as ready for review June 14, 2026 15:35
Copilot AI review requested due to automatic review settings June 14, 2026 15:35

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: 5/5 changed files
  • Comments generated: 1

Comment thread src/coreclr/debug/ee/debugger.cpp Outdated
Comment thread src/coreclr/vm/interpexec.cpp Outdated
#ifdef DEBUGGING_SUPPORTED
if (CORDebuggerAttached())
{
Thread *pCurThread = GetThread();

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 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.

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.

Updated, moved the once per thread gate onto InterpThreadContext and dropped the global TSNC flag.

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.

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)

Comment thread src/coreclr/debug/ee/debugger.cpp Outdated
if (!CORDebuggerAttached())
return;

if (pRuntimeThread->HasThreadStateNC(Thread::TSNC_DebuggerThreadStartSent))

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.

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

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.

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.

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.

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.

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.

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.

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.

Yes I agree, dropped it.

@kotlarmilos kotlarmilos force-pushed the interp-debugger-createthread branch from 6669499 to 17b22ab Compare June 15, 2026 13:45
Copilot AI review requested due to automatic review settings June 15, 2026 14:53
@kotlarmilos kotlarmilos force-pushed the interp-debugger-createthread branch from 17b22ab to 49a94b3 Compare June 15, 2026 14:53

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: 5/5 changed files
  • Comments generated: 2

Comment thread src/coreclr/vm/interpexec.cpp Outdated
Comment thread src/coreclr/debug/ee/debugger.cpp
@kotlarmilos kotlarmilos force-pushed the interp-debugger-createthread branch from 49a94b3 to dc9b4a3 Compare June 15, 2026 15:54
Copilot AI review requested due to automatic review settings June 15, 2026 17:58

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: 4/4 changed files
  • Comments generated: 2

Comment thread src/coreclr/vm/threads.cpp
Comment thread src/coreclr/debug/ee/debugger.cpp
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>
@kotlarmilos kotlarmilos force-pushed the interp-debugger-createthread branch from 77a20b5 to 4e66110 Compare June 15, 2026 18:22

virtual void ThreadStarted(Thread* pRuntimeThread) = 0;

virtual void SendCreateThreadAtInterpreterEntry(Thread* pRuntimeThread) = 0;

@BrzVlad BrzVlad Jun 15, 2026

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.

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

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