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:
parent
b8080fe02e
commit
19eaeb447c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
},
|
||||
);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in New Issue