You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Breakpoints are bound in one direction. DebugEngine::breakpoints() reads the engine's whole
breakpoint table through IDebugBreakpoint2 and fills in eleven fields — id, kind, address, expression, command, thread, enabled, deferred, one_shot, pass_count, passes_remaining — so every getter that matters is already called and already exercised.
Nothing writes any of them. What exists on the write side is dbgeng::Breakpoint, and it is:
public and unused — Breakpoint::new has no caller in src/, examples/ or tests/;
on IDebugBreakpoint, the v1 interface, where the read path uses IDebugBreakpoint2;
missing every setter but SetOffsetExpression — no SetCommand, SetPassCount, SetMatchThreadId, no DEBUG_BREAKPOINT_ONE_SHOT, no DEBUG_BREAKPOINT_DATA;
panicking in three of its four methods. enable, disable and remove each .expect(...),
against the crate's own rule that a primitive returns Result<_, DbgEngError>. enable's message
is a copy-paste of set_offset_expression's ("Failed to set breakpoint offset") for an AddFlags
call, which is a fair sign of how much it has been used.
The private ScopedBreakpoint shows the shape that works — AddBreakpoint2 + SetOffset + AddFlags(DEBUG_BREAKPOINT_ENABLED), with ManuallyDrop for the use-after-free that RemoveBreakpoint2 otherwise causes — but it is hard-wired to run_to_address's one need: an
enabled code breakpoint at a resolved address.
So this is not new API surface to research. It is the symmetric half of a struct the crate already
fills in, and the hazard the awkward half of it hides (DbgEng owns the object; do not let the
generated wrapper Release() it) is already solved and commented in two places.
Why it matters to a caller
windbg-mcp's set_breakpoint runs bp <expression> as text, and pays for it three times.
It is an injection surface. The command is built by interpolation, so the operand has to be
screened by reject_command_breakers before it goes anywhere near the engine: DbgEng reads ; as
a separator, and a double quote opens a breakpoint command string that WinDbg runs on every hit
— so nt!Foo ".opendump other.dmp" would arm a target swap that fires later, outside any tool
call. A typed setter takes the expression as a parameter. There is nothing to escape and nothing
to screen.
It has to be bounded.Bound every raw command, except the one an abort would damage windbg-mcp#271 gave EngineOp::SetBreakpoint a patience_ms
because bp nt!Foo+0x10 makes the MASM evaluator resolve a symbol, and on a deferred module with
a srv* path that is a symbol-server fetch with the session's engine held for all of it. See the
open question below — this one may not go away, but it stops being about a command.
The id has to be recovered by diffing. A successful bp prints nothing at all, so worker::set_breakpoint reads breakpoints() before and after and subtracts, with a documented
degraded mode when the "before" read fails (BreakpointSet::listed: false, added empty because
it is unknown rather than empty). AddBreakpoint2 hands back the object, and GetId is already
bound. The whole diff, and the degraded mode with it, is answering a question the API answers
directly.
And the reason recorded for the text path does not survive contact with the caller. It reads: "a
command rather than a typed AddBreakpoint because bp's syntax is the point: a condition, a
command string to run on each hit, /1 for one-shot". Two of those three are impossible to send
today — a condition and a command string both need a quoted string, and quotes are what reject_command_breakers refuses. Only /1 survives, and it is one flag bit
(DEBUG_BREAKPOINT_ONE_SHOT). The syntax that justified the text hatch is syntax the tool does not
accept.
What would close it
Setters mirroring the getters breakpoints() already calls, on IDebugBreakpoint2, each returning Result<_, DbgEngError> — offset or offset-expression, command, pass count, match-thread, the
enabled/one-shot flags, and DEBUG_BREAKPOINT_DATA for ba. Whether that is a builder, an AddBreakpoint-with-a-spec, or repairs to Breakpoint is the design question; what it must not be
is a second lifetime story, since ScopedBreakpoint and Breakpoint already disagree about who
removes a breakpoint and when.
Breakpoint's three panics want fixing whatever shape is chosen, and if the type is superseded it
should go rather than stay as a public trap.
The open question, which decides how much this is worth
Does SetOffsetExpression resolve eagerly?BreakpointInfo::expression's own doc says a
breakpoint that resolved when it was set keeps its address and the engine no longer holds the text
— so bp resolves when it can and defers only when the module is absent. If SetOffsetExpression
does the same, the symbol-server block does not go away; it moves from an Execute a watchdog can
Ctrl+Break to a direct engine call nothing can, which is a regression in what a caller can
promise, and the same one #95 is about for read_memory.
Measure before building: set a breakpoint on a symbol in a module whose PDB is not in the cache,
through bp and through SetOffsetExpression, and time both. Three outcomes, three different
answers — if it defers, this is a clear win; if it blocks and SetInterrupt reaches it, the setter
wants a budget like execute_command_bounded's; if it blocks and cannot be interrupted, the honest
result is that this trades a bound for a cleaner API and the issue should say so out loud rather
than let a caller discover it.
Dependent change
In windbg-mcp, once the primitive exists:
worker::set_breakpoint drops the bl before/after diff and the listed/listing_error
degraded mode with it.
EngineOp::SetBreakpoint's patience_ms (added in Bound every raw command, except the one an abort would damage windbg-mcp#271) is either passed to a
bounded setter or removed — and if it is removed, set_breakpoint moves into the enumerated
allowlist in worker::tests::every_unbounded_execute_in_this_worker_is_one_of_the_known_five,
which is the test that will notice either way.
reject_command_breakers on that tool becomes defence in depth rather than the only thing between
a caller and an armed .opendump.
ioctl_trace is the second consumer, and the one that needs SetCommand: it hand-builds bp <dispatch> ".printf \"IOCTL %08x …\", …; gc" with the escaping done by hand in a format
string. That is the one place the "bp's syntax is the point" argument is actually true today,
and a typed command parameter is what makes it stop being true.
FOLLOWUPS.md item 2 asks for ba (data) breakpoints as a typed primitive; BreakpointKind::Data
already exists on the read side, so the same work closes that half of it.
Found while closing FOLLOWUPS.md item 14 on glslang/windbg-mcp#271, where a review finding — set_breakpoint running an unbounded command inside a typed op — led to reading why it runs a
command at all.
The gap
Breakpoints are bound in one direction.
DebugEngine::breakpoints()reads the engine's wholebreakpoint table through
IDebugBreakpoint2and fills in eleven fields —id,kind,address,expression,command,thread,enabled,deferred,one_shot,pass_count,passes_remaining— so every getter that matters is already called and already exercised.Nothing writes any of them. What exists on the write side is
dbgeng::Breakpoint, and it is:Breakpoint::newhas no caller insrc/,examples/ortests/;IDebugBreakpoint, the v1 interface, where the read path usesIDebugBreakpoint2;SetOffsetExpression— noSetCommand,SetPassCount,SetMatchThreadId, noDEBUG_BREAKPOINT_ONE_SHOT, noDEBUG_BREAKPOINT_DATA;enable,disableandremoveeach.expect(...),against the crate's own rule that a primitive returns
Result<_, DbgEngError>.enable's messageis a copy-paste of
set_offset_expression's ("Failed to set breakpoint offset") for anAddFlagscall, which is a fair sign of how much it has been used.
The private
ScopedBreakpointshows the shape that works —AddBreakpoint2+SetOffset+AddFlags(DEBUG_BREAKPOINT_ENABLED), withManuallyDropfor the use-after-free thatRemoveBreakpoint2otherwise causes — but it is hard-wired torun_to_address's one need: anenabled code breakpoint at a resolved address.
So this is not new API surface to research. It is the symmetric half of a struct the crate already
fills in, and the hazard the awkward half of it hides (DbgEng owns the object; do not let the
generated wrapper
Release()it) is already solved and commented in two places.Why it matters to a caller
windbg-mcp'sset_breakpointrunsbp <expression>as text, and pays for it three times.screened by
reject_command_breakersbefore it goes anywhere near the engine: DbgEng reads;asa separator, and a double quote opens a breakpoint command string that WinDbg runs on every hit
— so
nt!Foo ".opendump other.dmp"would arm a target swap that fires later, outside any toolcall. A typed setter takes the expression as a parameter. There is nothing to escape and nothing
to screen.
EngineOp::SetBreakpointapatience_msbecause
bp nt!Foo+0x10makes the MASM evaluator resolve a symbol, and on a deferred module witha
srv*path that is a symbol-server fetch with the session's engine held for all of it. See theopen question below — this one may not go away, but it stops being about a command.
bpprints nothing at all, soworker::set_breakpointreadsbreakpoints()before and after and subtracts, with a documenteddegraded mode when the "before" read fails (
BreakpointSet::listed: false,addedempty becauseit is unknown rather than empty).
AddBreakpoint2hands back the object, andGetIdis alreadybound. The whole diff, and the degraded mode with it, is answering a question the API answers
directly.
And the reason recorded for the text path does not survive contact with the caller. It reads: "a
command rather than a typed
AddBreakpointbecausebp's syntax is the point: a condition, acommand string to run on each hit,
/1for one-shot". Two of those three are impossible to sendtoday — a condition and a command string both need a quoted string, and quotes are what
reject_command_breakersrefuses. Only/1survives, and it is one flag bit(
DEBUG_BREAKPOINT_ONE_SHOT). The syntax that justified the text hatch is syntax the tool does notaccept.
What would close it
Setters mirroring the getters
breakpoints()already calls, onIDebugBreakpoint2, each returningResult<_, DbgEngError>— offset or offset-expression, command, pass count, match-thread, theenabled/one-shot flags, and
DEBUG_BREAKPOINT_DATAforba. Whether that is a builder, anAddBreakpoint-with-a-spec, or repairs toBreakpointis the design question; what it must not beis a second lifetime story, since
ScopedBreakpointandBreakpointalready disagree about whoremoves a breakpoint and when.
Breakpoint's three panics want fixing whatever shape is chosen, and if the type is superseded itshould go rather than stay as a public trap.
The open question, which decides how much this is worth
Does
SetOffsetExpressionresolve eagerly?BreakpointInfo::expression's own doc says abreakpoint that resolved when it was set keeps its
addressand the engine no longer holds the text— so
bpresolves when it can and defers only when the module is absent. IfSetOffsetExpressiondoes the same, the symbol-server block does not go away; it moves from an
Executea watchdog canCtrl+Break to a direct engine call nothing can, which is a regression in what a caller can
promise, and the same one #95 is about for
read_memory.Measure before building: set a breakpoint on a symbol in a module whose PDB is not in the cache,
through
bpand throughSetOffsetExpression, and time both. Three outcomes, three differentanswers — if it defers, this is a clear win; if it blocks and
SetInterruptreaches it, the setterwants a budget like
execute_command_bounded's; if it blocks and cannot be interrupted, the honestresult is that this trades a bound for a cleaner API and the issue should say so out loud rather
than let a caller discover it.
Dependent change
In
windbg-mcp, once the primitive exists:worker::set_breakpointdrops theblbefore/after diff and thelisted/listing_errordegraded mode with it.
EngineOp::SetBreakpoint'spatience_ms(added in Bound every raw command, except the one an abort would damage windbg-mcp#271) is either passed to abounded setter or removed — and if it is removed,
set_breakpointmoves into the enumeratedallowlist in
worker::tests::every_unbounded_execute_in_this_worker_is_one_of_the_known_five,which is the test that will notice either way.
reject_command_breakerson that tool becomes defence in depth rather than the only thing betweena caller and an armed
.opendump.ioctl_traceis the second consumer, and the one that needsSetCommand: it hand-buildsbp <dispatch> ".printf \"IOCTL %08x …\", …; gc"with the escaping done by hand in a formatstring. That is the one place the "
bp's syntax is the point" argument is actually true today,and a typed
commandparameter is what makes it stop being true.FOLLOWUPS.mditem 2 asks forba(data) breakpoints as a typed primitive;BreakpointKind::Dataalready exists on the read side, so the same work closes that half of it.
Found while closing
FOLLOWUPS.mditem 14 on glslang/windbg-mcp#271, where a review finding —set_breakpointrunning an unbounded command inside a typed op — led to reading why it runs acommand at all.