Threading model

Two things here are easy to conflate and are not the same mechanism: the process stops as a unit, and a step targets a single thread. This page is about how both are true at once.

There is no debugger thread

The obvious design would be a dedicated thread pumping WaitForDebugEvent while requests arrive on another and are marshalled across. That is not what this is.

WaitForDebugEvent and ContinueDebugEvent are thread-affine — the thread that waits is the thread that must continue. So the engine does not own a thread at all: it is pumped cooperatively by a single host loop, and launching, attaching and pumping all have to happen on that same thread.

Both frontends therefore have exactly two threads:

ThreadJob
Main loop Drains the request queue, handles each request, then pumps one debug event. Everything that touches the debuggee happens here.
Stdin reader Reads and frames incoming protocol messages and pushes them onto a thread-safe queue. Nothing else.

Between the loop and the engine sits a command queue — continue, step, pause — drained at the top of each event cycle. Its job is not to cross threads but to place the action at a well-defined point: the moment before the next event is fetched, while the pending debug event is still held.

A consequence worth stating, because it looks like an omission: DbgHelp — which StackWalk64 lives in — is documented as single-threaded and is used here without a lock. That is safe precisely because every call into it is on the one pump thread. Add a second thread and the absence of that lock becomes a bug.

The one deliberate exception is cancelling a synthetic call. Evaluating an expression that calls a method runs real code in the debuggee, on the stopped thread, under a nested event pump. Cancelling it has to reach that pump from outside, so the stdin thread sets an atomic flag the nested pump polls. It is the only cross-thread signal in the design.

What stops together

At a breakpoint, an exception or a pause, the whole process stops. There is no freeze-one-thread-and-let-the-others-run mode: a single ContinueDebugEvent resumes the process, and every stop is reported to the client with all threads stopped.

Any thread’s call stack, locals and watches remain readable at that stop, whichever thread triggered it — see chapter 6.

Pause is DebugBreakProcess, not a suspend. Windows implements it by injecting a thread into the target, which would otherwise become the thread the stop is reported against — leaving you looking at a stack that is pure debugger plumbing. The engine picks a real thread to report instead, so a pause lands somewhere meaningful.

Stepping one thread

A step names a thread. Every other thread is explicitly suspended for the duration, because the Win32 debug API resumes all threads on ContinueDebugEvent — an explicit suspend is what survives that continue and keeps the others parked until the step lands.

Four details make this safe rather than merely plausible:

  • Freezes never stack. A new step thaws first, so it always starts from a known baseline rather than incrementing suspend counts that are never unwound.
  • Only successful suspends are recorded, so a failed suspend can never produce an unmatched resume later.
  • A thread created mid-step is frozen too. Otherwise it would run free alongside the one thread that is supposed to be moving.
  • If the stepped thread exits mid-step, everything thaws. Its single-step can never land, and leaving the others frozen would deadlock a process with nothing runnable in it. The exiting thread is also dropped from the freeze set before its handle closes, so nothing tries to resume a dead handle.

Thawing happens at exactly one place — the point every stop funnels through, whether it was a breakpoint, a step landing, an exception, a pause or the entry stop. There is no second code path that has to remember.

The inherent limit, the same one VS Code has: if the stepped thread blocks on a lock held by a frozen thread, the step cannot complete. Freezing is what makes single-thread stepping meaningful and it is also what makes this possible; the two cannot be separated.

The step that deliberately does not freeze

Stepping while stopped on a first-chance exception is the exception to all of the above, and the reason is worth reading if you are tempted to make it consistent.

Every other step this engine performs covers a handful of instructions. That one runs the operating system’s exception dispatch and Delphi’s unwind — code which takes the memory manager’s locks. Freeze a thread that happens to hold one of those and the step does not merely fail: the whole process deadlocks.

So that step scopes itself by checking where it landed instead of by freezing anything. Same goal, different mechanism, because the obvious mechanism is actively unsafe there.

Deciding on one thread, executing on another

Instruction-level steps and exception steps can refuse — no provable return address, undecodable bytes, an unprovable handler. That refusal has to come back synchronously to whoever asked, but the execution has to happen on the pump thread, because that is the only thread allowed to call ContinueDebugEvent.

So these steps are split: the decision is made where the request arrives, reading state only, and what crosses to the pump thread is a plan to execute. A refused step therefore answers immediately and nothing in the debuggee moves — not even partially.

Walking a thread’s stack

Each stack walk resolves the thread’s handle and seeds itself from a fresh GetThreadContext, on a private copy of the context — the walker mutates its context as it goes, so borrowing the caller’s would corrupt it.

On 32-bit targets StackWalk64 is the fallback rather than the primary walker: the EBP chain drives the walk instead. That is also why 32-bit stacks end early at the first routine built without a frame pointer, and why the raw stack scan exists as an opt-in for that case.

Thread names

Two sources, in order.

TThread.NameThreadForDebugging announces a name by raising exception code 0x406D1388 with the string in the exception record. The engine consumes it before the filter and rule machinery runs and resumes the thread. It can never surface as a stop — not with the all first-chance filter on, and no user rule can turn it into one, because it never reaches the rules at all.

The name is stored per thread id and looked up per request rather than cached when the thread is created, because the announcement typically arrives long after creation. It is dropped when the thread exits, since Windows recycles thread ids and a stale name would end up on the wrong thread.

When no name was announced, the engine falls back to the modern GetThreadDescription API, resolved dynamically so the adapter still loads on Windows versions that lack it. Failing both, a thread is reported by id.

Hardware watchpoints across threads

DR0–DR3 are per-thread registers, but which slot watches which address is a single process-wide fact — you asked to watch an address, not to watch it on one thread. The engine reconciles those by replicating: a watchpoint is armed on every live thread, armed again on each newly created thread, armed on attach, and cleared on detach.

A thread that could not be armed is logged rather than passed over silently. The user-facing behaviour is in chapter 3; the four-slot budget is in breakpoints and exceptions internals.

Two threads on one breakpoint

Resuming from a breakpoint means restoring the original byte, single-stepping over it, and putting the INT3 back. In a multithreaded process a second thread can reach that same address while the first is mid-re-arm.

The re-arm single-step is therefore bound to the thread that is performing it. Another thread arriving at the same address is a genuine hit and is reported as one, rather than being swallowed as if it were the re-arm step — which is the failure mode that makes a breakpoint in hot multithreaded code appear to miss.