Skip to main content

Module debug_control

Module debug_control 

Source
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] call vm::step directly (bypassing advance_with_limit entirely, per the module doc above), and count VM steps in a local loop variable — never Stats::steps. (Stats itself is still threaded through, because vm::step’s signature requires &mut Stats for 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 writes Stats::steps specifically.)
  • DEFAULT_DEBUG_BUDGET is the debug-only ceiling: generous enough that ordinary single-stepping never trips it, low enough that a debug_run that never reaches an armed breakpoint (or a debug_step step-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.
BreakpointSet
A caller-owned collection of breakpoints, checked by position. Not tied to any particular crate::Story — the same set can be handed to consecutive debug_run calls, or across flows compiled from the same crate::Program.
DebugRunOutcome
The result of a debug_run/debug_step* call: why it stopped, the resulting position (mirrors DebugPosition semantics — None for a frame with an empty container stack, e.g. after a terminal step, or a parked/External-frame position; see debug.rs’s own doc), and the resulting call-stack depth (the innermost thread’s frame count) at the moment execution stopped.
WatchHit
One recorded watchpoint hit: a watched global slot was written to.
WatchpointObserver
A WriteObserver that watches a fixed set of global slot indices and records a hit whenever one is written — the entire watchpoint implementation. Reuses the existing production WriteObserver/ ObservedContext seam (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§

DebugStopReason
Why a debug_run/debug_step call stopped.
StepMode
How a debug_step call derives its “run until” target from call-stack depth deltas (docs/debugger-spec.md §4). See Story::debug_step for 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§

BreakpointId
Identifies one breakpoint within a BreakpointSet.