Skip to content

Breakpoints are bound for reading only, so setting one is still a bp command #126

Description

@glslang

The gap

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions