brink_runtime/debug_control.rs
1//! Debugger control seam (D8, issue #3186): breakpoints, pause/resume, and
2//! step in/over/out — the part of the debugger epic (#452) that turns the
3//! read-only [`crate::DebugSnapshot`] (D4, #3182) into something that can
4//! actually halt and single-step a running story.
5//!
6//! **Why this is provably zero-cost when `debug-hooks` is off.** The
7//! `effect-trace`/`bench-counters` precedent this feature follows
8//! (`docs/debugger-spec.md` §1.4, `vm.rs:~1544-1620`) threads paired
9//! `#[cfg(feature)]`/no-op-stub *call sites* directly into `vm::step_impl`'s
10//! dispatch body, because that instrumentation (per-opcode fault/effect
11//! attribution) genuinely needs to run inline with specific opcodes. A
12//! breakpoint check does not: all it needs is the position
13//! `(container_idx, offset)` *before* an instruction executes, which is
14//! already fully available from outside the hot loop — [`Story`]'s own
15//! `container_stack.last()` (the same read `debug_snapshot`/D4's
16//! `debug_position` already do, `story/mod.rs`'s `build_debug_snapshot`).
17//! So this module does not add anything to `vm::step_impl` or to
18//! `FlowInstance::advance_with_limit` (the production per-turn loop) at
19//! all — on or off. Instead it wraps the existing `pub(crate) vm::step`
20//! (already used this way by the `testing`-gated `Story::step_once` probe)
21//! in its **own** loop, entered only through the `Story::debug_run`/
22//! `debug_step*` methods this module's types back — methods that exist at
23//! all only when `debug-hooks` is enabled (declared behind
24//! `#[cfg(feature = "debug-hooks")]` in `lib.rs` and `story/mod.rs`). With
25//! the feature off: this module doesn't compile, those methods don't
26//! exist, and every byte of `vm.rs`/`flow_instance.rs` that the production
27//! path (`continue_single`/`continue_maximally`/…) actually executes is
28//! untouched — not merely "the branch is cheap", there is no branch. This
29//! is a *stronger* zero-cost property than the effect-trace template's own
30//! (which does add cfg-compiled-out call sites inside the hot loop), and
31//! it is exactly what CLAUDE.md's "instrumentation doesn't belong in the
32//! production path" principle asks for: "if an `if observer` branch
33//! appears in a hot loop, the abstraction boundary is wrong" — so this
34//! seam doesn't put one there.
35//!
36//! **The step-limit ruling (issue #3186 decision comment, 2026-08-28).**
37//! Debug stepping gets its own budget, entirely separate from the
38//! production step limit (`FlowInstance::STEP_LIMIT`,
39//! `Stats::steps`/`RuntimeError::StepLimitExceeded`):
40//!
41//! - Production step accounting is unchanged and unread from debug-hook
42//! code: [`Story::debug_run`]/[`Story::debug_step`] call `vm::step`
43//! directly (bypassing `advance_with_limit` entirely, per the module doc
44//! above), and count VM steps in a **local** loop variable — never
45//! `Stats::steps`. (`Stats` itself is still threaded through, because
46//! `vm::step`'s signature requires `&mut Stats` for its own
47//! *non*-step-limit bookkeeping — `frames_pushed`, `materializations`,
48//! `choices_presented` — real per-event counters, not the step-limit
49//! counter this ruling is about. Debug-hook code never reads or writes
50//! `Stats::steps` specifically.)
51//! - [`DEFAULT_DEBUG_BUDGET`] is the debug-only ceiling: generous enough
52//! that ordinary single-stepping never trips it, low enough that a
53//! `debug_run` that never reaches an armed breakpoint (or a `debug_step`
54//! step-over/out that never returns to/leaves the target frame — a
55//! runaway loop between the two) surfaces promptly rather than hanging a
56//! studio UI. Callers may pass a tighter or looser ceiling per call.
57//! - Exceeding it is [`RuntimeError::DebugBudgetExceeded`] — never
58//! [`RuntimeError::StepLimitExceeded`], which would misreport a debug
59//! budget as the production one.
60//!
61//! **Frame semantics** (breakpoint/step-into/step-over/step-out) are
62//! derived from call-stack depth deltas per `docs/debugger-spec.md` §4 and
63//! the issue's own framing — see [`Story::debug_step`]'s doc for exactly
64//! how each [`StepMode`] maps to a depth comparison, and for what is
65//! deliberately *not* attempted here (source-level/statement-boundary
66//! stepping needs the `DebugInfo` section's `IS_STMT` entries, D6/#3184,
67//! not shipped yet; this seam only has opcode-level positions to work
68//! with, which is exactly what "derived from call-stack depth deltas" — a
69//! phrase from the issue text itself — asks for).
70//!
71//! **Watchpoints reuse [`WriteObserver`]/[`ObservedContext`]** (`state.rs`)
72//! rather than inventing a second observer, per the issue's own
73//! instruction. [`WatchpointObserver`] is the whole addition: a
74//! `WriteObserver` impl that records a hit when a *watched* global slot is
75//! written. [`Story::debug_run_watching`] wraps the routing context in
76//! [`ObservedContext`] around it, exactly as `Story::continue_single_observed`
77//! already does for the production line-buffered path — no VM change
78//! needed for this half either.
79
80use alloc::string::String;
81use alloc::vec::Vec;
82
83use brink_format::Value;
84
85use crate::debug::DebugPosition;
86use crate::state::WriteObserver;
87
88/// Debug stepping's own step budget ceiling — separate from the
89/// production step limit (`FlowInstance::STEP_LIMIT` = 1,000,000 per
90/// call). See the module doc's "step-limit ruling" section for why this
91/// exists and what it does and doesn't share with production accounting.
92///
93/// Chosen generous relative to a single step-into/step-over/breakpoint-run
94/// (ordinary stepping and running to a breakpoint a handful of frames away
95/// stays orders of magnitude under this), low enough that a predicate/loop
96/// that never satisfies its stop condition reports back in well under a
97/// second of VM-step work instead of hanging a studio UI indefinitely.
98pub const DEFAULT_DEBUG_BUDGET: u64 = 200_000;
99
100/// Identifies one breakpoint within a [`BreakpointSet`].
101pub type BreakpointId = u32;
102
103/// One breakpoint: an unconditional halt at a `(container_idx, offset)`
104/// bytecode position, checked *before* that instruction executes.
105///
106/// v1 breakpoints are position-only (no source expression condition) —
107/// scoped this way deliberately: an ink-expression *conditional*
108/// breakpoint would need to evaluate an expression inside the paused
109/// frame, which is exactly the "evaluate expression in frame" facility
110/// [`crate::Speculation`] exists for for a *later* slice of this seam, not
111/// re-derived here. A `run`/`debug_run` that never reaches an armed
112/// breakpoint is still bounded — that's what [`DEFAULT_DEBUG_BUDGET`] is
113/// for.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Breakpoint {
116 pub id: BreakpointId,
117 pub container_idx: u32,
118 pub offset: usize,
119 /// Author-facing name/label, surfaced in
120 /// [`DebugStopReason::Breakpoint`] and in
121 /// [`RuntimeError::DebugBudgetExceeded`](crate::RuntimeError::DebugBudgetExceeded)
122 /// when a conditional evaluation (a future slice) burns the debug
123 /// budget on this breakpoint specifically. Never empty in practice —
124 /// [`BreakpointSet::insert`] defaults it to the position if the caller
125 /// passes an empty string.
126 pub name: String,
127 pub enabled: bool,
128}
129
130/// A caller-owned collection of breakpoints, checked by position. Not tied
131/// to any particular [`crate::Story`] — the same set can be handed to
132/// consecutive `debug_run` calls, or across flows compiled from the same
133/// [`crate::Program`].
134#[derive(Debug, Clone, Default)]
135pub struct BreakpointSet {
136 breakpoints: Vec<Breakpoint>,
137 next_id: BreakpointId,
138}
139
140impl BreakpointSet {
141 #[must_use]
142 pub fn new() -> Self {
143 Self::default()
144 }
145
146 /// Add an enabled breakpoint at `(container_idx, offset)`, returning its
147 /// id. An empty `name` is replaced with a `container:offset` label so
148 /// every breakpoint has something non-empty to report.
149 pub fn insert(
150 &mut self,
151 container_idx: u32,
152 offset: usize,
153 name: impl Into<String>,
154 ) -> BreakpointId {
155 let id = self.next_id;
156 self.next_id += 1;
157 let mut name = name.into();
158 if name.is_empty() {
159 use alloc::format;
160 name = format!("{container_idx}:{offset}");
161 }
162 self.breakpoints.push(Breakpoint {
163 id,
164 container_idx,
165 offset,
166 name,
167 enabled: true,
168 });
169 id
170 }
171
172 /// Remove a breakpoint by id. Returns `false` if no breakpoint with
173 /// that id exists.
174 pub fn remove(&mut self, id: BreakpointId) -> bool {
175 let before = self.breakpoints.len();
176 self.breakpoints.retain(|b| b.id != id);
177 self.breakpoints.len() != before
178 }
179
180 /// Enable/disable a breakpoint without removing it. Returns `false` if
181 /// no breakpoint with that id exists.
182 pub fn set_enabled(&mut self, id: BreakpointId, enabled: bool) -> bool {
183 if let Some(bp) = self.breakpoints.iter_mut().find(|b| b.id == id) {
184 bp.enabled = enabled;
185 true
186 } else {
187 false
188 }
189 }
190
191 pub fn iter(&self) -> impl Iterator<Item = &Breakpoint> {
192 self.breakpoints.iter()
193 }
194
195 /// The first enabled breakpoint at `pos`, if any. Deterministic:
196 /// breakpoints are checked in insertion order (`Vec`, not a hash map),
197 /// so a position with more than one enabled breakpoint always reports
198 /// the earliest-inserted one.
199 #[must_use]
200 pub(crate) fn hit(&self, pos: DebugPosition) -> Option<&Breakpoint> {
201 self.breakpoints
202 .iter()
203 .find(|b| b.enabled && b.container_idx == pos.container_idx && b.offset == pos.offset)
204 }
205}
206
207/// How a `debug_step` call derives its "run until" target from call-stack
208/// depth deltas (`docs/debugger-spec.md` §4). See
209/// [`Story::debug_step`](crate::Story::debug_step) for the exact
210/// per-variant depth comparison.
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum StepMode {
213 /// Execute exactly one instruction, descending into any newly-entered
214 /// frame. Uniform across every `CallFrameType` — table §4's own
215 /// framing ("This is uniform across frame types").
216 Into,
217 /// Run through any call the next instruction makes without stopping
218 /// inside it; stop once back at (or still at) the starting depth.
219 Over,
220 /// Run until the current frame returns to its caller (depth strictly
221 /// less than the starting depth).
222 Out,
223}
224
225/// Why a `debug_run`/`debug_step` call stopped.
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum DebugStopReason {
228 /// An enabled breakpoint's position was reached, checked before the
229 /// matching instruction executed.
230 Breakpoint { id: BreakpointId, name: String },
231 /// A watched global was written (`debug_run_watching` only).
232 Watchpoint { global_idx: u32 },
233 /// A choice point was reached (`vm::Stepped::Done` with non-empty
234 /// pending choices) — the flow is now `WaitingForChoice`, distinct
235 /// from [`DebugStopReason::Terminal`]: unlike an actual `-> DONE`/
236 /// `-> END`, [`Story::choose`](crate::Story::choose) followed by
237 /// [`Story::continue_single`](crate::Story::continue_single) can
238 /// resume the story from here. Turn-index bump and invisible-default
239 /// auto-select have already been applied (the same bookkeeping
240 /// `advance_with_limit` performs on this outcome), so a caller that
241 /// hands control back to the production API sees consistent state
242 /// (issue #3186 review).
243 Choices,
244 /// The requested step (into/over/out) completed normally.
245 Step,
246 /// Execution reached a bound external whose handler deferred
247 /// ([`ExternalResult::Pending`](crate::ExternalResult::Pending)) —
248 /// the `External` frame is left intact, exactly as
249 /// [`FlowInstance::advance`](crate::FlowInstance::advance) surfaces
250 /// `StepOutcome::AwaitingExternal` (#3224). The host resolves it
251 /// out-of-band ([`Story::resolve_external`](crate::Story::resolve_external),
252 /// or [`resolve_external_flow`](crate::Story::resolve_external_flow)
253 /// for a named flow), then resumes with any debug verb. Synchronously
254 /// resolved externals and in-story fallbacks never surface this —
255 /// the debug loops run them through
256 /// exactly as production `advance()` does.
257 AwaitingExternal,
258 /// The flow reached a terminal VM outcome (`-> DONE`/`-> END`, or
259 /// content otherwise exhausted) before the requested stop condition —
260 /// breakpoint, watchpoint, or step target — was reached.
261 Terminal,
262 /// `StepMode::Out` was requested from the outermost (`Root`) frame,
263 /// which has no caller to return to — `docs/debugger-spec.md` §4:
264 /// "The debugger must disable step-out... exactly as GDB disables
265 /// `finish` in the outermost frame." Reported instead of running the
266 /// story to its own natural end, which would be a misleading way to
267 /// answer "step out of a frame with no caller."
268 NoStepOutTarget,
269 /// A line-granular step was requested but the artifact cannot say which
270 /// line execution is on — no `DebugInfo`, or a file compiled without
271 /// source text so it carries no line index (#3264, #3261).
272 ///
273 /// Reported rather than silently degrading to instruction stepping:
274 /// handing someone four-presses-per-line when they asked to advance one
275 /// line is how a missing line index becomes a mystery instead of a
276 /// legible "this build has no line info". Mirrors
277 /// [`Self::NoStepOutTarget`]'s posture — a verb that has nothing to do
278 /// here says so.
279 NoLineInfo,
280}
281
282/// The result of a `debug_run`/`debug_step*` call: why it stopped, the
283/// resulting position (mirrors [`DebugPosition`] semantics — `None` for a
284/// frame with an empty container stack, e.g. after a terminal step, or a
285/// parked/`External`-frame position; see `debug.rs`'s own doc), and the
286/// resulting call-stack depth (the innermost thread's frame count) at the
287/// moment execution stopped.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct DebugRunOutcome {
290 pub reason: DebugStopReason,
291 pub position: Option<DebugPosition>,
292 pub depth: usize,
293}
294
295/// One recorded watchpoint hit: a watched global slot was written to.
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub struct WatchHit {
298 pub global_idx: u32,
299}
300
301/// A [`WriteObserver`] that watches a fixed set of global slot indices and
302/// records a hit whenever one is written — the entire watchpoint
303/// implementation. Reuses the existing production `WriteObserver`/
304/// `ObservedContext` seam (`state.rs`) rather than a second observer
305/// mechanism, per the issue's own instruction: this struct is the only
306/// piece of new plumbing watchpoints need.
307///
308/// Composes with [`crate::Story::debug_run_watching`] for pausing
309/// mid-step-loop on a hit, or with the existing
310/// `Story::continue_single_observed` (unaffected by this feature) for
311/// production-path logging without pausing — the observer doesn't care
312/// which loop drives it.
313#[derive(Debug, Clone, Default)]
314pub struct WatchpointObserver {
315 watched: Vec<u32>,
316 pending: Vec<WatchHit>,
317}
318
319impl WatchpointObserver {
320 #[must_use]
321 pub fn new(watched_globals: Vec<u32>) -> Self {
322 Self {
323 watched: watched_globals,
324 pending: Vec::new(),
325 }
326 }
327
328 #[must_use]
329 pub fn watches(&self, global_idx: u32) -> bool {
330 self.watched.contains(&global_idx)
331 }
332
333 /// Every hit recorded since the last `take_hits`/`take_hit` call.
334 #[must_use]
335 pub fn hits(&self) -> &[WatchHit] {
336 &self.pending
337 }
338
339 /// Pop the earliest pending hit, if any — the FIFO half of the
340 /// pause-on-write loop `debug_run_watching` drives.
341 pub(crate) fn take_hit(&mut self) -> Option<WatchHit> {
342 if self.pending.is_empty() {
343 None
344 } else {
345 Some(self.pending.remove(0))
346 }
347 }
348
349 /// Drain every hit recorded since the last `take_hits`/`clear` call, in
350 /// the order they were recorded. The public counterpart to `take_hit`
351 /// — for a consumer that composes this observer with
352 /// `Story::continue_single_observed`/`continue_maximally_observed`
353 /// (non-pausing logging, per this module's doc), `take_hit`'s
354 /// `pub(crate)` FIFO pop is not reachable from outside the crate, so
355 /// without this method `pending` would accumulate for the observer's
356 /// entire lifetime with no way for a consumer to clear it — the
357 /// unbounded-growth guard this method (and `clear`) close.
358 pub fn take_hits(&mut self) -> Vec<WatchHit> {
359 core::mem::take(&mut self.pending)
360 }
361
362 /// Discard every pending hit without returning them — the other half
363 /// of the unbounded-growth guard `take_hits` provides.
364 pub fn clear(&mut self) {
365 self.pending.clear();
366 }
367}
368
369impl WriteObserver for WatchpointObserver {
370 fn on_set_global(&mut self, idx: u32, _value: &Value) {
371 if self.watched.contains(&idx) {
372 self.pending.push(WatchHit { global_idx: idx });
373 }
374 }
375}