Skip to main content

harn_vm/vm/
debug.rs

1use std::sync::Arc;
2
3use crate::chunk::{Chunk, Constant};
4use crate::value::{VmError, VmValue};
5
6use super::{CallFrame, Vm};
7
8/// Debug action returned by the debug hook.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum DebugAction {
11    /// Continue execution normally.
12    Continue,
13    /// Stop (breakpoint hit, step complete).
14    Stop,
15}
16
17/// Information about current execution state for the debugger.
18#[derive(Debug, Clone)]
19pub struct DebugState {
20    pub line: usize,
21    pub variables: crate::value::DictMap,
22    pub frame_name: String,
23    pub frame_depth: usize,
24}
25
26pub(super) type DebugHook = dyn FnMut(&DebugState) -> DebugAction + Send;
27
28impl Vm {
29    /// Replace breakpoints for a single source file. Pass an empty string
30    /// (or call `set_breakpoints` for the wildcard equivalent) to install
31    /// breakpoints that match every file — useful for ad-hoc CLI runs
32    /// where the embedder doesn't track per-file source paths.
33    pub fn set_breakpoints_for_file(&mut self, file: &str, lines: Vec<usize>) {
34        if lines.is_empty() {
35            self.breakpoints.remove(file);
36            return;
37        }
38        self.breakpoints
39            .insert(file.to_string(), lines.into_iter().collect());
40    }
41
42    /// Backwards-compatible wildcard form. Stores all lines under the
43    /// empty-string key, which matches *any* source file at the check
44    /// site. Existing embedders that don't track file scoping still work.
45    pub fn set_breakpoints(&mut self, lines: Vec<usize>) {
46        self.set_breakpoints_for_file("", lines);
47    }
48
49    /// Replace the function-breakpoint set. Every subsequent closure
50    /// call whose name matches one of the provided strings will pause
51    /// on entry. Empty vec clears the set.
52    pub fn set_function_breakpoints(&mut self, names: Vec<String>) {
53        self.function_breakpoints = names.into_iter().collect();
54        // Clear any pending latch so a stale entry from the previous
55        // configuration doesn't fire once.
56        self.pending_function_bp = None;
57    }
58
59    /// Returns the current function-breakpoint name set. Used by the
60    /// DAP adapter to build the `setFunctionBreakpoints` response with
61    /// verified=true per registered name.
62    pub fn function_breakpoint_names(&self) -> Vec<String> {
63        self.function_breakpoints.iter().cloned().collect()
64    }
65
66    /// Drain any pending function-breakpoint name latched by the most
67    /// recent closure entry. Returns `Some(name)` exactly once per hit
68    /// so the caller can emit a single `stopped` event.
69    pub fn take_pending_function_bp(&mut self) -> Option<String> {
70        self.pending_function_bp.take()
71    }
72
73    /// Source file path of the currently executing frame, if known.
74    pub(crate) fn current_source_file(&self) -> Option<&str> {
75        self.frames
76            .last()
77            .and_then(|f| f.chunk.source_file.as_deref())
78    }
79
80    /// True when a breakpoint at `line` is set for the current frame's
81    /// source file (or the wildcard set covers it).
82    pub(crate) fn breakpoint_matches(&self, line: usize) -> bool {
83        if let Some(wild) = self.breakpoints.get("") {
84            if wild.contains(&line) {
85                return true;
86            }
87        }
88        if let Some(file) = self.current_source_file() {
89            if let Some(set) = self.breakpoints.get(file) {
90                if set.contains(&line) {
91                    return true;
92                }
93            }
94            // Some callers send a relative or differently-prefixed path
95            // than the chunk records; fall back to suffix comparison so
96            // foo.harn matches /abs/path/foo.harn and vice-versa.
97            for (key, set) in &self.breakpoints {
98                if key.is_empty() {
99                    continue;
100                }
101                if (file.ends_with(key.as_str()) || key.ends_with(file)) && set.contains(&line) {
102                    return true;
103                }
104            }
105        }
106        false
107    }
108
109    /// Enable step mode (stop at the next source line regardless of
110    /// frame depth — i.e. step-in semantics, descending into calls).
111    pub fn set_step_mode(&mut self, step: bool) {
112        self.step_mode = step;
113        self.step_frame_depth = usize::MAX;
114    }
115
116    /// Enable step-over mode (stop at the next source line in the current
117    /// frame or a shallower one, skipping past any nested calls).
118    pub fn set_step_over(&mut self) {
119        self.step_mode = true;
120        self.step_frame_depth = self.frames.len();
121    }
122
123    /// Register a debug hook invoked whenever execution advances to a new source line.
124    pub fn set_debug_hook<F>(&mut self, hook: F)
125    where
126        F: FnMut(&DebugState) -> DebugAction + Send + 'static,
127    {
128        self.debug_hook = Some(parking_lot::Mutex::new(Box::new(hook)));
129    }
130
131    /// Clear the current debug hook.
132    pub fn clear_debug_hook(&mut self) {
133        self.debug_hook = None;
134    }
135
136    /// Enable step-out mode (stop at the next source line *after* the
137    /// current frame has returned — strictly shallower than where the
138    /// user requested the step-out).
139    pub fn set_step_out(&mut self) {
140        self.step_mode = true;
141        // Condition site compares `frames.len() <= step_frame_depth`, so
142        // storing N-1 makes the stop fire only after the current frame
143        // pops (frames.len() drops from N to N-1 or less). Clamp to 0 for
144        // the top frame — caller handles that via the usize::MAX sentinel
145        // if they wanted step-in semantics.
146        self.step_frame_depth = self.frames.len().saturating_sub(1);
147    }
148
149    /// Check if the VM is stopped at a debug point.
150    pub fn is_stopped(&self) -> bool {
151        self.stopped
152    }
153
154    /// Get the current debug state (variables, line, etc.).
155    pub fn debug_state(&self) -> DebugState {
156        let line = self.current_line();
157        let variables = self.visible_variables();
158        let frame_name = if self.frames.len() > 1 {
159            format!("frame_{}", self.frames.len() - 1)
160        } else {
161            "pipeline".to_string()
162        };
163        DebugState {
164            line,
165            variables,
166            frame_name,
167            frame_depth: self.frames.len(),
168        }
169    }
170
171    /// Call sites (name + ip) on `line` within the current frame's
172    /// chunk — drives DAP `stepInTargets` (#112). Walks the chunk's
173    /// parallel lines array, surfaces every Call / MethodCall /
174    /// CallSpread and pairs it with the name of the constant or
175    /// identifier preceding the call when we can derive it cheaply.
176    pub fn call_sites_on_line(&self, line: u32) -> Vec<(u32, String)> {
177        let Some(frame) = self.frames.last() else {
178            return Vec::new();
179        };
180        let chunk = &frame.chunk;
181        let mut out = Vec::new();
182        let code = &chunk.code;
183        let lines = &chunk.lines;
184        let mut ip: usize = 0;
185        while ip < code.len() {
186            let op = code[ip];
187            if ip < lines.len() && lines[ip] == line {
188                // 0x00 .. 0x99 covers the opcode space the compiler
189                // emits for calls. Rather than decode every op, we
190                // pattern-match on the Call-family opcodes via
191                // their numeric tag — stable because harn-vm locks
192                // opcodes with pin tests.
193                if matches!(op, 0x40..=0x44) {
194                    // Best-effort label: take the most recent
195                    // LoadConst / LoadGlobal constant value.
196                    let label = Self::label_preceding_call(chunk, ip);
197                    out.push((ip as u32, label));
198                }
199            }
200            ip += 1;
201        }
202        out
203    }
204
205    fn label_preceding_call(chunk: &Chunk, call_ip: usize) -> String {
206        // Walk backwards a few instructions to find a LoadConst that
207        // resolves to a string (the callee name). Good enough for
208        // the IDE menu; deep callee resolution can land later if
209        // needed.
210        let mut back = call_ip.saturating_sub(6);
211        while back < call_ip {
212            let op = chunk.code[back];
213            // LoadConst opcodes (range covers the two-byte tag) —
214            // fall back to "call" when none found.
215            if (op == 0x01 || op == 0x02) && back + 2 < chunk.code.len() {
216                let idx = (u16::from(chunk.code[back + 1]) << 8) | u16::from(chunk.code[back + 2]);
217                if let Some(Constant::String(s)) = chunk.constants.get(idx as usize) {
218                    return s.clone();
219                }
220            }
221            back += 1;
222        }
223        "call".to_string()
224    }
225
226    /// Install (or replace) the cooperative cancellation token on
227    /// this VM. Callers (DAP adapter, embedded host) flip the
228    /// wrapped AtomicBool to request graceful shutdown; the step
229    /// loop checks `is_cancel_requested()` at every instruction and
230    /// exits with `VmError::Cancelled` when set.
231    pub fn install_cancel_token(&mut self, token: std::sync::Arc<std::sync::atomic::AtomicBool>) {
232        self.cancel_token = Some(token);
233        self.cancel_grace_instructions_remaining = None;
234    }
235
236    /// Install a host signal token paired with the cancellation token. Hosts set
237    /// it to `SIGINT`, `SIGTERM`, or `SIGHUP` before flipping cancellation so
238    /// `std/signal` handlers can match the actual process signal.
239    pub fn install_interrupt_signal_token(
240        &mut self,
241        token: std::sync::Arc<std::sync::Mutex<Option<String>>>,
242    ) {
243        self.interrupt_signal_token = Some(token);
244    }
245
246    /// Signal cooperative cancellation on this VM — the step loop
247    /// unwinds on its next instruction check. Lazily allocates a
248    /// fresh token when none is installed so hosts don't need to
249    /// pre-plumb it on every launch. Returns the Arc so the caller
250    /// can hold onto it and re-signal later if needed.
251    pub fn signal_cancel(&mut self) -> std::sync::Arc<std::sync::atomic::AtomicBool> {
252        let token = self.cancel_token.clone().unwrap_or_else(|| {
253            let t = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
254            self.cancel_token = Some(t.clone());
255            t
256        });
257        token.store(true, std::sync::atomic::Ordering::SeqCst);
258        token
259    }
260
261    /// True when cooperative cancellation has been requested.
262    pub fn is_cancel_requested(&self) -> bool {
263        self.cancel_token
264            .as_ref()
265            .map(|t| t.load(std::sync::atomic::Ordering::SeqCst))
266            .unwrap_or(false)
267    }
268
269    /// Identifiers visible at the given frame's scope — locals plus
270    /// every registered builtin + async builtin. Drives DAP
271    /// `completions` (#109) so the REPL autocomplete surfaces
272    /// everything the unified evaluator can reach.
273    pub fn identifiers_in_scope(&self, _frame_id: usize) -> Vec<String> {
274        let mut out: Vec<String> = self
275            .visible_variables()
276            .keys()
277            .map(|k| k.to_string())
278            .collect();
279        out.extend(self.builtins.keys().cloned());
280        out.extend(self.async_builtins.keys().cloned());
281        out.sort();
282        out.dedup();
283        out
284    }
285
286    /// Get all stack frames for the debugger.
287    pub fn debug_stack_frames(&self) -> Vec<(String, usize)> {
288        self.debug_stack_frames_with_sources()
289            .into_iter()
290            .map(|(name, line, _source)| (name, line))
291            .collect()
292    }
293
294    /// Get all stack frames plus their source keys for debugger clients that
295    /// can retrieve synthetic sources through DAP `source`.
296    pub fn debug_stack_frames_with_sources(&self) -> Vec<(String, usize, Option<String>)> {
297        let mut frames = Vec::new();
298        for (i, frame) in self.frames.iter().enumerate() {
299            let line = if frame.ip > 0 && frame.ip - 1 < frame.chunk.lines.len() {
300                frame.chunk.lines[frame.ip - 1] as usize
301            } else {
302                0
303            };
304            let name = if frame.fn_name.is_empty() {
305                if i == 0 {
306                    "pipeline".to_string()
307                } else {
308                    format!("fn_{i}")
309                }
310            } else {
311                frame.fn_name.clone()
312            };
313            frames.push((name, line, frame.chunk.source_file.clone()));
314        }
315        frames
316    }
317
318    /// List every source path that has a cached body the debugger can
319    /// surface — entry programs, resolved imports, and stdlib modules.
320    /// Powers DAP `loadedSources` and `modules` so the IDE can populate
321    /// the "Loaded Scripts" and "Modules" panels without guessing the
322    /// import graph.
323    pub fn debug_loaded_source_paths(&self) -> Vec<String> {
324        let mut paths: Vec<String> = self
325            .source_cache
326            .keys()
327            .map(|p| p.to_string_lossy().into_owned())
328            .collect();
329        if let Some(entry) = self.source_file.as_deref() {
330            if !paths.iter().any(|p| p == entry) {
331                paths.push(entry.to_string());
332            }
333        }
334        paths.sort();
335        paths.dedup();
336        paths
337    }
338
339    /// Return cached source text by debugger source key. This covers entry
340    /// programs, real imports that have already been read, and synthetic
341    /// sources such as stdlib modules or generated in-memory modules.
342    pub fn debug_source_for_path(&self, path: &str) -> Option<String> {
343        if self.source_file.as_deref() == Some(path) {
344            if let Some(source) = &self.source_text {
345                return Some(source.clone());
346            }
347        }
348
349        let key = std::path::PathBuf::from(path);
350        if let Some(source) = self.source_cache.get(&key) {
351            return Some(source.clone());
352        }
353
354        if let Some(module) = path
355            .strip_prefix("<stdlib>/")
356            .and_then(|s| s.strip_suffix(".harn"))
357        {
358            return crate::stdlib_modules::get_stdlib_source(module).map(str::to_string);
359        }
360
361        None
362    }
363
364    /// Get the current source line.
365    pub(crate) fn current_line(&self) -> usize {
366        if let Some(frame) = self.frames.last() {
367            let ip = if frame.ip > 0 { frame.ip - 1 } else { 0 };
368            if ip < frame.chunk.lines.len() {
369                return frame.chunk.lines[ip] as usize;
370            }
371        }
372        0
373    }
374
375    /// Execute one instruction, returning whether to stop (breakpoint/step).
376    /// Returns Ok(None) to continue, Ok(Some(val)) on program end, Err on error.
377    ///
378    /// Line-change detection reads the line of the instruction we're
379    /// *about to execute* (`lines[ip]`) rather than the byte before
380    /// `ip`. After a jump, `ip-1` still points into the skipped region,
381    /// which previously reported phantom stops on the tail of a
382    /// not-taken branch (e.g. `host_metadata_save()` highlighted even
383    /// though `any_stale` was false). Using `lines[ip]` — combined with
384    /// cleanup ops emitted at line 0 after branch/loop exits — keeps
385    /// the debugger aligned with what's actually going to run.
386    pub async fn step_execute(&mut self) -> Result<Option<(VmValue, bool)>, VmError> {
387        self.ensure_execution_available()?;
388        // Cooperative cancellation and std/signal interrupts are both
389        // observed before instruction work so debug stepping exits promptly.
390        if let Some(err) = self.pending_scope_interrupt().await {
391            return Err(err);
392        }
393        if self.is_cancel_requested() {
394            self.cancel_spawned_tasks();
395            return Err(Self::cancelled_error());
396        }
397        let current_line = self.upcoming_line();
398        let line_changed = current_line != self.last_line && current_line > 0;
399
400        if line_changed {
401            self.last_line = current_line;
402
403            let state = self.debug_state();
404            if let Some(hook) = &self.debug_hook {
405                let mut hook = hook.lock();
406                if matches!((*hook)(&state), DebugAction::Stop) {
407                    self.stopped = true;
408                    return Ok(Some((VmValue::Nil, true)));
409                }
410            }
411
412            if self.breakpoint_matches(current_line) {
413                self.stopped = true;
414                return Ok(Some((VmValue::Nil, true)));
415            }
416
417            // Function-breakpoint latch: set by push_closure_frame when
418            // the callee's name is in `function_breakpoints`. Stop with
419            // the same shape as a line BP so the DAP adapter's
420            // classify_breakpoint_hit emits a standard stopped event.
421            if self.pending_function_bp.is_some() {
422                self.stopped = true;
423                return Ok(Some((VmValue::Nil, true)));
424            }
425
426            // step_frame_depth is the deepest frame count at which a stop
427            // is acceptable. set_step_mode uses usize::MAX (any depth,
428            // step-in), set_step_over uses N (same frame or shallower),
429            // set_step_out uses N-1 (strictly shallower than where the
430            // step-out was requested).
431            if self.step_mode && self.frames.len() <= self.step_frame_depth {
432                self.step_mode = false;
433                self.stopped = true;
434                return Ok(Some((VmValue::Nil, true)));
435            }
436        }
437
438        self.stopped = false;
439        self.execute_one_cycle().await
440    }
441
442    /// Line of the instruction *about to execute* — used by the
443    /// debugger for line-change detection so the first cycle after a
444    /// jump doesn't report a stale line from the skipped region.
445    pub(crate) fn upcoming_line(&self) -> usize {
446        if let Some(frame) = self.frames.last() {
447            if frame.ip < frame.chunk.lines.len() {
448                return frame.chunk.lines[frame.ip] as usize;
449            }
450        }
451        0
452    }
453
454    /// Number of live call frames. Used by the DAP adapter to
455    /// translate stackTrace ids (1-based, innermost first) back to
456    /// the VM's 0-based outermost-first index when processing
457    /// `restartFrame`.
458    pub fn frame_count(&self) -> usize {
459        self.frames.len()
460    }
461
462    /// Rewind the given frame to its entry state so stepping resumes
463    /// from the first instruction of the function with the original
464    /// arguments re-bound. Higher frames above `frame_id` are dropped.
465    /// Returns an error if the frame has no captured `initial_env`
466    /// (scratch / evaluator frames don't) or if the id is out of range.
467    ///
468    /// Side effects already performed by the restarted frame (tool
469    /// calls, file writes, host_call round-trips) are *not* rolled
470    /// back — DAP leaves that to the adapter's discretion. The IDE
471    /// should warn on frames whose source text contains obvious
472    /// side-effectful calls before invoking restartFrame.
473    pub fn restart_frame(&mut self, frame_id: usize) -> Result<(), VmError> {
474        self.ensure_execution_available()?;
475        if frame_id >= self.frames.len() {
476            return Err(VmError::Runtime(format!(
477                "restartFrame: frame id {frame_id} out of range (have {} frames)",
478                self.frames.len()
479            )));
480        }
481        let Some(initial_env) = self.frames[frame_id].initial_env.clone() else {
482            return Err(VmError::Runtime(
483                "restartFrame: target frame was not captured for restart \
484                 (scratch / evaluator frame, or frame created before a \
485                 debugger was attached)"
486                    .into(),
487            ));
488        };
489        let initial_local_slots = self.frames[frame_id].initial_local_slots.clone();
490        // Drop every frame above the target. Each pop restores its
491        // saved_iterator_depth into `self.iterators` so iterator state
492        // unwinds consistently.
493        while self.frames.len() > frame_id + 1 {
494            let popped = self.frames.pop().expect("bounds checked above");
495            self.iterators.truncate(popped.saved_iterator_depth);
496        }
497        // Rewind the target frame.
498        let frame = self
499            .frames
500            .last_mut()
501            .expect("frame_id within bounds guarantees a frame");
502        frame.ip = 0;
503        let stack_base = frame.stack_base;
504        let saved_iter_depth = frame.saved_iterator_depth;
505        self.stack.truncate(stack_base);
506        self.iterators.truncate(saved_iter_depth);
507        if let Some(initial_local_slots) = initial_local_slots {
508            frame.local_slots = initial_local_slots;
509            frame.local_scope_depth = 0;
510        }
511        self.env = initial_env;
512        self.last_line = 0;
513        self.stopped = false;
514        Ok(())
515    }
516
517    /// Assign a new value to a named binding in the paused VM's env.
518    /// Returns the value that was actually stored (after coercion, if
519    /// the VM performed any) so the caller can echo it back to the
520    /// DAP client. Fails if the name does not resolve to a mutable
521    /// binding in any live scope.
522    ///
523    /// The provided `value_expr` goes through the unified evaluator so
524    /// callers can type expressions like `plan.tasks.len() + 1` in the
525    /// Locals inline-edit field, not just literals.
526    pub async fn set_variable_in_frame(
527        &mut self,
528        name: &str,
529        value_expr: &str,
530        frame_id: usize,
531    ) -> Result<VmValue, VmError> {
532        let value = self.evaluate_in_frame(value_expr, frame_id).await?;
533        // Debug-specific assign: bypasses the `let` immutability gate
534        // because the user is explicitly editing in the IDE, and
535        // almost every pipeline binding is `let`. The underlying
536        // binding's mutability flag is preserved so runtime behavior
537        // after the override is unchanged.
538        if !self.assign_active_local_slot(name, value.clone(), true)? {
539            self.env
540                .assign_debug(name, value.clone())
541                .map_err(|e| match e {
542                    VmError::UndefinedVariable(n) => {
543                        VmError::Runtime(format!("setVariable: '{n}' is not in the current scope"))
544                    }
545                    other => other,
546                })?;
547        }
548        Ok(value)
549    }
550
551    /// Evaluate a Harn expression against the currently paused frame's
552    /// scope and return its value. This is the single evaluation path
553    /// used by hover tips, watch expressions, conditional breakpoints,
554    /// logpoint interpolation, and `setVariable` / `setExpression`
555    /// before we had a unified evaluator there were four separate
556    /// mini-parsers, each with its own rough edges (tracked by the IDE host).
557    ///
558    /// The expression is wrapped as `let __r = (<expr>)` so arbitrary
559    /// infix chains, ternaries, and access paths parse uniformly. A
560    /// scratch `CallFrame` runs the wrapped bytecode with `saved_env`
561    /// pointing at the caller's env, so the compiled expression sees
562    /// every local in scope. When the scratch frame pops, the caller's
563    /// env is automatically restored.
564    ///
565    /// A fixed instruction budget guards against runaway expressions
566    /// (infinite loops, accidental recursion) wedging the debugger.
567    /// Side effects — including `llm_call`, `host_*`, and file mutators
568    /// — are not blocked here; callers that invoke this for read-only
569    /// surfaces (hover, watch) should reject obviously-side-effectful
570    /// expressions before calling.
571    pub async fn evaluate_in_frame(
572        &mut self,
573        expr: &str,
574        _frame_id: usize,
575    ) -> Result<VmValue, VmError> {
576        self.ensure_execution_available()?;
577        let trimmed = expr.trim();
578        if trimmed.is_empty() {
579            return Err(VmError::Runtime("evaluate: empty expression".into()));
580        }
581
582        // Wrap as a pipeline whose body *returns* the expression. The
583        // explicit `return` compiles to `push value + Op::Return`, and
584        // Op::Return's frame-exit path pushes that value onto the
585        // caller's stack — which is where we read it from below.
586        // Avoids the script-mode compile path that trails a Pop+Nil
587        // sequence after every expression statement, which would
588        // clobber the result before we could capture it.
589        let wrapped = format!("pipeline default() {{\n  return ({trimmed})\n}}\n");
590        let program = harn_parser::check_source_strict(&wrapped)
591            .map_err(|e| VmError::Runtime(format!("evaluate: parse error: {e}")))?;
592        let mut chunk = crate::compiler::Compiler::new()
593            .compile(&program)
594            .map_err(|e| VmError::Runtime(format!("evaluate: compile error: {e}")))?;
595        // Inherit the current frame's source file so any runtime error
596        // enriched with `(line N)` attributes cleanly.
597        if let Some(current) = self.frames.last() {
598            chunk.source_file = current.chunk.source_file.clone();
599        }
600
601        // Snapshot every piece of VM state the scratch frame could
602        // perturb. Evaluation MUST be transparent: step state, scope
603        // depth, iterator depth, and the line-change baseline all
604        // restore on exit so the paused session continues exactly as
605        // before the user typed an expression into the REPL.
606        self.sync_current_frame_locals_to_env();
607        let saved_stack_len = self.stack.len();
608        let saved_frame_count = self.frames.len();
609        let saved_iter_depth = self.iterators.len();
610        let saved_scope_depth = self.env.scope_depth();
611        let saved_last_line = self.last_line;
612        let saved_step_mode = self.step_mode;
613        let saved_step_frame_depth = self.step_frame_depth;
614        let saved_stopped = self.stopped;
615        let saved_env = self.env.clone();
616
617        // Disable stepping during evaluation; otherwise the debug hook
618        // would fire on every synthetic line and block the pause UI.
619        self.step_mode = false;
620        self.stopped = false;
621
622        let local_slots = Self::fresh_local_slots(&chunk);
623        let chunk = Arc::new(chunk);
624        let inline_cache_set = self.inline_cache_set_index_for_chunk(&chunk);
625        self.frames.push(CallFrame {
626            chunk,
627            inline_cache_set,
628            ip: 0,
629            stack_base: saved_stack_len,
630            saved_env,
631            // Scratch evaluator frames never accept restartFrame — the
632            // REPL/watch user expects read-only inspection semantics,
633            // not replay — so skip the clone.
634            initial_env: None,
635            initial_local_slots: None,
636            saved_iterator_depth: saved_iter_depth,
637            fn_name: "<eval>".to_string(),
638            argc: 0,
639            saved_source_dir: self.source_dir.clone(),
640            module_functions: None,
641            module_state: None,
642            local_slots,
643            local_scope_base: self.env.scope_depth().saturating_sub(1),
644            local_scope_depth: 0,
645        });
646
647        // Drive one op at a time with a fixed budget. A pure expression
648        // is typically < 20 instructions; 10k gives plenty of headroom
649        // for e.g. a list comprehension without letting a bad loop
650        // hang the debugger forever.
651        const MAX_EVAL_STEPS: usize = 10_000;
652        let mut err: Option<VmError> = None;
653        for _ in 0..MAX_EVAL_STEPS {
654            if self.frames.len() <= saved_frame_count {
655                break;
656            }
657            match self.execute_one_cycle().await {
658                Ok(_) => {
659                    if self.frames.len() <= saved_frame_count {
660                        break;
661                    }
662                }
663                Err(e) => {
664                    err = Some(e);
665                    break;
666                }
667            }
668        }
669
670        // Read the result before restoring the stack — frame exit
671        // pushes the last-computed value onto the caller's stack, so
672        // it sits at `saved_stack_len` if execution completed cleanly.
673        let result = if self.stack.len() > saved_stack_len {
674            Some(self.stack[saved_stack_len].clone())
675        } else {
676            None
677        };
678
679        // Unconditional cleanup so a mid-execution error doesn't leak
680        // scratch state into the live session.
681        self.frames.truncate(saved_frame_count);
682        self.stack.truncate(saved_stack_len);
683        self.iterators.truncate(saved_iter_depth);
684        self.env.truncate_scopes(saved_scope_depth);
685        self.last_line = saved_last_line;
686        self.step_mode = saved_step_mode;
687        self.step_frame_depth = saved_step_frame_depth;
688        self.stopped = saved_stopped;
689
690        if let Some(e) = err {
691            return Err(e);
692        }
693        result.ok_or_else(|| {
694            VmError::Runtime(
695                "evaluate: step budget exceeded before the expression produced a value".into(),
696            )
697        })
698    }
699}