The native debugging APIs (like kernel32!WaitForDebugEvent), dispatch one debug event at a time. In contrast, ICorDebug queues up debug events and such may be ready to dispatch multiple events at a single stop. This is an extremely significant difference. It means that you need to drain the managed event queue before you have an accurate view of the debuggee. That's because the debug events have already occurred even though they haven't been dispatched to you yet.
Another way to look at this is that ICorDebugProcess::Continue actually has 2 meanings: 1) dispatch the next queued debug event without actually resuming the debuggee; and 2) resume the debuggee. There's an API, ICorDebugProcess::HasQueuedCallbacks, which can tell you if there are any managed callbacks queued up.
The problem: For example, suppose there are 2 queued managed events:
1) Breakpoint on Thread 1
2) Breakpoint on Thread 2
This means that both threads have hit breakpoints. Let's say you don't drain the event queue, and instead in the first callback (for the BP on thread 1), you suspend Thread 2. You then call Continue, and ICorDebug dispatches the next debug event in the queue which will be the breakpoint on thread 2. Thus you're getting debug events on a thread that you thought you suspended! Since you generally don't expect a suspended thread to do anything like generate debug events, this can be confusing.
Note that sometimes a single debuggee state change can generate multiple debug events even on the same thread. For example, if a step-over lands on a breakpoint, you could get a step-complete and breakpoint notification together. [Update:] However, the interface specifies no limitations on which combinations of events may occur in a single callback queue. So debugger authors need to be careful to handle this properly and not make assumptions.
The bottom line is that it's very important to call ICorDebugProcess::HasQueuedCallbacks to fully drain the event queue and get an accurate picture of the debuggee's state. I would also strongly recommend calling it with a null thread parameter since you want process wide knowledge. The full prototype from CorDebug.idl is:
HRESULT HasQueuedCallbacks([in] ICorDebugThread *pThread, [out] BOOL *pbQueued);
pbQueued will be set to true iff there are queued events; else it will be false.
The idl currently says this regarding the pThread parameter: "If NULL is given for the pThread parameter, HasQueuedCallbacks will return TRUE if there are currently managed callbacks queued for any thread."
I think phrases like "queued for any thread" and "associated with a thread" are ambiguous. In one sense, all debug events are somehow associated with a thread because:
1) all events correspond to the debuggee reaching a particular state, and
2) debuggee state changes only occur from theads executing, and
3) so some thread must have been the one to finally cause that state change.
For example, although the LoadModule callback doesn't include a thread parameter, the module load was still initiated by some thread.
So when pThread==NULL, then HasQueuedCallbacks returns true iff there are any callbacks queued for the given process.
Since ICorDebug does not support per-appdomain debugging, you should only call HasQueuedCallbacks on a process.
Also, I would recommend to always pass pThread==NULL unless you know exactly what you're doing. Unless you look at the entire process, you may fall victim to these cross-thread state changes described above.
Regarding the UI: An additional catch is that this does force the UI to deal with how to show the user that multiple debug events occurred simultaneously (such as breakpoints on different threads). This is easy in a command line debugger: just print all the debug events to the console before stopping the shell.
Regarding Native debugging: It turns out native debugging still has a similar problem, but in a much smaller window. If a native debug event has already been dispatched, then WaitForDebug event may still pick up the event even if the debugger suspends the debuggee thread.