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:
| Thread | Job |
|---|---|
| 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.
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 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.