Skip to main content

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}