From 19eaeb447c56f5584f8bee68e13228259bfa857e Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Tue, 3 Nov 2026 20:53:43 +0100 Subject: [PATCH] feat: add actor source stepping and breakpoints Add implementation-bound line breakpoints and bounded step-into for paused active actors. Preserve invocation drafts and transactional sends, expose debugger stop reasons, and wire VS Code actor controls with exact loaded-source navigation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 + README.md | 6 +- crates/runa-core/src/execution.rs | 25 ++ crates/runa-core/src/lib.rs | 5 +- crates/runa-image-api/src/lib.rs | 22 ++ crates/runa-protocol/src/lib.rs | 53 +++- crates/runa-rune/src/lib.rs | 114 ++++++++ crates/runa-runtime/src/lib.rs | 439 +++++++++++++++++++++++++++- crates/runa/src/lib.rs | 46 +++ docs/architecture.md | 5 +- docs/protocol.md | 47 ++- docs/runtime.md | 43 ++- vscode/README.md | 44 ++- vscode/integration/debugger.test.ts | 129 ++++++++ vscode/integration/host.ts | 47 +++ vscode/package.json | 22 ++ vscode/src/client.ts | 47 +++ vscode/src/extension.ts | 96 +++++- vscode/src/protocol.ts | 81 +++++ vscode/test/client.test.ts | 18 ++ vscode/test/protocol.test.ts | 86 ++++++ 21 files changed, 1357 insertions(+), 23 deletions(-) create mode 100644 vscode/integration/debugger.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 750b69d..d591f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,11 @@ All notable changes to this project will be documented in this file. ### Added +- Actor-local, implementation-bound source breakpoints with explicit line + verification and scheduler pauses that retain active drafts and buffered sends. +- Bounded source step-into through the image API/protocol and VS Code actor + actions, with stop reasons, exact loaded-source navigation, and async/lifecycle safety. + - Read-only execution-stack inspection with exact loaded source identities, bounded nested Rune frames, UTF-16 locations, and explicit pending-await status. - VS Code **Inspect Execution Stack...** actor action, navigating frames in diff --git a/README.md b/README.md index 7340923..5c586e5 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,10 @@ The prototype uses worker-local invocation stacks and cooperative instruction slices, and actor snapshots report basic active-execution progress. Native callbacks are not preemptible. The debugger foundation exposes bounded active Rune stack frames and exact loaded source through read-only protocol inspection and -VS Code's **Inspect Execution Stack...** actor action. Stepping, breakpoints, -locals, and persistent storage remain unimplemented. +VS Code's **Inspect Execution Stack...** actor action. Actor-local source +breakpoints and bounded step-into are implemented through the protocol and VS +Code actor actions; step-over/out, locals, DAP, and persistent storage remain +unimplemented. ## Design goals diff --git a/crates/runa-core/src/execution.rs b/crates/runa-core/src/execution.rs index 01046ae..08043f5 100644 --- a/crates/runa-core/src/execution.rs +++ b/crates/runa-core/src/execution.rs @@ -28,3 +28,28 @@ pub struct ExecutionInspection { pub frames: Vec, pub frames_truncated: bool, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceBreakpoint { + /// Zero-based line in the exact installed source. Empty addresses mean unverified. + pub line: usize, + pub instruction_pointers: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DebugStopReason { + Breakpoint, + Step, + Budget, + Awaiting, + Completed, + Failed, + Removed, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DebuggerSnapshot { + pub breakpoints: Vec, + pub stop_reason: Option, +} diff --git a/crates/runa-core/src/lib.rs b/crates/runa-core/src/lib.rs index 0e94d34..05109c7 100644 --- a/crates/runa-core/src/lib.rs +++ b/crates/runa-core/src/lib.rs @@ -7,7 +7,10 @@ mod execution; mod log; mod value; pub use diagnostic::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange}; -pub use execution::{ExecutionInspection, StackFrame, StackFrameKind}; +pub use execution::{ + DebugStopReason, DebuggerSnapshot, ExecutionInspection, SourceBreakpoint, StackFrame, + StackFrameKind, +}; pub use log::{LogEntry, LogLevel}; pub use value::{ActorRef, MAX_VALUE_BYTES, MAX_VALUE_DEPTH, MAX_VALUE_NODES, Value}; diff --git a/crates/runa-image-api/src/lib.rs b/crates/runa-image-api/src/lib.rs index f5928ba..ea9438a 100644 --- a/crates/runa-image-api/src/lib.rs +++ b/crates/runa-image-api/src/lib.rs @@ -26,6 +26,7 @@ pub struct ExecutionInspection { pub source: String, pub behavior_path: Option, pub execution: Option, + pub debugger: runa_core::DebuggerSnapshot, } impl Default for ImageApi { @@ -362,9 +363,30 @@ impl ImageApi { source: artifact.original_source().to_owned(), behavior_path: self.actor_path(actor).map(str::to_owned), execution: self.runtime.inspect_execution(actor)?, + debugger: self.runtime.debugger(actor)?, }) } + pub fn set_breakpoints( + &mut self, + actor: ActorRef, + expected: ImplementationId, + lines: &[usize], + ) -> RuntimeResult> { + self.runtime.set_breakpoints(actor, expected, lines) + } + + pub fn step_source( + &mut self, + actor: ActorRef, + expected: ImplementationId, + budget: NonZeroUsize, + ) -> RuntimeResult { + let result = self.runtime.step_source(actor, expected, budget); + self.collect_runtime_events(); + result + } + pub fn edit_actor( &mut self, actor: ActorRef, diff --git a/crates/runa-protocol/src/lib.rs b/crates/runa-protocol/src/lib.rs index 094d1bc..3a0b097 100644 --- a/crates/runa-protocol/src/lib.rs +++ b/crates/runa-protocol/src/lib.rs @@ -1,8 +1,11 @@ //! Versioned, transport-neutral wire contracts for Runa clients and servers. pub use runa_core::Value; +pub use runa_core::{ + DebugStopReason, DebuggerSnapshot, ExecutionInspection, SourceBreakpoint, StackFrame, + StackFrameKind, +}; pub use runa_core::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange}; -pub use runa_core::{ExecutionInspection, StackFrame, StackFrameKind}; pub use runa_core::{LogEntry, LogLevel}; use serde::{Deserialize, Serialize}; @@ -48,6 +51,36 @@ impl Request { } match &self.command { + Command::SetBreakpoints { + actor_ref, + implementation_id, + lines, + } => { + validate_actor_ref(actor_ref)?; + if implementation_id.is_empty() + || lines.len() > 128 + || lines.iter().any(|line| *line > u32::MAX as usize) + { + return Err(invalid_request( + "expected implementation and at most 128 breakpoint lines are required", + )); + } + } + Command::StepSource { + actor_ref, + implementation_id, + instruction_budget, + } => { + validate_actor_ref(actor_ref)?; + if implementation_id.is_empty() + || *instruction_budget == 0 + || *instruction_budget > 10_000 + { + return Err(invalid_request( + "expected implementation and a step budget from 1 to 10000 are required", + )); + } + } Command::CheckSource { .. } => {} Command::RenameSource { path, target, .. } => { validate_name(path)?; @@ -175,6 +208,16 @@ impl Request { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "operation", content = "parameters", rename_all = "snake_case")] pub enum Command { + SetBreakpoints { + actor_ref: String, + implementation_id: String, + lines: Vec, + }, + StepSource { + actor_ref: String, + implementation_id: String, + instruction_budget: usize, + }, InspectExecution { actor_ref: String, }, @@ -328,6 +371,12 @@ pub struct Response { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", content = "data", rename_all = "snake_case")] pub enum Reply { + BreakpointsSet { + breakpoints: Vec, + }, + DebugStepCompleted { + reason: DebugStopReason, + }, ExecutionInspected(ExecutionInspectionDto), SourceChecked { diagnostics: Vec, @@ -505,6 +554,8 @@ pub struct ExecutionInspectionDto { pub source: String, pub behavior_path: Option, pub execution: Option, + #[serde(default)] + pub debugger: DebuggerSnapshot, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/runa-rune/src/lib.rs b/crates/runa-rune/src/lib.rs index ba8998d..a363422 100644 --- a/crates/runa-rune/src/lib.rs +++ b/crates/runa-rune/src/lib.rs @@ -53,6 +53,7 @@ pub struct CompiledProgram { unit: Arc, diagnostics: Vec, source_id: rune::SourceId, + source_line_starts: Vec, } impl CompiledProgram { @@ -148,6 +149,14 @@ impl CompiledProgram { unit: Arc::new(unit), diagnostics, source_id, + source_line_starts: std::iter::once(0) + .chain( + source + .bytes() + .enumerate() + .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset + 1)), + ) + .collect(), }) } @@ -159,6 +168,75 @@ impl CompiledProgram { self.vm().lookup_function([name]).is_ok() } + fn instruction_line(&self, ip: usize) -> Option { + let instruction = self.unit.debug_info()?.instruction_at(ip)?; + if instruction.source_id != self.source_id { + return None; + } + Some( + self.source_line_starts + .partition_point(|start| *start <= instruction.span.range().start) + .saturating_sub(1), + ) + } + + pub fn current_source_line(&self, stack: &InvocationStack) -> Option { + if !Arc::ptr_eq(&self.unit, &stack.unit) { + return None; + } + self.instruction_line(stack.instruction_pointer) + } + + pub fn source_breakpoints(&self, lines: &[usize]) -> Vec { + let mut instructions: Vec<_> = self + .unit + .debug_info() + .into_iter() + .flat_map(|debug| debug.instructions.iter()) + .map(|(ip, instruction)| { + ( + *ip, + self.instruction_line(*ip), + !instruction.labels.is_empty() + || self + .unit + .debug_info() + .is_some_and(|debug| debug.functions_rev.contains_key(ip)), + ) + }) + .collect(); + instructions.sort_unstable_by_key(|(ip, _, _)| *ip); + let mut previous = None; + let entries: Vec<_> = instructions + .into_iter() + .filter_map(|(ip, line, target)| { + let entry = if target || line != previous { + line.map(|line| (ip, line)) + } else { + None + }; + previous = line; + entry + }) + .collect(); + lines + .iter() + .copied() + .collect::>() + .into_iter() + .map(|line| { + let instruction_pointers = entries + .iter() + .filter_map(|(ip, mapped)| (*mapped == line).then_some(*ip)) + .collect(); + runa_core::SourceBreakpoint { + line, + instruction_pointers, + } + }) + .collect() + } + pub fn inspect_stack( &self, source: &str, @@ -349,6 +427,42 @@ pub fn run_feasibility() -> ExperimentResult { mod tests { use super::*; + #[test] + fn breakpoint_entries_use_exact_unicode_crlf_source_lines() -> ExperimentResult<()> { + let source = "pub fn handle() {\r\n let text = \"é😀\";\r\n\r\n native_double(text.len())\r\n}"; + let program = CompiledProgram::compile(source)?; + let breakpoints = program.source_breakpoints(&[3, 2, 1, 3, 999]); + assert_eq!( + breakpoints.iter().map(|bp| bp.line).collect::>(), + [1, 2, 3, 999] + ); + assert!( + breakpoints[1].instruction_pointers.is_empty(), + "blank line is not relocated" + ); + assert!(breakpoints[3].instruction_pointers.is_empty()); + assert!(!breakpoints[2].instruction_pointers.is_empty()); + for bp in &breakpoints { + for ip in &bp.instruction_pointers { + assert_eq!(program.instruction_line(*ip), Some(bp.line)); + let debug = program + .unit + .debug_info() + .unwrap() + .instruction_at(*ip) + .unwrap(); + assert_eq!( + diagnostics::source_range(source, debug.span) + .unwrap() + .start + .line, + bp.line + ); + } + } + Ok(()) + } + #[test] fn feasibility_probes() -> ExperimentResult<()> { let report = run_feasibility()?; diff --git a/crates/runa-runtime/src/lib.rs b/crates/runa-runtime/src/lib.rs index d0de09c..f874309 100644 --- a/crates/runa-runtime/src/lib.rs +++ b/crates/runa-runtime/src/lib.rs @@ -1,7 +1,7 @@ //! Single-worker, in-memory actor runtime. No durability or preemption guarantees. use std::cell::RefCell; -use std::collections::{BTreeMap, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::num::NonZeroUsize; use std::rc::Rc; use std::sync::Arc; @@ -13,7 +13,10 @@ use runa_actor::{ SystemMessage, }; use runa_code::CompiledArtifact; -use runa_core::{ImplementationId, LogEntry, LogLevel, Value}; +use runa_core::{ + DebugStopReason, DebuggerSnapshot, ImplementationId, LogEntry, LogLevel, SourceBreakpoint, + Value, +}; use runa_rune::{ ActorContext, ActorLogs, ExperimentResult, Invocation, Slice, value_from_rune, value_to_rune, }; @@ -43,6 +46,9 @@ struct Actor { pending_install: Option, wake: Arc, paused_from: Option, + debugger: DebuggerSnapshot, + breakpoint_ips: BTreeSet, + skip_breakpoint: Option, } #[derive(Default)] @@ -113,6 +119,9 @@ impl Runtime { pending_install: None, wake: Arc::new(WakeFlag(AtomicBool::new(true))), paused_from: None, + debugger: DebuggerSnapshot::default(), + breakpoint_ips: BTreeSet::new(), + skip_breakpoint: None, }, ); self.events.push(Event::Spawned(actor_ref)); @@ -181,6 +190,138 @@ impl Runtime { ))) } + pub fn debugger(&self, id: ActorRef) -> ExperimentResult { + Ok(self + .actors + .get(&id) + .ok_or("unknown actor")? + .debugger + .clone()) + } + + pub fn set_breakpoints( + &mut self, + id: ActorRef, + expected: ImplementationId, + lines: &[usize], + ) -> ExperimentResult> { + if lines.len() > 128 || lines.iter().any(|line| *line > u32::MAX as usize) { + return Err("at most 128 breakpoint lines in the 32-bit line range are allowed".into()); + } + let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; + if actor.snapshot.implementation != expected { + return Err("implementation conflict: loaded behavior changed".into()); + } + let artifact = &self.implementations[&expected]; + let breakpoints = artifact.program().source_breakpoints(lines); + actor.breakpoint_ips = breakpoints + .iter() + .flat_map(|bp| bp.instruction_pointers.iter().copied()) + .collect(); + actor.debugger.breakpoints = breakpoints.clone(); + Ok(breakpoints) + } + + /// Advance only an already active paused actor, retaining its draft and effects. + pub fn step_source( + &mut self, + id: ActorRef, + expected: ImplementationId, + budget: NonZeroUsize, + ) -> ExperimentResult { + if budget.get() > 10_000 { + return Err("debug step budget must not exceed 10000".into()); + } + let actor = self.actors.get(&id).ok_or("unknown actor")?; + if actor.snapshot.implementation != expected { + return Err("implementation conflict: loaded behavior changed".into()); + } + if actor.snapshot.status != Status::Paused || actor.active.is_none() { + return Err("source stepping requires a paused actor with an active invocation".into()); + } + let origin = self.inspect_execution(id)?.expect("active invocation"); + let origin_line = origin.frames[0].range.map(|range| range.start.line); + let actor = self.actors.get_mut(&id).expect("actor checked"); + let invocation = actor.active.as_ref().expect("active invocation"); + actor.skip_breakpoint = Some(invocation.progress().instruction_pointer); + actor.debugger.stop_reason = None; + let waker = Waker::from(Arc::clone(&actor.wake)); + let mut cx = Context::from_waker(&waker); + let mut reason = DebugStopReason::Budget; + for _ in 0..budget.get() { + let actor = self.actors.get_mut(&id).expect("actor checked"); + let artifact = &self.implementations[&actor.snapshot.implementation]; + let invocation = actor.active.as_mut().expect("active invocation"); + if breakpoint_hit( + actor.skip_breakpoint.take(), + &actor.breakpoint_ips, + invocation, + ) { + reason = DebugStopReason::Breakpoint; + break; + } + match invocation.poll_slice(&mut cx, NonZeroUsize::new(1).unwrap()) { + Slice::BudgetExhausted => { + if breakpoint_hit(None, &actor.breakpoint_ips, invocation) { + reason = DebugStopReason::Breakpoint; + break; + } + let stack = invocation.stack(); + if artifact + .program() + .current_source_line(&stack) + .is_some_and(|line| { + stack.call_depth != origin.call_depth || Some(line) != origin_line + }) + { + reason = DebugStopReason::Step; + break; + } + } + Slice::Awaiting => { + reason = DebugStopReason::Awaiting; + break; + } + Slice::Completed(value) => { + match self.commit(&id, value) { + Ok(()) => { + reason = if self.actors.contains_key(&id) { + DebugStopReason::Completed + } else { + DebugStopReason::Removed + } + } + Err(error) => { + self.fail(&id, error.to_string()); + reason = DebugStopReason::Failed; + } + } + break; + } + Slice::Failed(error) => { + self.fail(&id, error.to_string()); + reason = DebugStopReason::Failed; + break; + } + } + } + if let Some(actor) = self.actors.get_mut(&id) { + actor.debugger.stop_reason = Some(reason); + if !matches!(actor.snapshot.status, Status::Failed(_)) { + actor.paused_from = Some(if actor.active.is_none() { + Status::Idle + } else if reason == DebugStopReason::Awaiting { + Status::Awaiting + } else { + Status::Running + }); + actor.snapshot.status = Status::Paused; + self.events.push(Event::Paused(id)); + } + } + Ok(reason) + } + pub fn edit_actor( &mut self, id: ActorRef, @@ -290,6 +431,13 @@ impl Runtime { if let Some(status) = actor.paused_from.take() { actor.snapshot.status = status; } + if actor.debugger.stop_reason == Some(DebugStopReason::Breakpoint) { + actor.skip_breakpoint = actor + .active + .as_ref() + .map(|invocation| invocation.progress().instruction_pointer); + } + actor.debugger.stop_reason = None; Ok(()) } @@ -347,6 +495,7 @@ impl Runtime { } else { actor.pending_install = None; actor.snapshot.implementation = implementation; + clear_debugger(actor); self.events .push(Event::BehaviorInstalled(id, implementation)); } @@ -364,6 +513,7 @@ impl Runtime { } actor.paused_from = Some(actor.snapshot.status.clone()); actor.snapshot.status = Status::Paused; + actor.debugger.stop_reason = None; self.events.push(Event::Paused(id)); Ok(()) } @@ -373,6 +523,13 @@ impl Runtime { let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; let status = actor.paused_from.take().ok_or("actor is not paused")?; actor.snapshot.status = status; + if actor.debugger.stop_reason == Some(DebugStopReason::Breakpoint) { + actor.skip_breakpoint = actor + .active + .as_ref() + .map(|invocation| invocation.progress().instruction_pointer); + } + actor.debugger.stop_reason = None; self.events.push(Event::Resumed(id)); Ok(()) } @@ -391,6 +548,8 @@ impl Runtime { .take() .ok_or("active invocation has no reserved message")?; actor.active = None; + actor.debugger.stop_reason = None; + actor.skip_breakpoint = None; actor.paused_from = None; actor.context.take_effects(); actor.mailbox.push_front(Envelope { @@ -410,6 +569,8 @@ impl Runtime { .checked_add(1) .ok_or("incarnation exhausted")?; actor.active = None; + actor.debugger.stop_reason = None; + actor.skip_breakpoint = None; actor.context.take_effects(); actor.stopping = false; actor.shutdown = false; @@ -461,6 +622,7 @@ impl Runtime { let actor = self.actors.get_mut(&id).expect("actor checked above"); actor.snapshot.state = migrated_state.clone(); actor.snapshot.implementation = implementation; + clear_debugger(actor); self.events .push(Event::BehaviorInstalled(id, implementation)); if migrated_state != previous_state { @@ -477,6 +639,9 @@ impl Runtime { fn fail(&mut self, id: &ActorRef, error: String) { let actor = self.actors.get_mut(id).expect("scheduled actor exists"); actor.active = None; + actor.debugger.stop_reason = None; + actor.skip_breakpoint = None; + actor.paused_from = None; actor.context.take_effects(); actor.snapshot.status = Status::Failed(error.clone()); self.events.push(Event::Failed(*id, error)); @@ -573,6 +738,7 @@ impl Runtime { } else { if let Some(implementation) = actor.pending_install.take() { actor.snapshot.implementation = implementation; + clear_debugger(actor); self.events .push(Event::BehaviorInstalled(id, implementation)); } @@ -603,6 +769,7 @@ impl Runtime { } } actor.snapshot.status = Status::Running; + actor.debugger.stop_reason = None; } if actor.snapshot.status == Status::Awaiting && !actor.wake.0.load(Ordering::SeqCst) { continue; @@ -610,11 +777,33 @@ impl Runtime { actor.wake.0.store(false, Ordering::SeqCst); let waker = Waker::from(Arc::clone(&actor.wake)); let mut cx = Context::from_waker(&waker); - let outcome = actor - .active - .as_mut() - .expect("active invocation") - .poll_slice(&mut cx, budget); + let mut outcome = Slice::BudgetExhausted; + for _ in 0..budget.get() { + let invocation = actor.active.as_mut().expect("active invocation"); + if breakpoint_hit( + actor.skip_breakpoint.take(), + &actor.breakpoint_ips, + invocation, + ) { + actor.paused_from = Some(Status::Running); + actor.snapshot.status = Status::Paused; + actor.debugger.stop_reason = Some(DebugStopReason::Breakpoint); + self.events.push(Event::Paused(id)); + break; + } + let slice_budget = if actor.breakpoint_ips.is_empty() { + budget + } else { + NonZeroUsize::new(1).unwrap() + }; + outcome = invocation.poll_slice(&mut cx, slice_budget); + if !matches!(outcome, Slice::BudgetExhausted) || actor.breakpoint_ips.is_empty() { + break; + } + } + if actor.snapshot.status == Status::Paused { + continue; + } match outcome { Slice::Completed(transition) => { if let Err(error) = self.commit(&id, transition) { @@ -629,11 +818,247 @@ impl Runtime { } } +fn clear_debugger(actor: &mut Actor) { + actor.debugger = DebuggerSnapshot::default(); + actor.breakpoint_ips.clear(); + actor.skip_breakpoint = None; +} + +fn breakpoint_hit( + skip: Option, + breakpoints: &BTreeSet, + invocation: &Invocation, +) -> bool { + let ip = invocation.progress().instruction_pointer; + skip != Some(ip) && breakpoints.contains(&ip) && !invocation.stack().awaiting +} + #[cfg(test)] mod tests { use super::*; use runa_core::MAX_VALUE_NODES; + #[test] + fn debugger_breakpoints_preserve_transactions_and_step_only_one_actor() -> ExperimentResult<()> + { + let mut runtime = Runtime::default(); + let source = "pub fn handle(ctx, state, sender, message) {\n ctx.send(ctx.this, 99);\n let next = nested(state);\n next\n}\nfn nested(value) {\n value + 1\n}"; + let code = publish(&mut runtime, source)?; + let actor = runtime.spawn(code, Value::Integer(3), capacity(4))?; + let other = runtime.spawn(code, Value::Integer(10), capacity(4))?; + runtime.send(actor, Value::Null)?; + let verified = runtime.set_breakpoints(actor, code, &[2, 100, 2])?; + assert_eq!(verified.len(), 2); + assert!(!verified[0].instruction_pointers.is_empty()); + assert!(verified[1].instruction_pointers.is_empty()); + runtime.tick(capacity(1000)); + let before = runtime.inspect(actor)?; + assert_eq!(before.status, Status::Paused); + assert_eq!(before.state, Value::Integer(3)); + assert_eq!(before.reserved, Some(Value::Null)); + assert!( + before.queued.is_empty(), + "draft send is not visible before commit" + ); + assert_eq!( + runtime.debugger(actor)?.stop_reason, + Some(DebugStopReason::Breakpoint) + ); + assert_eq!( + runtime.inspect_execution(actor)?.unwrap().frames[0] + .range + .unwrap() + .start + .line, + 2 + ); + runtime.send(other, Value::Null)?; + let other_before = runtime.inspect(other)?; + let origin = runtime.inspect_execution(actor)?.unwrap().vm_steps; + let reason = runtime.step_source(actor, code, capacity(1000))?; + assert_eq!(reason, DebugStopReason::Step); + let stepped = runtime.inspect_execution(actor)?.unwrap(); + assert!(stepped.vm_steps > origin); + assert_eq!(stepped.frames[0].function.as_deref(), Some("nested")); + assert_eq!(runtime.inspect(actor)?.state, Value::Integer(3)); + assert_eq!(runtime.inspect(other)?.queued, other_before.queued); + assert_eq!(runtime.inspect(other)?.execution, other_before.execution); + let mut completed = false; + for _ in 0..20 { + if runtime.step_source(actor, code, capacity(1000))? == DebugStopReason::Completed { + completed = true; + break; + } + } + assert!(completed); + let after = runtime.inspect(actor)?; + assert_eq!(after.status, Status::Paused); + assert_eq!(after.state, Value::Integer(4)); + assert_eq!(after.queued, [Value::Integer(99)]); + assert!(after.reserved.is_none()); + assert!(after.execution.is_none()); + assert!(runtime.step_source(actor, code, capacity(1000)).is_err()); + runtime.resume(actor)?; + assert_eq!(runtime.inspect(actor)?.status, Status::Idle); + Ok(()) + } + + #[test] + fn debugger_resume_crosses_breakpoint_once_and_loop_can_hit_again() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let code = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) {\n let n = 0;\n loop {\n n += 1;\n }\n}", + )?; + let actor = runtime.spawn(code, Value::Null, capacity(2))?; + runtime.set_breakpoints(actor, code, &[3])?; + runtime.send(actor, Value::Null)?; + runtime.tick(capacity(100)); + let stopped = runtime.inspect_execution(actor)?.unwrap().vm_steps; + assert_eq!(runtime.inspect(actor)?.status, Status::Paused); + runtime.tick(capacity(100)); + assert_eq!(runtime.inspect_execution(actor)?.unwrap().vm_steps, stopped); + runtime.resume(actor)?; + runtime.tick(capacity(100)); + assert_eq!(runtime.inspect(actor)?.status, Status::Paused); + assert!(runtime.inspect_execution(actor)?.unwrap().vm_steps > stopped); + runtime.set_breakpoints(actor, code, &[])?; + runtime.resume(actor)?; + runtime.tick(capacity(100)); + assert_eq!(runtime.inspect(actor)?.status, Status::Running); + Ok(()) + } + + #[test] + fn debugger_bounded_steps_handle_same_line_loops_awaits_failure_and_shutdown() + -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let code = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { loop {} }", + )?; + let actor = runtime.spawn(code, Value::Null, capacity(2))?; + runtime.send(actor, Value::Null)?; + runtime.tick(capacity(1)); + runtime.pause(actor)?; + let before = runtime.inspect_execution(actor)?.unwrap().vm_steps; + assert_eq!( + runtime.step_source(actor, code, capacity(5))?, + DebugStopReason::Budget + ); + assert_eq!( + runtime.inspect_execution(actor)?.unwrap().vm_steps, + before + 5 + ); + assert_eq!(runtime.inspect(actor)?.status, Status::Paused); + assert!(runtime.step_source(actor, code, capacity(10_001)).is_err()); + let code = publish( + &mut runtime, + "pub async fn handle(ctx, state, sender, message) {\n native_suspend(7).await;\n 42\n}", + )?; + let actor = runtime.spawn(code, Value::Integer(1), capacity(2))?; + runtime.set_breakpoints(actor, code, &[1])?; + runtime.send(actor, Value::Null)?; + runtime.tick(capacity(100)); + runtime.set_breakpoints(actor, code, &[])?; + assert_eq!( + runtime.step_source(actor, code, capacity(100))?, + DebugStopReason::Awaiting + ); + assert_eq!(runtime.inspect(actor)?.status, Status::Paused); + assert!(runtime.inspect_execution(actor)?.unwrap().awaiting); + for _ in 0..20 { + if runtime.step_source(actor, code, capacity(100))? == DebugStopReason::Completed { + break; + } + } + assert_eq!(runtime.inspect(actor)?.state, Value::Integer(42)); + let code = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) {\n 1 / 0\n}", + )?; + let actor = runtime.spawn(code, Value::Null, capacity(2))?; + runtime.set_breakpoints(actor, code, &[1])?; + runtime.send(actor, Value::Null)?; + runtime.tick(capacity(100)); + runtime.set_breakpoints(actor, code, &[])?; + assert_eq!( + runtime.step_source(actor, code, capacity(100))?, + DebugStopReason::Failed + ); + assert!(matches!(runtime.inspect(actor)?.status, Status::Failed(_))); + assert_eq!(runtime.inspect(actor)?.reserved, Some(Value::Null)); + assert!(runtime.resume(actor).is_err()); + let code = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { state }\npub fn on_shutdown(state) {\n state + 1\n}", + )?; + let actor = runtime.spawn(code, Value::Integer(0), capacity(2))?; + assert!( + !runtime.set_breakpoints(actor, code, &[2])?[0] + .instruction_pointers + .is_empty() + ); + runtime.stop(actor)?; + runtime.tick(capacity(100)); + assert_eq!(runtime.inspect(actor)?.status, Status::Paused); + runtime.set_breakpoints(actor, code, &[])?; + let mut removed = false; + for _ in 0..10 { + if runtime.step_source(actor, code, capacity(100))? == DebugStopReason::Removed { + removed = true; + break; + } + } + assert!(removed); + assert!(runtime.inspect(actor).is_err()); + Ok(()) + } + + #[test] + fn debugger_configuration_is_version_bound_and_resets_only_at_install() -> ExperimentResult<()> + { + let mut runtime = Runtime::default(); + let code = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) {\n state + 1\n}", + )?; + let new = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { state + 2 }", + )?; + let actor = runtime.spawn(code, Value::Integer(0), capacity(2))?; + runtime.set_breakpoints(actor, code, &[1])?; + assert!(runtime.set_breakpoints(actor, new, &[0]).is_err()); + assert!( + runtime.step_source(actor, code, capacity(100)).is_err(), + "idle actor must not start a handler implicitly" + ); + runtime.send(actor, Value::Null)?; + runtime.tick(capacity(100)); + runtime.schedule_install(actor, new)?; + assert_eq!(runtime.debugger(actor)?.breakpoints.len(), 1); + assert!(runtime.step_source(actor, new, capacity(100)).is_err()); + runtime.restart(actor)?; + assert_eq!( + runtime.debugger(actor)?.breakpoints.len(), + 1, + "same loaded version retains breakpoints across restart" + ); + runtime.tick(capacity(100)); + assert_eq!(runtime.inspect(actor)?.status, Status::Paused); + runtime.set_breakpoints(actor, code, &[])?; + runtime.resume(actor)?; + runtime.tick(capacity(100)); + runtime.schedule_install(actor, new)?; + runtime.set_breakpoints(actor, code, &[1])?; + runtime.tick(capacity(100)); + assert_eq!(runtime.inspect(actor)?.implementation, new); + assert_eq!(runtime.debugger(actor)?, DebuggerSnapshot::default()); + assert!(runtime.set_breakpoints(actor, code, &[]).is_err()); + Ok(()) + } + #[test] fn bundled_counter_defaults_missing_state_to_zero() -> ExperimentResult<()> { let mut runtime = Runtime::default(); diff --git a/crates/runa/src/lib.rs b/crates/runa/src/lib.rs index fb9f346..b32d986 100644 --- a/crates/runa/src/lib.rs +++ b/crates/runa/src/lib.rs @@ -65,6 +65,51 @@ impl Server { fn dispatch_command(&mut self, command: Command) -> Result { match command { + Command::SetBreakpoints { + actor_ref, + implementation_id, + lines, + } => { + let actor = parse_actor_ref(&actor_ref)?; + let expected = self.image.inspect(actor).map_err(api_error)?.implementation; + if expected.to_string() != implementation_id { + return Err(ProtocolError::new( + ErrorCode::Conflict, + "loaded implementation changed", + )); + } + self.image + .set_breakpoints(actor, expected, &lines) + .map(|breakpoints| Reply::BreakpointsSet { breakpoints }) + .map_err(api_error) + } + Command::StepSource { + actor_ref, + implementation_id, + instruction_budget, + } => { + let actor = parse_actor_ref(&actor_ref)?; + let expected = self.image.inspect(actor).map_err(api_error)?.implementation; + if expected.to_string() != implementation_id { + return Err(ProtocolError::new( + ErrorCode::Conflict, + "loaded implementation changed", + )); + } + self.image + .step_source( + actor, + expected, + NonZeroUsize::new(instruction_budget).ok_or_else(|| { + ProtocolError::new( + ErrorCode::InvalidRequest, + "step budget must be positive", + ) + })?, + ) + .map(|reason| Reply::DebugStepCompleted { reason }) + .map_err(api_error) + } Command::InspectExecution { actor_ref } => self .image .inspect_execution(parse_actor_ref(&actor_ref)?) @@ -77,6 +122,7 @@ impl Server { source: inspection.source, behavior_path: inspection.behavior_path, execution: inspection.execution, + debugger: inspection.debugger, }) }) .map_err(api_error), diff --git a/docs/architecture.md b/docs/architecture.md index 37859a4..afee282 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -538,7 +538,10 @@ Debugger foundations now expose bounded head-VM stack frames and UTF-16 source locations through read-only execution inspection. VS Code navigates immutable exact-loaded-source documents, including old code during pending replacements. Await locations represent the last safe snapshot; native/hidden async frames, -locals, post-mortem stacks, stepping, breakpoints, and DAP remain unimplemented. +locals, post-mortem stacks, and DAP remain unimplemented. Actor-specific +implementation-bound line breakpoints and bounded step-into now preserve active +drafts/effects and use exact loaded-source mappings. These are command-based +controls, not gutter breakpoints; step-over/out and conditional stops remain planned. Non-destructive image-change notifications and automatic snapshot refresh are implemented over the shared `runa-debug` WebSocket transport. diff --git a/docs/protocol.md b/docs/protocol.md index 002d76c..b8c4c2f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -66,6 +66,8 @@ behavior aliases. They exclude the `source/` root and `.rn` extension. | `rollback_source` | `path`, `revision` | `source`: restored file | | `spawn` | `path`, tagged `state`, positive `mailbox_capacity` | `actor_spawned`: `{actor_ref}` | | `inspect_execution` | `actor_ref` | `execution_inspected`: loaded source and optional execution stack | +| `set_breakpoints` | `actor_ref`, `implementation_id`, `lines` | `breakpoints_set`: `{breakpoints}` | +| `step_source` | `actor_ref`, `implementation_id`, `instruction_budget` | `debug_step_completed`: `{reason}` | | `system` | `actor_ref`, `message` | `acknowledged` | A file is `{path, source_id, source}` and a commit is @@ -120,7 +122,7 @@ as modules. `inspect_execution` is an additive, read-only v5 operation. It returns `{actor_ref, incarnation, implementation_id, source_id, source, behavior_path, -execution}`. Source is the exact original text of the installed artifact, not +execution, debugger}`. Source is the exact original text of the installed artifact, not working source or a pending temporary replacement. The nullable behavior path is a logical binding, not a guarantee that a mutable file still matches the code. Idle/failed actors return `execution: null`; removed actors return `not_found`. @@ -141,9 +143,43 @@ as diagnostics and refer only to the returned loaded source. Only the head Rune VM's visible frames are captured, not native stacks or stacks hidden inside nested futures. Pending async steps are not counted as completed VM steps. Snapshots contain no VM stack values or mutable execution handles. -There is no locals inspection, post-mortem stack retention, stepping, breakpoint, -or DAP contract. Responses are point-in-time; clients must pause explicitly for -stable inspection. +There is no locals inspection, post-mortem stack retention, or DAP contract. +Responses are point-in-time; clients must pause explicitly for stable inspection. + +### Source debugger controls + +These additive v5 mutations are available to trusted clients and broadcast +`image_changed`. Both require the expected `implementation_id` display string; +a stale installed identity returns `conflict` without mutation. Pending shared +or temporary source replacements do not retarget the controls. + +`set_breakpoints` replaces the selected actor's list with up to 128 zero-based +32-bit line numbers; `[]` clears it. Returned breakpoints are sorted/deduplicated +`{line, instruction_pointers}` entries. Addresses identify mapped line entries +(line transitions, function entries, or labeled branch targets); an empty list +means the requested line is unverified. No relocation to another line occurs. +During ticks, a hit pauses that actor before execution. Resume skips that +stopped address once, not all subsequent hits. + +`step_source` requires a paused actor with an active handler/shutdown invocation, +and a positive budget no greater than 10,000. It advances only that actor, stopping +at the next mapped line or call-depth change (step-into), another breakpoint, +pending async operation, completion/failure/removal, or budget exhaustion. +`reason` is one of `step`, `breakpoint`, `budget`, `awaiting`, `completed`, +`failed`, `removed`. Failure is an executed actor transition with explicit reason +and failure text in its ordinary snapshot, not an infrastructure success fallback. +Nonfailed surviving actors remain paused, including at a completed handler +boundary; drafts/sends commit only on normal completion. Another explicit step +may poll a pending async operation once; steps do not block waiting for it. +Native calls remain cooperative, not preemptible. + +`execution_inspected` adds `debugger: {breakpoints, stop_reason}` even when +`execution` is null. Older replies may omit it; Rust defaults to an empty +configuration and no reason. `stop_reason` is nullable and uses the same reason +strings as step replies. Installation clears breakpoints; restart/cancel retain +them for unchanged loaded code and clear stop reasons. Debugger state is actor +local, shared by clients, and in-memory; it is not owned by a client connection. +There are no conditions, hit counts, step-over/out, or gutter/DAP breakpoints yet. System message shape is `{kind: "stop"|"kill"|"restart"|"pause"|"resume"|"reload"}` or `{kind: "become", path: "actors/other"}`. This is a trusted host request; @@ -155,7 +191,8 @@ Failed actors are not automatically removed. A `Stopped` event denotes removal, and `image_changed` invalidates actor lists. Restart is independent of stop/kill and retains identity, mailbox, the loaded script and state; removed actors cannot be restarted. Reload/become defer installation to -the next handler boundary. The scheduler still advances only through `tick`. +the next handler boundary. Normal scheduling advances through `tick`; the +explicit debugger step operation advances only its selected paused actor. Responses echo the request ID and carry either a typed reply or a protocol error. Events use a separate versioned envelope. Actor and source snapshots diff --git a/docs/runtime.md b/docs/runtime.md index 69936c8..26b4e51 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -17,7 +17,48 @@ frame's source. Idle/failed actors have no active stack; failures still discard the invocation, so this is not post-mortem debugging. Shutdown hooks use the same mapping. Pause retains a stable active snapshot; restart/kill discard it. Native Rust frames and hidden nested-future stacks are not inspected. -Breakpoints, source stepping, variables, and durable stack storage remain planned. +Variables and durable stack storage remain planned. + +## Source stepping and breakpoints + +The trusted image API supports actor-local breakpoints in the **installed** +implementation, independent of shared working text and pending temporary edits. +Setting breakpoints replaces the actor's list (maximum 128 zero-based lines). +The result reports sorted instruction addresses for each requested line; an +empty address list explicitly means unverified. Rune debug spans, line +transitions, function entries, and labeled control-flow targets determine line +entries; lines are never silently moved to a nearby executable location. + +Normal ticks check those addresses before execution and pause only the matching +actor. A debugger pause retains the reserved message, invocation-local draft, +and buffered sends; no transition commits early. Resume skips the stopped +address once so it can progress, but revisiting a loop breakpoint can stop again. +Other actors still receive their ordinary scheduler slices. + +`step_source` requires a paused **active** invocation and an expected loaded +implementation identity. It polls only that actor until the next mapped line +or call-depth change, a breakpoint, pending async operation, completion, failure, +or the instruction budget. It is step-into, not step-over/out. Budgets range +from 1 to 10,000 (VS Code uses 1,000); same-line loops and unmapped instructions +are bounded rather than allowed to run indefinitely. Native calls are still +not preemptible. A pending async poll returns immediately with `awaiting`; +another explicit step may poll it again. Native/hidden future stacks remain +outside source stepping. + +The actor stays paused even after successful handler completion, with normal +state/send commit and no automatic start of the next message. Resume then +restores idle/running/awaiting status as appropriate. Failure retains the failed +reservation, discards buffered effects, and cannot be resumed without repair. +Stepping a successful shutdown hook removes the actor normally. +Inspection includes debugger configuration and the latest stop reason. +Restart/cancel clear the stop reason but retain breakpoints on the same loaded +code; implementation installation clears all breakpoints rather than applying +stale line mappings. Removal discards debugger state. Steps and breakpoint +configuration changes broadcast `image_changed`. + +These are explicit actor controls, not a DAP session or VS Code gutter +breakpoints. CLI debugger commands, conditions, hit counts, step-over/out, locals, +and post-mortem stacks are not implemented. ## Actor repair and logging diff --git a/vscode/README.md b/vscode/README.md index 668cffd..e8dc5da 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -146,10 +146,45 @@ URIs. These are not source editors or temporary behavior documents. Only the head Rune VM's visible frames are available, not native Rust frames or stacks hidden inside nested futures. There are no locals, post-mortem stacks, -breakpoints, source stepping, or Debug Adapter Protocol support yet. +or Debug Adapter Protocol support yet. For automation, `runa.inspectExecution` accepts `{actor_ref: string}` and an optional zero-based frame index to navigate without the picker. +### Source breakpoints and step-into + +Use the actor context menu or Command Palette: + +1. **Set Actor Breakpoints...** accepts 1-based lines separated by commas in the + actor's **loaded source**, not a newer working/temporary buffer. It replaces + the actor's list; blank input clears it. Up to 128 lines are accepted. Lines + without executable entries are reported as unverified, never moved silently. +2. Queue a message and **Advance Scheduler**. A breakpoint pauses that actor + before a mapped line entry, preserving draft state and buffered sends. + **Inspect Execution Stack...** shows the `breakpoint` stop reason and opens + the exact loaded code. Other actors continue on ordinary ticks. +3. **Step Into Source Line** advances only a paused actor with an active + invocation, up to 1,000 instruction polls. It stops at another mapped line, + call-depth change, breakpoint, pending await, completion/failure, or budget. + It automatically opens the current frame. The Runa output channel records + the reason; budget/await/completion outcomes also produce a notification. +4. **Resume Actor** and advance the scheduler to continue normally. Resume + crosses the stopped breakpoint once; a loop can hit it again. + +An idle actor cannot be stepped: set a breakpoint, send, then tick first, or +pause an already active handler. A completed step commits normally and leaves +the actor paused at the handler boundary, without consuming another message. +Failure is surfaced with its error text; successful shutdown may remove the actor. +Same-line loops are bounded and pending awaits return immediately. +Installation clears breakpoints to prevent stale source mappings; pending edits +do not. Breakpoints belong to the actor/image, so disconnect does not clear +them. Restart on unchanged code retains them. These are command-based controls, +not editor-gutter breakpoints or an F5/debug-toolbar session. Step-over/out, +conditions, hit counts, and variables remain planned. + +For automation, `runa.setBreakpoints` accepts `{actor_ref: string}` and optional +comma-separated 1-based line text (including `""` to clear). `runa.stepSource` +accepts `{actor_ref: string}` and returns its stop reason. + For automation, the `runa.connect` command also accepts an explicit endpoint string, and `runa.inspect` accepts an object with an `actor_ref` field. **Copy Actor Reference** in the actor context menu or inline button copies the @@ -269,9 +304,10 @@ sources but not old-session actor documents. Compiler errors are normal validation results, not connection failures. Transport and compiler-service failures remain explicit notifications in the Runa output channel. -This is single-source compilation checking, not an LSP or debugger: there is no -completion, cross-file import resolution through image source paths, quick fixes, -runtime source mapping, breakpoints, or variable inspection yet. +Compilation checking is single-source, not an LSP: completion, cross-file +import resolution through image source paths, and quick fixes remain planned. +Debugger controls are described separately above; variable inspection is not +implemented. ## Development and validation diff --git a/vscode/integration/debugger.test.ts b/vscode/integration/debugger.test.ts new file mode 100644 index 0000000..da53f46 --- /dev/null +++ b/vscode/integration/debugger.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ImageClient } from "../src/client"; +import { ProtocolError } from "../src/protocol"; +import { parseValue } from "../src/values"; +import { startImage, control } from "./server"; + +test( + "actor breakpoints and source stepping preserve drafts, isolate actors and reject stale implementations", + { timeout: 10000 }, + async (t) => { + const image = await startImage(); + let changes = 0; + let onChange: (() => void) | undefined; + const client = await ImageClient.connect( + image.address, + (error) => assert.equal(error, undefined), + () => { + changes++; + onChange?.(); + }, + ); + t.after(async () => { + await client.close(); + await image.stop(); + }); + const source = + "pub fn handle(ctx, state, sender, message) {\n ctx.send(ctx.this, 99);\n let next = nested(state);\n next\n}\nfn nested(value) {\n value + 1\n}"; + const file = await client.writeSource("tests/debugger", null, source); + const actor = await client.spawn(file.path, parseValue("3")); + const other = await client.spawn(file.path, parseValue("10")); + const loaded = await client.inspectExecution(actor); + const bps = await client.setBreakpoints(actor, loaded.implementation_id, [2, 100, 2]); + assert.deepEqual( + bps.map((bp) => bp.line), + [2, 100], + ); + assert.ok(bps[0]?.instruction_pointers.length); + assert.deepEqual(bps[1]?.instruction_pointers, []); + await assert.rejects( + client.stepSource(actor, loaded.implementation_id), + /paused actor with an active invocation/, + ); + await client.send(actor, parseValue("null")); + await client.tick(1, 1000); + const paused = await client.inspectActor(actor); + const hit = await client.inspectExecution(actor); + assert.equal(paused.status.status, "paused"); + assert.equal(hit.debugger?.stop_reason, "breakpoint"); + assert.equal(hit.execution?.frames[0]?.range?.start.line, 2); + assert.deepEqual(paused.state, parseValue("3")); + assert.equal(paused.queued.length, 0); + assert.deepEqual(paused.reserved, parseValue("null")); + await client.send(other, parseValue("null")); + const otherBefore = await client.inspectActor(other); + const count = changes; + const changed = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("step did not emit image_changed")), 2000); + onChange = () => { + clearTimeout(timer); + onChange = undefined; + resolve(); + }; + }); + assert.equal(await client.stepSource(actor, hit.implementation_id), "step"); + await changed; + assert.ok(changes > count, "step changes broadcast image invalidation"); + assert.deepEqual(await client.inspectActor(other), otherBefore); + const nested = await client.inspectExecution(actor); + assert.equal(nested.execution?.frames[0]?.function, "nested"); + assert.equal(nested.debugger?.stop_reason, "step"); + assert.deepEqual((await client.inspectActor(actor)).state, parseValue("3")); + let completed = false; + for (let i = 0; i < 20; i++) { + if ((await client.stepSource(actor, hit.implementation_id)) === "completed") { + completed = true; + break; + } + } + assert.ok(completed); + const committed = await client.inspectActor(actor); + assert.equal(committed.status.status, "paused"); + assert.deepEqual(committed.state, parseValue("4")); + assert.deepEqual(committed.queued, [parseValue("99")]); + await client.setBreakpoints(actor, hit.implementation_id, []); + await client.writeSource( + file.path, + file.source_id, + "pub fn handle(ctx, state, sender, message) { 42 }", + ); + await client.system(actor, { kind: "reload" }); + assert.equal((await client.inspectExecution(actor)).source, source); + await client.system(actor, { kind: "resume" }); + await client.tick(1, 100); + assert.deepEqual((await client.inspectExecution(actor)).debugger, { + breakpoints: [], + stop_reason: null, + }); + await assert.rejects( + client.setBreakpoints(actor, hit.implementation_id, [0]), + (error) => error instanceof ProtocolError && error.code === "conflict", + ); + await assert.rejects( + client.stepSource(actor, hit.implementation_id), + (error) => error instanceof ProtocolError && error.code === "conflict", + ); + const active = await client.inspectExecution(actor); + for (const budget of [0, 10001]) { + const reply = await control(image.address, { + operation: "step_source", + parameters: { + actor_ref: actor, + implementation_id: active.implementation_id, + instruction_budget: budget, + }, + }); + assert.match(JSON.stringify(reply), /invalid_request/); + } + const invalid = await control(image.address, { + operation: "set_breakpoints", + parameters: { + actor_ref: actor, + implementation_id: active.implementation_id, + lines: Array(129).fill(0), + }, + }); + assert.match(JSON.stringify(invalid), /invalid_request/); + }, +); diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index f9f324d..989fa28 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -45,6 +45,7 @@ export async function run(): Promise { await editSources(image.address); await checkSourceDiagnostics(image.address); await inspectExecution(image.address); + await sourceDebugging(image.address); await editActorFiles(image.address); const spawned = await vscode.commands.executeCommand("runa.spawn", { path: "actors/counter", @@ -339,6 +340,52 @@ async function inspectExecution(address: string): Promise { } } +async function sourceDebugging(address: string): Promise { + const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); + try { + const source = + "pub fn handle(ctx, state, sender, message) {\n let next = nested(state);\n next\n}\nfn nested(value) {\n value + 1\n}"; + const file = await client.writeSource("tests/source_debugging", null, source); + const actor = await client.spawn(file.path, parseValue("10")); + const selected = { actor_ref: actor }; + await vscode.commands.executeCommand("runa.setBreakpoints", selected, "2"); + const bps = (await client.inspectExecution(actor)).debugger?.breakpoints; + assert.equal(bps?.[0]?.line, 1); + assert.ok(bps[0].instruction_pointers.length); + await client.send(actor, parseValue("null")); + await client.tick(1, 1000); + assert.equal((await client.inspectActor(actor)).status.status, "paused"); + assert.equal(await vscode.commands.executeCommand("runa.stepSource", selected), "step"); + const editor = vscode.window.activeTextEditor; + assert.ok(editor); + assert.equal(editor.document.uri.scheme, "runa-execution"); + assert.equal(editor.document.getText(), source); + const inspection = await client.inspectExecution(actor); + assert.equal(inspection.execution?.frames[0]?.function, "nested"); + assert.equal(editor.selection.start.line, 5); + assert.deepEqual((await client.inspectActor(actor)).state, parseValue("10")); + await vscode.commands.executeCommand("runa.setBreakpoints", selected, ""); + assert.deepEqual((await client.inspectExecution(actor)).debugger?.breakpoints, []); + let completed = false; + for (let i = 0; i < 20; i++) { + if ((await vscode.commands.executeCommand("runa.stepSource", selected)) === "completed") { + completed = true; + break; + } + } + assert.ok(completed); + const snapshot = await client.inspectActor(actor); + assert.equal(snapshot.status.status, "paused"); + assert.deepEqual(snapshot.state, parseValue("11")); + await client.system(actor, { kind: "kill" }); + console.log( + "Runa source debugger: actor breakpoints, nested step navigation, clearing and transactional completion passed", + ); + } finally { + await client.close(); + } +} + async function editSources(address: string): Promise { const extension = vscode.extensions.all.find( (candidate) => candidate.packageJSON.name === "runa", diff --git a/vscode/package.json b/vscode/package.json index 37b178e..42ee4c6 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -51,6 +51,18 @@ } ], "commands": [ + { + "command": "runa.stepSource", + "title": "Step Into Source Line", + "category": "Runa", + "enablement": "runa.connected" + }, + { + "command": "runa.setBreakpoints", + "title": "Set Actor Breakpoints...", + "category": "Runa", + "enablement": "runa.connected" + }, { "command": "runa.inspectExecution", "title": "Inspect Execution Stack...", @@ -311,6 +323,16 @@ } ], "view/item/context": [ + { + "command": "runa.stepSource", + "when": "view == runa.actors && viewItem == runa.actor && runa.connected", + "group": "debug@2" + }, + { + "command": "runa.setBreakpoints", + "when": "view == runa.actors && viewItem == runa.actor && runa.connected", + "group": "debug@3" + }, { "command": "runa.inspectExecution", "when": "view == runa.actors && viewItem == runa.actor && runa.connected", diff --git a/vscode/src/client.ts b/vscode/src/client.ts index 1dcf2e7..d83481f 100644 --- a/vscode/src/client.ts +++ b/vscode/src/client.ts @@ -5,6 +5,8 @@ import { stringify } from "lossless-json"; import { ActorSnapshot, ExecutionInspection, + SourceBreakpoint, + DebugStopReason, decodeNotification, decodeResponse, decodeValue, @@ -236,6 +238,51 @@ export class ImageClient { if (reply.kind !== "execution_inspected") throw new Error("Expected execution inspection"); return reply.data; } + + async setBreakpoints( + actorRef: string, + implementationId: string, + lines: number[], + ): Promise { + if ( + !isActorRef(actorRef) || + !implementationId || + lines.length > 128 || + lines.some((line) => !Number.isSafeInteger(line) || line < 0 || line > 4294967295) + ) + throw new Error("Invalid actor, implementation or breakpoint lines"); + const reply = await this.request({ + operation: "set_breakpoints", + parameters: { actor_ref: actorRef, implementation_id: implementationId, lines }, + }); + if (reply.kind !== "breakpoints_set") throw new Error("Expected breakpoint verification"); + return reply.data.breakpoints; + } + + async stepSource( + actorRef: string, + implementationId: string, + budget = 1000, + ): Promise { + if ( + !isActorRef(actorRef) || + !implementationId || + !Number.isSafeInteger(budget) || + budget < 1 || + budget > 10000 + ) + throw new Error("Invalid actor, implementation or source step budget"); + const reply = await this.request({ + operation: "step_source", + parameters: { + actor_ref: actorRef, + implementation_id: implementationId, + instruction_budget: budget, + }, + }); + if (reply.kind !== "debug_step_completed") throw new Error("Expected source step result"); + return reply.data.reason; + } async listSources(): Promise { const reply = await this.request({ operation: "list_sources" }); if (reply.kind !== "sources") throw new Error("Expected source listing"); diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index cabed90..5fe9d34 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -368,7 +368,7 @@ export function activate(context: vscode.ExtensionContext): { const chosen = frameIndex === undefined ? await vscode.window.showQuickPick(choices, { - title: `@${actorRef} / incarnation ${inspection.incarnation} / ${execution.vm_steps} completed steps`, + title: `@${actorRef} / incarnation ${inspection.incarnation} / ${execution.vm_steps} completed steps${inspection.debugger?.stop_reason ? ` / ${inspection.debugger.stop_reason}` : ""}`, placeHolder: execution.frames_truncated ? "Choose a frame (innermost 64 shown; stack truncated)" : "Choose a frame to open its exact loaded source", @@ -387,6 +387,98 @@ export function activate(context: vscode.ExtensionContext): { ); }; + const setBreakpoints = async (selected?: { actor_ref: string }, input?: string) => { + const connection = active(); + const actorRef = await chooseActor(selected); + if (!actorRef) return; + connection.check(); + const inspection = await connection.client.inspectExecution(actorRef); + connection.check(); + const parseLines = (value: string) => { + if (!value.trim()) return []; + const parts = value.split(",").map((part) => part.trim()); + if ( + parts.length > 128 || + parts.some( + (part) => + !/^[1-9]\d*$/.test(part) || + !Number.isSafeInteger(Number(part)) || + Number(part) > 4294967296, + ) + ) + throw new Error( + "Enter at most 128 positive line numbers separated by commas, or leave empty to clear.", + ); + return parts.map((part) => Number(part) - 1); + }; + const value = + input ?? + (await vscode.window.showInputBox({ + title: `Breakpoints in loaded source for @${actorRef}`, + prompt: + "1-based lines, separated by commas. Blank clears all. Applies only to this actor's loaded implementation.", + value: inspection.debugger?.breakpoints.map((bp) => bp.line + 1).join(", ") ?? "", + validateInput: (value) => { + try { + parseLines(value); + return undefined; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + })); + if (value === undefined) return; + connection.check(); + const breakpoints = await connection.client.setBreakpoints( + actorRef, + inspection.implementation_id, + parseLines(value), + ); + connection.check(); + const unverified = breakpoints.filter((bp) => !bp.instruction_pointers.length); + if (unverified.length) + void vscode.window.showWarningMessage( + `Runa: no executable line entry at ${unverified.map((bp) => bp.line + 1).join(", ")} in the loaded source. Those breakpoints are unverified.`, + ); + else + void vscode.window.showInformationMessage( + `Runa: ${breakpoints.length} breakpoint line(s) set for @${actorRef}. Advance the scheduler to run.`, + ); + return breakpoints; + }; + + const stepSource = async (selected?: { actor_ref: string }) => { + const connection = active(); + const actorRef = await chooseActor(selected); + if (!actorRef) return; + connection.check(); + const inspection = await connection.client.inspectExecution(actorRef); + connection.check(); + const reason = await connection.client.stepSource(actorRef, inspection.implementation_id); + connection.check(); + await refresh(); + connection.check(); + output.appendLine(`Source step @${actorRef}: ${reason}`); + if ( + reason === "step" || + reason === "breakpoint" || + reason === "budget" || + reason === "awaiting" + ) { + await inspectExecution({ actor_ref: actorRef }, 0); + } + if (reason === "failed") { + const snapshot = await connection.client.inspectActor(actorRef); + connection.check(); + throw new Error(`Source step failed: ${snapshot.status.message ?? "actor failed"}`); + } + if (reason !== "step") + void vscode.window.showInformationMessage( + `Runa: source step stopped (${reason}). ${reason === "budget" ? "Instruction budget reached; actor remains paused." : reason === "awaiting" ? "Pending async operation; actor remains paused." : ""}`, + ); + return reason; + }; + const jsonInput = (title: string, value?: string) => vscode.window.showInputBox({ title, @@ -846,6 +938,8 @@ export function activate(context: vscode.ExtensionContext): { command("runa.refresh", refresh), command("runa.inspect", inspect), command("runa.inspectExecution", inspectExecution), + command("runa.setBreakpoints", setBreakpoints), + command("runa.stepSource", stepSource), command("runa.editBehavior", editBehavior), command("runa.goToBehavior", goToBehavior), command("runa.updateBehavior", updateBehavior), diff --git a/vscode/src/protocol.ts b/vscode/src/protocol.ts index 7e87731..51dfe1c 100644 --- a/vscode/src/protocol.ts +++ b/vscode/src/protocol.ts @@ -103,9 +103,37 @@ export interface ExecutionInspection { frames: StackFrame[]; frames_truncated: boolean; } | null; + debugger?: DebuggerSnapshot; +} + +export const DEBUG_STOP_REASONS = [ + "breakpoint", + "step", + "budget", + "awaiting", + "completed", + "failed", + "removed", +] as const; +export type DebugStopReason = (typeof DEBUG_STOP_REASONS)[number]; +export interface SourceBreakpoint { + line: number; + instruction_pointers: bigint[]; +} +export interface DebuggerSnapshot { + breakpoints: SourceBreakpoint[]; + stop_reason: DebugStopReason | null; } export type Command = + | { + operation: "set_breakpoints"; + parameters: { actor_ref: string; implementation_id: string; lines: number[] }; + } + | { + operation: "step_source"; + parameters: { actor_ref: string; implementation_id: string; instruction_budget: number }; + } | { operation: "inspect_execution"; parameters: { actor_ref: string } } | { operation: "check_source"; parameters: { source: string } } | { operation: "actor_behavior"; parameters: { actor_ref: string } } @@ -369,6 +397,8 @@ function decodeSourceRange(input: unknown): SourceDiagnostic["range"] { } export type Reply = + | { kind: "breakpoints_set"; data: { breakpoints: SourceBreakpoint[] } } + | { kind: "debug_step_completed"; data: { reason: DebugStopReason } } | { kind: "execution_inspected"; data: ExecutionInspection } | { kind: "source_checked"; data: { diagnostics: SourceDiagnostic[] } } | { kind: "actor_behavior"; data: { source: string; source_id: string } } @@ -438,6 +468,21 @@ export function decodeResponse(line: string, requestId: string, command: Command ); } const reply = object(result.Ok); + if (command.operation === "set_breakpoints" && reply.kind === "breakpoints_set") { + const breakpoints = decodeBreakpoints(object(reply.data).breakpoints); + const requested = [...new Set(command.parameters.lines)].sort((a, b) => a - b); + if ( + breakpoints.length !== requested.length || + breakpoints.some((bp, index) => bp.line !== requested[index]) + ) + throw new Error("Breakpoint lines mismatch"); + return { kind: "breakpoints_set", data: { breakpoints } }; + } + if (command.operation === "step_source" && reply.kind === "debug_step_completed") + return { + kind: "debug_step_completed", + data: { reason: decodeDebugStopReason(object(reply.data).reason) }, + }; if (command.operation === "inspect_execution" && reply.kind === "execution_inspected") { const data = object(reply.data); if (data.actor_ref !== command.parameters.actor_ref || !isActorRef(data.actor_ref)) @@ -491,6 +536,7 @@ export function decodeResponse(line: string, requestId: string, command: Command source: text(data.source), behavior_path: data.behavior_path, execution, + ...(data.debugger === undefined ? {} : { debugger: decodeDebugger(data.debugger) }), }, }; } @@ -620,6 +666,41 @@ export function decodeResponse(line: string, requestId: string, command: Command throw new Error("Unexpected reply kind"); } +function decodeDebugStopReason(input: unknown): DebugStopReason { + const reason = DEBUG_STOP_REASONS.find((reason) => reason === input); + if (!reason) throw new Error("Invalid debugger stop reason"); + return reason; +} + +function decodeBreakpoints(input: unknown): SourceBreakpoint[] { + const breakpoints = array(input).map((input) => { + const bp = object(input), + line = uint(bp.line); + if (line > BigInt(Number.MAX_SAFE_INTEGER)) + throw new Error("Breakpoint line exceeds editor limits"); + const instruction_pointers = array(bp.instruction_pointers).map(uint); + if ( + instruction_pointers.some((ip, index) => index > 0 && ip <= instruction_pointers[index - 1]!) + ) + throw new Error("Breakpoint addresses must be ordered and unique"); + return { line: Number(line), instruction_pointers }; + }); + if ( + breakpoints.length > 128 || + breakpoints.some((bp, index) => index > 0 && bp.line <= breakpoints[index - 1]!.line) + ) + throw new Error("Breakpoint lines must be ordered, unique and bounded"); + return breakpoints; +} + +function decodeDebugger(input: unknown): DebuggerSnapshot { + const value = object(input); + return { + breakpoints: decodeBreakpoints(value.breakpoints), + stop_reason: value.stop_reason === null ? null : decodeDebugStopReason(value.stop_reason), + }; +} + export function formatSnapshot( actor: ActorSnapshot, endpoint: string, diff --git a/vscode/test/client.test.ts b/vscode/test/client.test.ts index 4170e26..353e6cf 100644 --- a/vscode/test/client.test.ts +++ b/vscode/test/client.test.ts @@ -25,6 +25,24 @@ test("only numeric loopback endpoints and valid ports are accepted", () => { } }); +test("debugger arguments reject invalid budgets, identities and line lists before transport", async (t) => { + let requests = 0; + const address = await fakeImage(t, (request, socket) => { + requests++; + socket.send(response(request, { kind: "actors", data: [] })); + }); + const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); + t.after(() => client.close()); + for (const budget of [0, -1, 10001, 1.5, NaN]) + await assert.rejects(client.stepSource(REF, "impl", budget), /Invalid actor/); + for (const lines of [[-1], [0.5], [4294967296], Array(129).fill(0), [NaN]]) + await assert.rejects(client.setBreakpoints(REF, "impl", lines), /Invalid actor/); + await assert.rejects(client.setBreakpoints(REF, "", []), /Invalid actor/); + await assert.rejects(client.stepSource("bad", "impl"), /Invalid actor/); + assert.equal(requests, 0); + assert.deepEqual(await client.listActors(), []); +}); + test("normal disconnect completes a code-1000 close handshake and rejects further work", async (t) => { let closed: Promise | undefined; const address = await fakeImage(t, (request, socket) => { diff --git a/vscode/test/protocol.test.ts b/vscode/test/protocol.test.ts index 15ccd89..79f7089 100644 --- a/vscode/test/protocol.test.ts +++ b/vscode/test/protocol.test.ts @@ -19,6 +19,67 @@ const command: Command = { operation: "list_actors" }; const envelope = (reply: unknown) => json({ version: 5, request_id: "test", result: { Ok: reply } }); +test("debugger replies validate breakpoint verification and explicit stop reasons", () => { + const command: Command = { + operation: "set_breakpoints", + parameters: { actor_ref: REF, implementation_id: "impl", lines: [3, 1, 3] }, + }; + const breakpoints = [ + { line: 1n, instruction_pointers: [7n, 18446744073709551615n] }, + { line: 3n, instruction_pointers: [] }, + ]; + const reply = decodeResponse( + envelope({ kind: "breakpoints_set", data: { breakpoints } }), + "test", + command, + ); + assert.deepEqual(reply, { + kind: "breakpoints_set", + data: { + breakpoints: [ + { line: 1, instruction_pointers: breakpoints[0]!.instruction_pointers }, + { line: 3, instruction_pointers: [] }, + ], + }, + }); + for (const value of [ + [], + [...breakpoints].reverse(), + [breakpoints[0], breakpoints[0]], + [{ line: 1n, instruction_pointers: [2n, 2n] }, breakpoints[1]], + [{ line: 1n, instruction_pointers: [-1n] }, breakpoints[1]], + [{ line: 1n, instruction_pointers: [3n, 2n] }, breakpoints[1]], + ]) + assert.throws(() => + decodeResponse( + envelope({ kind: "breakpoints_set", data: { breakpoints: value } }), + "test", + command, + ), + ); + const step: Command = { + operation: "step_source", + parameters: { actor_ref: REF, implementation_id: "impl", instruction_budget: 1000 }, + }; + for (const reason of [ + "breakpoint", + "step", + "budget", + "awaiting", + "completed", + "failed", + "removed", + ]) + assert.deepEqual( + decodeResponse(envelope({ kind: "debug_step_completed", data: { reason } }), "test", step), + { kind: "debug_step_completed", data: { reason } }, + ); + for (const reason of [null, 1, "success", "paused"]) + assert.throws(() => + decodeResponse(envelope({ kind: "debug_step_completed", data: { reason } }), "test", step), + ); +}); + test("execution inspections preserve u64 fields and validate stack order, bounds, identities and ranges", () => { const command: Command = { operation: "inspect_execution", parameters: { actor_ref: REF } }; const frame = { @@ -48,6 +109,31 @@ test("execution inspections preserve u64 fields and validate stack order, bounds assert.equal(decoded.kind, "execution_inspected"); if (decoded.kind !== "execution_inspected") throw new Error("unexpected reply"); assert.equal(decoded.data.incarnation, data.incarnation); + const debuggerState = { + breakpoints: [{ line: 0n, instruction_pointers: [1n] }], + stop_reason: "breakpoint", + }; + const withDebugger = decodeResponse( + envelope({ kind: "execution_inspected", data: { ...data, debugger: debuggerState } }), + "test", + command, + ); + assert.equal(withDebugger.kind, "execution_inspected"); + if (withDebugger.kind === "execution_inspected") + assert.deepEqual(withDebugger.data.debugger, { + ...debuggerState, + breakpoints: [{ line: 0, instruction_pointers: [1n] }], + }); + assert.throws(() => + decodeResponse( + envelope({ + kind: "execution_inspected", + data: { ...data, debugger: { ...debuggerState, stop_reason: "invalid" } }, + }), + "test", + command, + ), + ); assert.deepEqual(decoded.data.execution?.frames[0]?.range, { start: { line: 1, character: 2 }, end: { line: 1, character: 5 },