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
|
### 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,
|
- Read-only execution-stack inspection with exact loaded source identities,
|
||||||
bounded nested Rune frames, UTF-16 locations, and explicit pending-await status.
|
bounded nested Rune frames, UTF-16 locations, and explicit pending-await status.
|
||||||
- VS Code **Inspect Execution Stack...** actor action, navigating frames in
|
- 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
|
slices, and actor snapshots report basic active-execution progress. Native
|
||||||
callbacks are not preemptible. The debugger foundation exposes bounded active Rune
|
callbacks are not preemptible. The debugger foundation exposes bounded active Rune
|
||||||
stack frames and exact loaded source through read-only protocol inspection and
|
stack frames and exact loaded source through read-only protocol inspection and
|
||||||
VS Code's **Inspect Execution Stack...** actor action. Stepping, breakpoints,
|
VS Code's **Inspect Execution Stack...** actor action. Actor-local source
|
||||||
locals, and persistent storage remain unimplemented.
|
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
|
## Design goals
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,28 @@ pub struct ExecutionInspection {
|
||||||
pub frames: Vec<StackFrame>,
|
pub frames: Vec<StackFrame>,
|
||||||
pub frames_truncated: bool,
|
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 log;
|
||||||
mod value;
|
mod value;
|
||||||
pub use diagnostic::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
|
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 log::{LogEntry, LogLevel};
|
||||||
pub use value::{ActorRef, MAX_VALUE_BYTES, MAX_VALUE_DEPTH, MAX_VALUE_NODES, Value};
|
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 source: String,
|
||||||
pub behavior_path: Option<String>,
|
pub behavior_path: Option<String>,
|
||||||
pub execution: Option<runa_core::ExecutionInspection>,
|
pub execution: Option<runa_core::ExecutionInspection>,
|
||||||
|
pub debugger: runa_core::DebuggerSnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ImageApi {
|
impl Default for ImageApi {
|
||||||
|
|
@ -362,9 +363,30 @@ impl ImageApi {
|
||||||
source: artifact.original_source().to_owned(),
|
source: artifact.original_source().to_owned(),
|
||||||
behavior_path: self.actor_path(actor).map(str::to_owned),
|
behavior_path: self.actor_path(actor).map(str::to_owned),
|
||||||
execution: self.runtime.inspect_execution(actor)?,
|
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(
|
pub fn edit_actor(
|
||||||
&mut self,
|
&mut self,
|
||||||
actor: ActorRef,
|
actor: ActorRef,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
//! Versioned, transport-neutral wire contracts for Runa clients and servers.
|
//! Versioned, transport-neutral wire contracts for Runa clients and servers.
|
||||||
|
|
||||||
pub use runa_core::Value;
|
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::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
|
||||||
pub use runa_core::{ExecutionInspection, StackFrame, StackFrameKind};
|
|
||||||
pub use runa_core::{LogEntry, LogLevel};
|
pub use runa_core::{LogEntry, LogLevel};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
@ -48,6 +51,36 @@ impl Request {
|
||||||
}
|
}
|
||||||
|
|
||||||
match &self.command {
|
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::CheckSource { .. } => {}
|
||||||
Command::RenameSource { path, target, .. } => {
|
Command::RenameSource { path, target, .. } => {
|
||||||
validate_name(path)?;
|
validate_name(path)?;
|
||||||
|
|
@ -175,6 +208,16 @@ impl Request {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "operation", content = "parameters", rename_all = "snake_case")]
|
#[serde(tag = "operation", content = "parameters", rename_all = "snake_case")]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
|
SetBreakpoints {
|
||||||
|
actor_ref: String,
|
||||||
|
implementation_id: String,
|
||||||
|
lines: Vec<usize>,
|
||||||
|
},
|
||||||
|
StepSource {
|
||||||
|
actor_ref: String,
|
||||||
|
implementation_id: String,
|
||||||
|
instruction_budget: usize,
|
||||||
|
},
|
||||||
InspectExecution {
|
InspectExecution {
|
||||||
actor_ref: String,
|
actor_ref: String,
|
||||||
},
|
},
|
||||||
|
|
@ -328,6 +371,12 @@ pub struct Response {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
|
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
|
||||||
pub enum Reply {
|
pub enum Reply {
|
||||||
|
BreakpointsSet {
|
||||||
|
breakpoints: Vec<SourceBreakpoint>,
|
||||||
|
},
|
||||||
|
DebugStepCompleted {
|
||||||
|
reason: DebugStopReason,
|
||||||
|
},
|
||||||
ExecutionInspected(ExecutionInspectionDto),
|
ExecutionInspected(ExecutionInspectionDto),
|
||||||
SourceChecked {
|
SourceChecked {
|
||||||
diagnostics: Vec<SourceDiagnostic>,
|
diagnostics: Vec<SourceDiagnostic>,
|
||||||
|
|
@ -505,6 +554,8 @@ pub struct ExecutionInspectionDto {
|
||||||
pub source: String,
|
pub source: String,
|
||||||
pub behavior_path: Option<String>,
|
pub behavior_path: Option<String>,
|
||||||
pub execution: Option<ExecutionInspection>,
|
pub execution: Option<ExecutionInspection>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub debugger: DebuggerSnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ pub struct CompiledProgram {
|
||||||
unit: Arc<Unit>,
|
unit: Arc<Unit>,
|
||||||
diagnostics: Vec<runa_core::SourceDiagnostic>,
|
diagnostics: Vec<runa_core::SourceDiagnostic>,
|
||||||
source_id: rune::SourceId,
|
source_id: rune::SourceId,
|
||||||
|
source_line_starts: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CompiledProgram {
|
impl CompiledProgram {
|
||||||
|
|
@ -148,6 +149,14 @@ impl CompiledProgram {
|
||||||
unit: Arc::new(unit),
|
unit: Arc::new(unit),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
source_id,
|
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()
|
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(
|
pub fn inspect_stack(
|
||||||
&self,
|
&self,
|
||||||
source: &str,
|
source: &str,
|
||||||
|
|
@ -349,6 +427,42 @@ pub fn run_feasibility() -> ExperimentResult<FeasibilityReport> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn feasibility_probes() -> ExperimentResult<()> {
|
fn feasibility_probes() -> ExperimentResult<()> {
|
||||||
let report = run_feasibility()?;
|
let report = run_feasibility()?;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Single-worker, in-memory actor runtime. No durability or preemption guarantees.
|
//! Single-worker, in-memory actor runtime. No durability or preemption guarantees.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -13,7 +13,10 @@ use runa_actor::{
|
||||||
SystemMessage,
|
SystemMessage,
|
||||||
};
|
};
|
||||||
use runa_code::CompiledArtifact;
|
use runa_code::CompiledArtifact;
|
||||||
use runa_core::{ImplementationId, LogEntry, LogLevel, Value};
|
use runa_core::{
|
||||||
|
DebugStopReason, DebuggerSnapshot, ImplementationId, LogEntry, LogLevel, SourceBreakpoint,
|
||||||
|
Value,
|
||||||
|
};
|
||||||
use runa_rune::{
|
use runa_rune::{
|
||||||
ActorContext, ActorLogs, ExperimentResult, Invocation, Slice, value_from_rune, value_to_rune,
|
ActorContext, ActorLogs, ExperimentResult, Invocation, Slice, value_from_rune, value_to_rune,
|
||||||
};
|
};
|
||||||
|
|
@ -43,6 +46,9 @@ struct Actor {
|
||||||
pending_install: Option<ImplementationId>,
|
pending_install: Option<ImplementationId>,
|
||||||
wake: Arc<WakeFlag>,
|
wake: Arc<WakeFlag>,
|
||||||
paused_from: Option<Status>,
|
paused_from: Option<Status>,
|
||||||
|
debugger: DebuggerSnapshot,
|
||||||
|
breakpoint_ips: BTreeSet<usize>,
|
||||||
|
skip_breakpoint: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
|
@ -113,6 +119,9 @@ impl Runtime {
|
||||||
pending_install: None,
|
pending_install: None,
|
||||||
wake: Arc::new(WakeFlag(AtomicBool::new(true))),
|
wake: Arc::new(WakeFlag(AtomicBool::new(true))),
|
||||||
paused_from: None,
|
paused_from: None,
|
||||||
|
debugger: DebuggerSnapshot::default(),
|
||||||
|
breakpoint_ips: BTreeSet::new(),
|
||||||
|
skip_breakpoint: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
self.events.push(Event::Spawned(actor_ref));
|
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(
|
pub fn edit_actor(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: ActorRef,
|
id: ActorRef,
|
||||||
|
|
@ -290,6 +431,13 @@ impl Runtime {
|
||||||
if let Some(status) = actor.paused_from.take() {
|
if let Some(status) = actor.paused_from.take() {
|
||||||
actor.snapshot.status = status;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,6 +495,7 @@ impl Runtime {
|
||||||
} else {
|
} else {
|
||||||
actor.pending_install = None;
|
actor.pending_install = None;
|
||||||
actor.snapshot.implementation = implementation;
|
actor.snapshot.implementation = implementation;
|
||||||
|
clear_debugger(actor);
|
||||||
self.events
|
self.events
|
||||||
.push(Event::BehaviorInstalled(id, implementation));
|
.push(Event::BehaviorInstalled(id, implementation));
|
||||||
}
|
}
|
||||||
|
|
@ -364,6 +513,7 @@ impl Runtime {
|
||||||
}
|
}
|
||||||
actor.paused_from = Some(actor.snapshot.status.clone());
|
actor.paused_from = Some(actor.snapshot.status.clone());
|
||||||
actor.snapshot.status = Status::Paused;
|
actor.snapshot.status = Status::Paused;
|
||||||
|
actor.debugger.stop_reason = None;
|
||||||
self.events.push(Event::Paused(id));
|
self.events.push(Event::Paused(id));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -373,6 +523,13 @@ impl Runtime {
|
||||||
let actor = self.actors.get_mut(&id).ok_or("unknown actor")?;
|
let actor = self.actors.get_mut(&id).ok_or("unknown actor")?;
|
||||||
let status = actor.paused_from.take().ok_or("actor is not paused")?;
|
let status = actor.paused_from.take().ok_or("actor is not paused")?;
|
||||||
actor.snapshot.status = status;
|
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));
|
self.events.push(Event::Resumed(id));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -391,6 +548,8 @@ impl Runtime {
|
||||||
.take()
|
.take()
|
||||||
.ok_or("active invocation has no reserved message")?;
|
.ok_or("active invocation has no reserved message")?;
|
||||||
actor.active = None;
|
actor.active = None;
|
||||||
|
actor.debugger.stop_reason = None;
|
||||||
|
actor.skip_breakpoint = None;
|
||||||
actor.paused_from = None;
|
actor.paused_from = None;
|
||||||
actor.context.take_effects();
|
actor.context.take_effects();
|
||||||
actor.mailbox.push_front(Envelope {
|
actor.mailbox.push_front(Envelope {
|
||||||
|
|
@ -410,6 +569,8 @@ impl Runtime {
|
||||||
.checked_add(1)
|
.checked_add(1)
|
||||||
.ok_or("incarnation exhausted")?;
|
.ok_or("incarnation exhausted")?;
|
||||||
actor.active = None;
|
actor.active = None;
|
||||||
|
actor.debugger.stop_reason = None;
|
||||||
|
actor.skip_breakpoint = None;
|
||||||
actor.context.take_effects();
|
actor.context.take_effects();
|
||||||
actor.stopping = false;
|
actor.stopping = false;
|
||||||
actor.shutdown = false;
|
actor.shutdown = false;
|
||||||
|
|
@ -461,6 +622,7 @@ impl Runtime {
|
||||||
let actor = self.actors.get_mut(&id).expect("actor checked above");
|
let actor = self.actors.get_mut(&id).expect("actor checked above");
|
||||||
actor.snapshot.state = migrated_state.clone();
|
actor.snapshot.state = migrated_state.clone();
|
||||||
actor.snapshot.implementation = implementation;
|
actor.snapshot.implementation = implementation;
|
||||||
|
clear_debugger(actor);
|
||||||
self.events
|
self.events
|
||||||
.push(Event::BehaviorInstalled(id, implementation));
|
.push(Event::BehaviorInstalled(id, implementation));
|
||||||
if migrated_state != previous_state {
|
if migrated_state != previous_state {
|
||||||
|
|
@ -477,6 +639,9 @@ impl Runtime {
|
||||||
fn fail(&mut self, id: &ActorRef, error: String) {
|
fn fail(&mut self, id: &ActorRef, error: String) {
|
||||||
let actor = self.actors.get_mut(id).expect("scheduled actor exists");
|
let actor = self.actors.get_mut(id).expect("scheduled actor exists");
|
||||||
actor.active = None;
|
actor.active = None;
|
||||||
|
actor.debugger.stop_reason = None;
|
||||||
|
actor.skip_breakpoint = None;
|
||||||
|
actor.paused_from = None;
|
||||||
actor.context.take_effects();
|
actor.context.take_effects();
|
||||||
actor.snapshot.status = Status::Failed(error.clone());
|
actor.snapshot.status = Status::Failed(error.clone());
|
||||||
self.events.push(Event::Failed(*id, error));
|
self.events.push(Event::Failed(*id, error));
|
||||||
|
|
@ -573,6 +738,7 @@ impl Runtime {
|
||||||
} else {
|
} else {
|
||||||
if let Some(implementation) = actor.pending_install.take() {
|
if let Some(implementation) = actor.pending_install.take() {
|
||||||
actor.snapshot.implementation = implementation;
|
actor.snapshot.implementation = implementation;
|
||||||
|
clear_debugger(actor);
|
||||||
self.events
|
self.events
|
||||||
.push(Event::BehaviorInstalled(id, implementation));
|
.push(Event::BehaviorInstalled(id, implementation));
|
||||||
}
|
}
|
||||||
|
|
@ -603,6 +769,7 @@ impl Runtime {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
actor.snapshot.status = Status::Running;
|
actor.snapshot.status = Status::Running;
|
||||||
|
actor.debugger.stop_reason = None;
|
||||||
}
|
}
|
||||||
if actor.snapshot.status == Status::Awaiting && !actor.wake.0.load(Ordering::SeqCst) {
|
if actor.snapshot.status == Status::Awaiting && !actor.wake.0.load(Ordering::SeqCst) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -610,11 +777,33 @@ impl Runtime {
|
||||||
actor.wake.0.store(false, Ordering::SeqCst);
|
actor.wake.0.store(false, Ordering::SeqCst);
|
||||||
let waker = Waker::from(Arc::clone(&actor.wake));
|
let waker = Waker::from(Arc::clone(&actor.wake));
|
||||||
let mut cx = Context::from_waker(&waker);
|
let mut cx = Context::from_waker(&waker);
|
||||||
let outcome = actor
|
let mut outcome = Slice::BudgetExhausted;
|
||||||
.active
|
for _ in 0..budget.get() {
|
||||||
.as_mut()
|
let invocation = actor.active.as_mut().expect("active invocation");
|
||||||
.expect("active invocation")
|
if breakpoint_hit(
|
||||||
.poll_slice(&mut cx, budget);
|
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 {
|
match outcome {
|
||||||
Slice::Completed(transition) => {
|
Slice::Completed(transition) => {
|
||||||
if let Err(error) = self.commit(&id, 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use runa_core::MAX_VALUE_NODES;
|
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]
|
#[test]
|
||||||
fn bundled_counter_defaults_missing_state_to_zero() -> ExperimentResult<()> {
|
fn bundled_counter_defaults_missing_state_to_zero() -> ExperimentResult<()> {
|
||||||
let mut runtime = Runtime::default();
|
let mut runtime = Runtime::default();
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,51 @@ impl Server {
|
||||||
|
|
||||||
fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> {
|
fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> {
|
||||||
match command {
|
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
|
Command::InspectExecution { actor_ref } => self
|
||||||
.image
|
.image
|
||||||
.inspect_execution(parse_actor_ref(&actor_ref)?)
|
.inspect_execution(parse_actor_ref(&actor_ref)?)
|
||||||
|
|
@ -77,6 +122,7 @@ impl Server {
|
||||||
source: inspection.source,
|
source: inspection.source,
|
||||||
behavior_path: inspection.behavior_path,
|
behavior_path: inspection.behavior_path,
|
||||||
execution: inspection.execution,
|
execution: inspection.execution,
|
||||||
|
debugger: inspection.debugger,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(api_error),
|
.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
|
locations through read-only execution inspection. VS Code navigates immutable
|
||||||
exact-loaded-source documents, including old code during pending replacements.
|
exact-loaded-source documents, including old code during pending replacements.
|
||||||
Await locations represent the last safe snapshot; native/hidden async frames,
|
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
|
Non-destructive image-change notifications and automatic snapshot refresh are
|
||||||
implemented over the shared `runa-debug` WebSocket transport.
|
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 |
|
| `rollback_source` | `path`, `revision` | `source`: restored file |
|
||||||
| `spawn` | `path`, tagged `state`, positive `mailbox_capacity` | `actor_spawned`: `{actor_ref}` |
|
| `spawn` | `path`, tagged `state`, positive `mailbox_capacity` | `actor_spawned`: `{actor_ref}` |
|
||||||
| `inspect_execution` | `actor_ref` | `execution_inspected`: loaded source and optional execution stack |
|
| `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` |
|
| `system` | `actor_ref`, `message` | `acknowledged` |
|
||||||
|
|
||||||
A file is `{path, source_id, source}` and a commit is
|
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
|
`inspect_execution` is an additive, read-only v5 operation. It returns
|
||||||
`{actor_ref, incarnation, implementation_id, source_id, source, behavior_path,
|
`{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
|
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.
|
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`.
|
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
|
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
|
hidden inside nested futures. Pending async steps are not counted as completed
|
||||||
VM steps. Snapshots contain no VM stack values or mutable execution handles.
|
VM steps. Snapshots contain no VM stack values or mutable execution handles.
|
||||||
There is no locals inspection, post-mortem stack retention, stepping, breakpoint,
|
There is no locals inspection, post-mortem stack retention, or DAP contract.
|
||||||
or DAP contract. Responses are point-in-time; clients must pause explicitly for
|
Responses are point-in-time; clients must pause explicitly for stable inspection.
|
||||||
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"}`
|
System message shape is `{kind: "stop"|"kill"|"restart"|"pause"|"resume"|"reload"}`
|
||||||
or `{kind: "become", path: "actors/other"}`. This is a trusted host request;
|
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
|
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
|
retains identity, mailbox, the loaded script and state; removed actors cannot be
|
||||||
restarted. Reload/become defer installation to
|
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
|
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
|
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
|
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.
|
mapping. Pause retains a stable active snapshot; restart/kill discard it.
|
||||||
Native Rust frames and hidden nested-future stacks are not inspected.
|
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
|
## 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
|
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,
|
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
|
For automation, `runa.inspectExecution` accepts `{actor_ref: string}` and an
|
||||||
optional zero-based frame index to navigate without the picker.
|
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
|
For automation, the `runa.connect` command also accepts an explicit endpoint
|
||||||
string, and `runa.inspect` accepts an object with an `actor_ref` field.
|
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
|
**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
|
validation results, not connection failures. Transport and compiler-service
|
||||||
failures remain explicit notifications in the Runa output channel.
|
failures remain explicit notifications in the Runa output channel.
|
||||||
|
|
||||||
This is single-source compilation checking, not an LSP or debugger: there is no
|
Compilation checking is single-source, not an LSP: completion, cross-file
|
||||||
completion, cross-file import resolution through image source paths, quick fixes,
|
import resolution through image source paths, and quick fixes remain planned.
|
||||||
runtime source mapping, breakpoints, or variable inspection yet.
|
Debugger controls are described separately above; variable inspection is not
|
||||||
|
implemented.
|
||||||
|
|
||||||
## Development and validation
|
## 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 editSources(image.address);
|
||||||
await checkSourceDiagnostics(image.address);
|
await checkSourceDiagnostics(image.address);
|
||||||
await inspectExecution(image.address);
|
await inspectExecution(image.address);
|
||||||
|
await sourceDebugging(image.address);
|
||||||
await editActorFiles(image.address);
|
await editActorFiles(image.address);
|
||||||
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
|
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
|
||||||
path: "actors/counter",
|
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> {
|
async function editSources(address: string): Promise<void> {
|
||||||
const extension = vscode.extensions.all.find(
|
const extension = vscode.extensions.all.find(
|
||||||
(candidate) => candidate.packageJSON.name === "runa",
|
(candidate) => candidate.packageJSON.name === "runa",
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,18 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"commands": [
|
"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",
|
"command": "runa.inspectExecution",
|
||||||
"title": "Inspect Execution Stack...",
|
"title": "Inspect Execution Stack...",
|
||||||
|
|
@ -311,6 +323,16 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"view/item/context": [
|
"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",
|
"command": "runa.inspectExecution",
|
||||||
"when": "view == runa.actors && viewItem == runa.actor && runa.connected",
|
"when": "view == runa.actors && viewItem == runa.actor && runa.connected",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { stringify } from "lossless-json";
|
||||||
import {
|
import {
|
||||||
ActorSnapshot,
|
ActorSnapshot,
|
||||||
ExecutionInspection,
|
ExecutionInspection,
|
||||||
|
SourceBreakpoint,
|
||||||
|
DebugStopReason,
|
||||||
decodeNotification,
|
decodeNotification,
|
||||||
decodeResponse,
|
decodeResponse,
|
||||||
decodeValue,
|
decodeValue,
|
||||||
|
|
@ -236,6 +238,51 @@ export class ImageClient {
|
||||||
if (reply.kind !== "execution_inspected") throw new Error("Expected execution inspection");
|
if (reply.kind !== "execution_inspected") throw new Error("Expected execution inspection");
|
||||||
return reply.data;
|
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[]> {
|
async listSources(): Promise<SourceFile[]> {
|
||||||
const reply = await this.request({ operation: "list_sources" });
|
const reply = await this.request({ operation: "list_sources" });
|
||||||
if (reply.kind !== "sources") throw new Error("Expected source listing");
|
if (reply.kind !== "sources") throw new Error("Expected source listing");
|
||||||
|
|
|
||||||
|
|
@ -368,7 +368,7 @@ export function activate(context: vscode.ExtensionContext): {
|
||||||
const chosen =
|
const chosen =
|
||||||
frameIndex === undefined
|
frameIndex === undefined
|
||||||
? await vscode.window.showQuickPick(choices, {
|
? 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
|
placeHolder: execution.frames_truncated
|
||||||
? "Choose a frame (innermost 64 shown; stack truncated)"
|
? "Choose a frame (innermost 64 shown; stack truncated)"
|
||||||
: "Choose a frame to open its exact loaded source",
|
: "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) =>
|
const jsonInput = (title: string, value?: string) =>
|
||||||
vscode.window.showInputBox({
|
vscode.window.showInputBox({
|
||||||
title,
|
title,
|
||||||
|
|
@ -846,6 +938,8 @@ export function activate(context: vscode.ExtensionContext): {
|
||||||
command("runa.refresh", refresh),
|
command("runa.refresh", refresh),
|
||||||
command("runa.inspect", inspect),
|
command("runa.inspect", inspect),
|
||||||
command("runa.inspectExecution", inspectExecution),
|
command("runa.inspectExecution", inspectExecution),
|
||||||
|
command("runa.setBreakpoints", setBreakpoints),
|
||||||
|
command("runa.stepSource", stepSource),
|
||||||
command("runa.editBehavior", editBehavior),
|
command("runa.editBehavior", editBehavior),
|
||||||
command("runa.goToBehavior", goToBehavior),
|
command("runa.goToBehavior", goToBehavior),
|
||||||
command("runa.updateBehavior", updateBehavior),
|
command("runa.updateBehavior", updateBehavior),
|
||||||
|
|
|
||||||
|
|
@ -103,9 +103,37 @@ export interface ExecutionInspection {
|
||||||
frames: StackFrame[];
|
frames: StackFrame[];
|
||||||
frames_truncated: boolean;
|
frames_truncated: boolean;
|
||||||
} | null;
|
} | 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 =
|
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: "inspect_execution"; parameters: { actor_ref: string } }
|
||||||
| { operation: "check_source"; parameters: { source: string } }
|
| { operation: "check_source"; parameters: { source: string } }
|
||||||
| { operation: "actor_behavior"; parameters: { actor_ref: string } }
|
| { operation: "actor_behavior"; parameters: { actor_ref: string } }
|
||||||
|
|
@ -369,6 +397,8 @@ function decodeSourceRange(input: unknown): SourceDiagnostic["range"] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Reply =
|
export type Reply =
|
||||||
|
| { kind: "breakpoints_set"; data: { breakpoints: SourceBreakpoint[] } }
|
||||||
|
| { kind: "debug_step_completed"; data: { reason: DebugStopReason } }
|
||||||
| { kind: "execution_inspected"; data: ExecutionInspection }
|
| { kind: "execution_inspected"; data: ExecutionInspection }
|
||||||
| { kind: "source_checked"; data: { diagnostics: SourceDiagnostic[] } }
|
| { kind: "source_checked"; data: { diagnostics: SourceDiagnostic[] } }
|
||||||
| { kind: "actor_behavior"; data: { source: string; source_id: string } }
|
| { 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);
|
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") {
|
if (command.operation === "inspect_execution" && reply.kind === "execution_inspected") {
|
||||||
const data = object(reply.data);
|
const data = object(reply.data);
|
||||||
if (data.actor_ref !== command.parameters.actor_ref || !isActorRef(data.actor_ref))
|
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),
|
source: text(data.source),
|
||||||
behavior_path: data.behavior_path,
|
behavior_path: data.behavior_path,
|
||||||
execution,
|
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");
|
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(
|
export function formatSnapshot(
|
||||||
actor: ActorSnapshot,
|
actor: ActorSnapshot,
|
||||||
endpoint: string,
|
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) => {
|
test("normal disconnect completes a code-1000 close handshake and rejects further work", async (t) => {
|
||||||
let closed: Promise<unknown[]> | undefined;
|
let closed: Promise<unknown[]> | undefined;
|
||||||
const address = await fakeImage(t, (request, socket) => {
|
const address = await fakeImage(t, (request, socket) => {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,67 @@ const command: Command = { operation: "list_actors" };
|
||||||
const envelope = (reply: unknown) =>
|
const envelope = (reply: unknown) =>
|
||||||
json({ version: 5, request_id: "test", result: { Ok: reply } });
|
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", () => {
|
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 command: Command = { operation: "inspect_execution", parameters: { actor_ref: REF } };
|
||||||
const frame = {
|
const frame = {
|
||||||
|
|
@ -48,6 +109,31 @@ test("execution inspections preserve u64 fields and validate stack order, bounds
|
||||||
assert.equal(decoded.kind, "execution_inspected");
|
assert.equal(decoded.kind, "execution_inspected");
|
||||||
if (decoded.kind !== "execution_inspected") throw new Error("unexpected reply");
|
if (decoded.kind !== "execution_inspected") throw new Error("unexpected reply");
|
||||||
assert.equal(decoded.data.incarnation, data.incarnation);
|
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, {
|
assert.deepEqual(decoded.data.execution?.frames[0]?.range, {
|
||||||
start: { line: 1, character: 2 },
|
start: { line: 1, character: 2 },
|
||||||
end: { line: 1, character: 5 },
|
end: { line: 1, character: 5 },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue