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>
This commit is contained in:
Steve Biedermann 2026-11-03 20:53:43 +01:00
parent b8080fe02e
commit 19eaeb447c
21 changed files with 1357 additions and 23 deletions

View File

@ -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

View File

@ -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

View File

@ -28,3 +28,28 @@ pub struct ExecutionInspection {
pub frames: Vec<StackFrame>,
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<usize>,
}
#[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<SourceBreakpoint>,
pub stop_reason: Option<DebugStopReason>,
}

View File

@ -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};

View File

@ -26,6 +26,7 @@ pub struct ExecutionInspection {
pub source: String,
pub behavior_path: Option<String>,
pub execution: Option<runa_core::ExecutionInspection>,
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<Vec<runa_core::SourceBreakpoint>> {
self.runtime.set_breakpoints(actor, expected, lines)
}
pub fn step_source(
&mut self,
actor: ActorRef,
expected: ImplementationId,
budget: NonZeroUsize,
) -> RuntimeResult<runa_core::DebugStopReason> {
let result = self.runtime.step_source(actor, expected, budget);
self.collect_runtime_events();
result
}
pub fn edit_actor(
&mut self,
actor: ActorRef,

View File

@ -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<usize>,
},
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<SourceBreakpoint>,
},
DebugStepCompleted {
reason: DebugStopReason,
},
ExecutionInspected(ExecutionInspectionDto),
SourceChecked {
diagnostics: Vec<SourceDiagnostic>,
@ -505,6 +554,8 @@ pub struct ExecutionInspectionDto {
pub source: String,
pub behavior_path: Option<String>,
pub execution: Option<ExecutionInspection>,
#[serde(default)]
pub debugger: DebuggerSnapshot,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]

View File

@ -53,6 +53,7 @@ pub struct CompiledProgram {
unit: Arc<Unit>,
diagnostics: Vec<runa_core::SourceDiagnostic>,
source_id: rune::SourceId,
source_line_starts: Vec<usize>,
}
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<usize> {
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<usize> {
if !Arc::ptr_eq(&self.unit, &stack.unit) {
return None;
}
self.instruction_line(stack.instruction_pointer)
}
pub fn source_breakpoints(&self, lines: &[usize]) -> Vec<runa_core::SourceBreakpoint> {
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::<std::collections::BTreeSet<_>>()
.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<FeasibilityReport> {
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::<Vec<_>>(),
[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()?;

View File

@ -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<ImplementationId>,
wake: Arc<WakeFlag>,
paused_from: Option<Status>,
debugger: DebuggerSnapshot,
breakpoint_ips: BTreeSet<usize>,
skip_breakpoint: Option<usize>,
}
#[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<DebuggerSnapshot> {
Ok(self
.actors
.get(&id)
.ok_or("unknown actor")?
.debugger
.clone())
}
pub fn set_breakpoints(
&mut self,
id: ActorRef,
expected: ImplementationId,
lines: &[usize],
) -> ExperimentResult<Vec<SourceBreakpoint>> {
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<DebugStopReason> {
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<usize>,
breakpoints: &BTreeSet<usize>,
invocation: &Invocation<rune::Value>,
) -> 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();

View File

@ -65,6 +65,51 @@ impl Server {
fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> {
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),

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<void>((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/);
},
);

View File

@ -45,6 +45,7 @@ export async function run(): Promise<void> {
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<unknown>("runa.spawn", {
path: "actors/counter",
@ -339,6 +340,52 @@ async function inspectExecution(address: string): Promise<void> {
}
}
async function sourceDebugging(address: string): Promise<void> {
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<void> {
const extension = vscode.extensions.all.find(
(candidate) => candidate.packageJSON.name === "runa",

View File

@ -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",

View File

@ -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<SourceBreakpoint[]> {
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<DebugStopReason> {
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<SourceFile[]> {
const reply = await this.request({ operation: "list_sources" });
if (reply.kind !== "sources") throw new Error("Expected source listing");

View File

@ -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),

View File

@ -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,

View File

@ -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<unknown[]> | undefined;
const address = await fakeImage(t, (request, socket) => {

View File

@ -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 },