Expand description
Debugger control seam (D8, issue #3186): breakpoints, pause/resume, and
step in/over/out — the part of the debugger epic (#452) that turns the
read-only crate::DebugSnapshot (D4, #3182) into something that can
actually halt and single-step a running story.
Why this is provably zero-cost when debug-hooks is off. The
effect-trace/bench-counters precedent this feature follows
(docs/debugger-spec.md §1.4, vm.rs:~1544-1620) threads paired
#[cfg(feature)]/no-op-stub call sites directly into vm::step_impl’s
dispatch body, because that instrumentation (per-opcode fault/effect
attribution) genuinely needs to run inline with specific opcodes. A
breakpoint check does not: all it needs is the position
(container_idx, offset) before an instruction executes, which is
already fully available from outside the hot loop — [Story]’s own
container_stack.last() (the same read debug_snapshot/D4’s
debug_position already do, story/mod.rs’s build_debug_snapshot).
So this module does not add anything to vm::step_impl or to
FlowInstance::advance_with_limit (the production per-turn loop) at
all — on or off. Instead it wraps the existing pub(crate) vm::step
(already used this way by the testing-gated Story::step_once probe)
in its own loop, entered only through the Story::debug_run/
debug_step* methods this module’s types back — methods that exist at
all only when debug-hooks is enabled (declared behind
#[cfg(feature = "debug-hooks")] in lib.rs and story/mod.rs). With
the feature off: this module doesn’t compile, those methods don’t
exist, and every byte of vm.rs/flow_instance.rs that the production
path (continue_single/continue_maximally/…) actually executes is
untouched — not merely “the branch is cheap”, there is no branch. This
is a stronger zero-cost property than the effect-trace template’s own
(which does add cfg-compiled-out call sites inside the hot loop), and
it is exactly what CLAUDE.md’s “instrumentation doesn’t belong in the
production path” principle asks for: “if an if observer branch
appears in a hot loop, the abstraction boundary is wrong” — so this
seam doesn’t put one there.
The step-limit ruling (issue #3186 decision comment, 2026-08-28).
Debug stepping gets its own budget, entirely separate from the
production step limit (FlowInstance::STEP_LIMIT,
Stats::steps/RuntimeError::StepLimitExceeded):
- Production step accounting is unchanged and unread from debug-hook
code: [
Story::debug_run]/[Story::debug_step] callvm::stepdirectly (bypassingadvance_with_limitentirely, per the module doc above), and count VM steps in a local loop variable — neverStats::steps. (Statsitself is still threaded through, becausevm::step’s signature requires&mut Statsfor its own non-step-limit bookkeeping —frames_pushed,materializations,choices_presented— real per-event counters, not the step-limit counter this ruling is about. Debug-hook code never reads or writesStats::stepsspecifically.) DEFAULT_DEBUG_BUDGETis the debug-only ceiling: generous enough that ordinary single-stepping never trips it, low enough that adebug_runthat never reaches an armed breakpoint (or adebug_stepstep-over/out that never returns to/leaves the target frame — a runaway loop between the two) surfaces promptly rather than hanging a studio UI. Callers may pass a tighter or looser ceiling per call.- Exceeding it is [
RuntimeError::DebugBudgetExceeded] — never [RuntimeError::StepLimitExceeded], which would misreport a debug budget as the production one.
Frame semantics (breakpoint/step-into/step-over/step-out) are
derived from call-stack depth deltas per docs/debugger-spec.md §4 and
the issue’s own framing — see [Story::debug_step]’s doc for exactly
how each StepMode maps to a depth comparison, and for what is
deliberately not attempted here (source-level/statement-boundary
stepping needs the DebugInfo section’s IS_STMT entries, D6/#3184,
not shipped yet; this seam only has opcode-level positions to work
with, which is exactly what “derived from call-stack depth deltas” — a
phrase from the issue text itself — asks for).
Watchpoints reuse WriteObserver/[ObservedContext] (state.rs)
rather than inventing a second observer, per the issue’s own
instruction. WatchpointObserver is the whole addition: a
WriteObserver impl that records a hit when a watched global slot is
written. [Story::debug_run_watching] wraps the routing context in
[ObservedContext] around it, exactly as Story::continue_single_observed
already does for the production line-buffered path — no VM change
needed for this half either.
Structs§
- Breakpoint
- One breakpoint: an unconditional halt at a
(container_idx, offset)bytecode position, checked before that instruction executes. - Breakpoint
Set - A caller-owned collection of breakpoints, checked by position. Not tied
to any particular
crate::Story— the same set can be handed to consecutivedebug_runcalls, or across flows compiled from the samecrate::Program. - Debug
RunOutcome - The result of a
debug_run/debug_step*call: why it stopped, the resulting position (mirrorsDebugPositionsemantics —Nonefor a frame with an empty container stack, e.g. after a terminal step, or a parked/External-frame position; seedebug.rs’s own doc), and the resulting call-stack depth (the innermost thread’s frame count) at the moment execution stopped. - Watch
Hit - One recorded watchpoint hit: a watched global slot was written to.
- Watchpoint
Observer - A
WriteObserverthat watches a fixed set of global slot indices and records a hit whenever one is written — the entire watchpoint implementation. Reuses the existing productionWriteObserver/ObservedContextseam (state.rs) rather than a second observer mechanism, per the issue’s own instruction: this struct is the only piece of new plumbing watchpoints need.
Enums§
- Debug
Stop Reason - Why a
debug_run/debug_stepcall stopped. - Step
Mode - How a
debug_stepcall derives its “run until” target from call-stack depth deltas (docs/debugger-spec.md§4). SeeStory::debug_stepfor the exact per-variant depth comparison.
Constants§
- DEFAULT_
DEBUG_ BUDGET - Debug stepping’s own step budget ceiling — separate from the
production step limit (
FlowInstance::STEP_LIMIT= 1,000,000 per call). See the module doc’s “step-limit ruling” section for why this exists and what it does and doesn’t share with production accounting.
Type Aliases§
- Breakpoint
Id - Identifies one breakpoint within a
BreakpointSet.