feat: add loaded-source execution stack inspection
Expose bounded active Rune frames and exact installed source through read-only image and protocol inspection. Add immutable VS Code source navigation and lifecycle, await, and integration coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
7e25cb8ef6
commit
b8080fe02e
|
|
@ -56,6 +56,11 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### Added
|
||||
|
||||
- Read-only execution-stack inspection with exact loaded source identities,
|
||||
bounded nested Rune frames, UTF-16 locations, and explicit pending-await status.
|
||||
- VS Code **Inspect Execution Stack...** actor action, navigating frames in
|
||||
immutable loaded-source documents even after shared or temporary source edits.
|
||||
|
||||
- Structured Rune compilation errors and warnings through a read-only
|
||||
`check_source` protocol operation and compilation-failure error details.
|
||||
- Automatic VS Code inline/Problems diagnostics for open image sources and
|
||||
|
|
|
|||
|
|
@ -30,8 +30,10 @@ Server change notifications automatically refresh VS Code's actor tree and inspe
|
|||
|
||||
The prototype uses worker-local invocation stacks and cooperative instruction
|
||||
slices, and actor snapshots report basic active-execution progress. Native
|
||||
callbacks are not preemptible; source-level debugging and persistent storage
|
||||
are not implemented.
|
||||
callbacks are not preemptible. The debugger foundation exposes bounded active Rune
|
||||
stack frames and exact loaded source through read-only protocol inspection and
|
||||
VS Code's **Inspect Execution Stack...** actor action. Stepping, breakpoints,
|
||||
locals, and persistent storage remain unimplemented.
|
||||
|
||||
## Design goals
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::SourceRange;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StackFrameKind {
|
||||
Current,
|
||||
Caller,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StackFrame {
|
||||
pub kind: StackFrameKind,
|
||||
/// Current instruction or caller's saved return address.
|
||||
pub instruction_pointer: usize,
|
||||
/// Instruction mapped to source (the call instruction for a caller).
|
||||
pub source_instruction_pointer: Option<usize>,
|
||||
pub function: Option<String>,
|
||||
pub range: Option<SourceRange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExecutionInspection {
|
||||
pub vm_steps: u64,
|
||||
pub awaiting: bool,
|
||||
pub call_depth: usize,
|
||||
pub frames: Vec<StackFrame>,
|
||||
pub frames_truncated: bool,
|
||||
}
|
||||
|
|
@ -3,9 +3,11 @@
|
|||
use std::fmt;
|
||||
|
||||
mod diagnostic;
|
||||
mod execution;
|
||||
mod log;
|
||||
mod value;
|
||||
pub use diagnostic::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
|
||||
pub use execution::{ExecutionInspection, StackFrame, StackFrameKind};
|
||||
pub use log::{LogEntry, LogLevel};
|
||||
pub use value::{ActorRef, MAX_VALUE_BYTES, MAX_VALUE_DEPTH, MAX_VALUE_NODES, Value};
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@ pub struct ImageApi {
|
|||
events: Vec<Event>,
|
||||
}
|
||||
|
||||
pub struct ExecutionInspection {
|
||||
pub actor_ref: ActorRef,
|
||||
pub incarnation: u64,
|
||||
pub implementation_id: ImplementationId,
|
||||
pub source_id: runa_core::SourceId,
|
||||
pub source: String,
|
||||
pub behavior_path: Option<String>,
|
||||
pub execution: Option<runa_core::ExecutionInspection>,
|
||||
}
|
||||
|
||||
impl Default for ImageApi {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
|
|
@ -338,6 +348,23 @@ impl ImageApi {
|
|||
self.runtime.inspect(actor)
|
||||
}
|
||||
|
||||
pub fn inspect_execution(&self, actor: ActorRef) -> RuntimeResult<ExecutionInspection> {
|
||||
let snapshot = self.runtime.inspect(actor)?;
|
||||
let artifact = self
|
||||
.code
|
||||
.artifact(snapshot.implementation)
|
||||
.ok_or("behavior artifact is unavailable")?;
|
||||
Ok(ExecutionInspection {
|
||||
actor_ref: actor,
|
||||
incarnation: snapshot.incarnation,
|
||||
implementation_id: snapshot.implementation,
|
||||
source_id: artifact.source_id,
|
||||
source: artifact.original_source().to_owned(),
|
||||
behavior_path: self.actor_path(actor).map(str::to_owned),
|
||||
execution: self.runtime.inspect_execution(actor)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn edit_actor(
|
||||
&mut self,
|
||||
actor: ActorRef,
|
||||
|
|
@ -571,6 +598,113 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_inspection_keeps_exact_loaded_source_across_edits_pause_and_reload()
|
||||
-> RuntimeResult<()> {
|
||||
let mut image = ImageApi::new();
|
||||
let original = "pub fn handle(ctx, state, sender, message) {\n nested()\n}\nfn nested() { loop {} }";
|
||||
let file = image.write_source("actors/stack", None, original)?;
|
||||
let actor = image.spawn(&file.path, Value::Null, capacity(2))?;
|
||||
assert!(image.inspect_execution(actor)?.execution.is_none());
|
||||
image.send(actor, Value::Null)?;
|
||||
image.tick(1, capacity(10));
|
||||
let before = image.inspect_execution(actor)?;
|
||||
assert_eq!(before.source, original);
|
||||
assert_eq!(before.source_id, file.source_id);
|
||||
assert_eq!(before.behavior_path.as_deref(), Some("actors/stack"));
|
||||
let frames = before.execution.as_ref().unwrap();
|
||||
assert_eq!(frames.frames[0].function.as_deref(), Some("nested"));
|
||||
assert_eq!(frames.frames[0].range.unwrap().start.line, 3);
|
||||
let updated = image.write_source(
|
||||
&file.path,
|
||||
Some(file.source_id),
|
||||
"pub fn handle(ctx, state, sender, message) { 1 }",
|
||||
)?;
|
||||
image.system(actor, SystemMessage::Pause)?;
|
||||
image.tick(1, capacity(1));
|
||||
let paused = image.inspect_execution(actor)?.execution.unwrap();
|
||||
image.tick(2, capacity(100));
|
||||
assert_eq!(image.inspect_execution(actor)?.execution, Some(paused));
|
||||
image.reload(actor)?;
|
||||
let pending = image.inspect_execution(actor)?;
|
||||
assert_eq!(pending.implementation_id, before.implementation_id);
|
||||
assert_eq!(pending.source, original);
|
||||
image.system(actor, SystemMessage::Restart)?;
|
||||
assert!(image.inspect_execution(actor)?.execution.is_none());
|
||||
assert_eq!(image.inspect_execution(actor)?.source, original);
|
||||
image.reload(actor)?;
|
||||
image.tick(2, capacity(100));
|
||||
let after = image.inspect_execution(actor)?;
|
||||
assert_eq!(after.source, updated.source);
|
||||
assert_eq!(after.source_id, updated.source_id);
|
||||
assert!(after.execution.is_none());
|
||||
assert_eq!(after.incarnation, before.incarnation + 1);
|
||||
image.system(actor, SystemMessage::Kill)?;
|
||||
assert!(image.inspect_execution(actor).is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_inspection_distinguishes_installed_and_pending_temporary_source()
|
||||
-> RuntimeResult<()> {
|
||||
let mut image = ImageApi::new();
|
||||
let original = image.write_source(
|
||||
"actors/temp_stack",
|
||||
None,
|
||||
"pub fn handle(ctx, state, sender, message) { state }",
|
||||
)?;
|
||||
let actor = image.spawn(&original.path, Value::Null, capacity(2))?;
|
||||
let temporary = image.write_actor_behavior(
|
||||
actor,
|
||||
original.source_id.to_string(),
|
||||
"pub fn handle(ctx, state, sender, message) { loop {} }",
|
||||
)?;
|
||||
assert_eq!(
|
||||
image.inspect_execution(actor)?.source_id,
|
||||
original.source_id
|
||||
);
|
||||
image.send(actor, Value::Null)?;
|
||||
image.tick(1, capacity(10));
|
||||
let running = image.inspect_execution(actor)?;
|
||||
assert_eq!(running.source_id, temporary.source_id);
|
||||
assert_eq!(running.source, temporary.source);
|
||||
assert!(running.execution.is_some());
|
||||
let replacement = image.write_actor_behavior(
|
||||
actor,
|
||||
temporary.source_id.to_string(),
|
||||
"pub fn handle(ctx, state, sender, message) { 42 }",
|
||||
)?;
|
||||
let pending = image.inspect_execution(actor)?;
|
||||
assert_eq!(pending.source_id, temporary.source_id);
|
||||
assert_ne!(pending.source_id, replacement.source_id);
|
||||
assert_eq!(pending.source, running.source);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_inspection_maps_shutdown_and_has_no_frames_after_failure() -> RuntimeResult<()> {
|
||||
let mut image = ImageApi::new();
|
||||
let file = image.write_source("actors/shutdown_stack", None, "pub fn handle(ctx, state, sender, message) { state }\npub fn on_shutdown(state) { loop {} }")?;
|
||||
let actor = image.spawn(&file.path, Value::Null, capacity(2))?;
|
||||
image.system(actor, SystemMessage::Stop)?;
|
||||
image.tick(1, capacity(10));
|
||||
let shutdown = image.inspect_execution(actor)?.execution.unwrap();
|
||||
assert_eq!(shutdown.frames[0].function.as_deref(), Some("on_shutdown"));
|
||||
assert_eq!(shutdown.frames[0].range.unwrap().start.line, 1);
|
||||
let file = image.write_source(
|
||||
"actors/failing_stack",
|
||||
None,
|
||||
"pub fn handle(ctx, state, sender, message) { 1 / 0 }",
|
||||
)?;
|
||||
let failed = image.spawn(&file.path, Value::Null, capacity(2))?;
|
||||
image.send(failed, Value::Null)?;
|
||||
image.tick(1, capacity(100));
|
||||
let failed = image.inspect_execution(failed)?;
|
||||
assert!(failed.execution.is_none());
|
||||
assert_eq!(failed.source, file.source);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_never_mutates_sources_actors_or_compile_cache() -> RuntimeResult<()> {
|
||||
let mut image = ImageApi::new();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
pub use runa_core::Value;
|
||||
pub use runa_core::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
|
||||
pub use runa_core::{ExecutionInspection, StackFrame, StackFrameKind};
|
||||
pub use runa_core::{LogEntry, LogLevel};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -53,7 +54,8 @@ impl Request {
|
|||
validate_name(target)?;
|
||||
}
|
||||
Command::DeleteSource { path, .. } => validate_name(path)?,
|
||||
Command::ActorBehavior { actor_ref }
|
||||
Command::InspectExecution { actor_ref }
|
||||
| Command::ActorBehavior { actor_ref }
|
||||
| Command::WriteActorBehavior { actor_ref, .. } => validate_actor_ref(actor_ref)?,
|
||||
Command::UpdateBehaviorSource {
|
||||
actor_ref, path, ..
|
||||
|
|
@ -173,6 +175,9 @@ impl Request {
|
|||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "parameters", rename_all = "snake_case")]
|
||||
pub enum Command {
|
||||
InspectExecution {
|
||||
actor_ref: String,
|
||||
},
|
||||
CheckSource {
|
||||
source: String,
|
||||
},
|
||||
|
|
@ -323,6 +328,7 @@ pub struct Response {
|
|||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
|
||||
pub enum Reply {
|
||||
ExecutionInspected(ExecutionInspectionDto),
|
||||
SourceChecked {
|
||||
diagnostics: Vec<SourceDiagnostic>,
|
||||
},
|
||||
|
|
@ -490,6 +496,17 @@ pub struct ExecutionSnapshotDto {
|
|||
pub call_depth: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExecutionInspectionDto {
|
||||
pub actor_ref: String,
|
||||
pub incarnation: u64,
|
||||
pub implementation_id: String,
|
||||
pub source_id: String,
|
||||
pub source: String,
|
||||
pub behavior_path: Option<String>,
|
||||
pub execution: Option<ExecutionInspection>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorCode {
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ pub(crate) fn collect(
|
|||
output
|
||||
}
|
||||
|
||||
fn source_range(source: &str, span: Span) -> Option<SourceRange> {
|
||||
pub(crate) fn source_range(source: &str, span: Span) -> Option<SourceRange> {
|
||||
let range = span.range();
|
||||
let start = source.get(..range.start)?;
|
||||
let end = source.get(..range.end)?;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::future::{Future, poll_fn};
|
||||
use std::num::NonZeroUsize;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use rune::runtime::{Args, VmError};
|
||||
use rune::runtime::{Args, Unit, VmError};
|
||||
use rune::{FromValue, Vm};
|
||||
|
||||
use crate::ExperimentResult;
|
||||
|
|
@ -28,10 +29,54 @@ pub enum Slice<T> {
|
|||
|
||||
type ExecutionFuture<T> = Pin<Box<dyn Future<Output = ExperimentResult<T>>>>;
|
||||
|
||||
pub const MAX_INSPECTION_FRAMES: usize = 64;
|
||||
|
||||
/// Owned instruction addresses only; no Rune stack values escape the worker.
|
||||
#[derive(Clone)]
|
||||
pub struct InvocationStack {
|
||||
pub unit: Arc<Unit>,
|
||||
pub instruction_pointer: usize,
|
||||
pub return_addresses: Vec<usize>,
|
||||
pub call_depth: usize,
|
||||
pub awaiting: bool,
|
||||
}
|
||||
|
||||
impl InvocationStack {
|
||||
fn capture(vm: &Vm) -> Self {
|
||||
let mut stack = Self {
|
||||
unit: Arc::clone(vm.unit()),
|
||||
instruction_pointer: vm.ip(),
|
||||
return_addresses: Vec::new(),
|
||||
call_depth: vm.call_frames().len(),
|
||||
awaiting: false,
|
||||
};
|
||||
stack.update(vm);
|
||||
stack
|
||||
}
|
||||
|
||||
fn update(&mut self, vm: &Vm) {
|
||||
if !Arc::ptr_eq(&self.unit, vm.unit()) {
|
||||
self.unit = Arc::clone(vm.unit());
|
||||
}
|
||||
self.instruction_pointer = vm.ip();
|
||||
self.call_depth = vm.call_frames().len();
|
||||
self.return_addresses.clear();
|
||||
self.return_addresses.extend(
|
||||
vm.call_frames()
|
||||
.iter()
|
||||
.rev()
|
||||
.take(MAX_INSPECTION_FRAMES - 1)
|
||||
.map(|frame| frame.ip),
|
||||
);
|
||||
self.awaiting = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Invocation<T> {
|
||||
future: Option<ExecutionFuture<T>>,
|
||||
stepped: Rc<Cell<bool>>,
|
||||
progress: Rc<Cell<InvocationProgress>>,
|
||||
stack: Rc<RefCell<InvocationStack>>,
|
||||
}
|
||||
|
||||
impl<T: FromValue + 'static> Invocation<T> {
|
||||
|
|
@ -43,13 +88,19 @@ impl<T: FromValue + 'static> Invocation<T> {
|
|||
execution.vm_mut().set_ip(entrypoint);
|
||||
let stepped = Rc::new(Cell::new(false));
|
||||
let marker = Rc::clone(&stepped);
|
||||
let progress = Rc::new(Cell::new(InvocationProgress::default()));
|
||||
let progress = Rc::new(Cell::new(InvocationProgress {
|
||||
instruction_pointer: entrypoint,
|
||||
..InvocationProgress::default()
|
||||
}));
|
||||
let inspector = Rc::clone(&progress);
|
||||
let stack = Rc::new(RefCell::new(InvocationStack::capture(execution.vm())));
|
||||
let stack_inspector = Rc::clone(&stack);
|
||||
let future = Box::pin(async move {
|
||||
loop {
|
||||
let result = execution.async_step().await;
|
||||
let vm = execution.vm();
|
||||
let previous = inspector.get();
|
||||
stack_inspector.borrow_mut().update(vm);
|
||||
inspector.set(InvocationProgress {
|
||||
vm_steps: previous.vm_steps.saturating_add(1),
|
||||
instruction_pointer: vm.ip(),
|
||||
|
|
@ -76,6 +127,7 @@ impl<T: FromValue + 'static> Invocation<T> {
|
|||
future: Some(future),
|
||||
stepped,
|
||||
progress,
|
||||
stack,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +136,10 @@ impl<T: FromValue + 'static> Invocation<T> {
|
|||
self.progress.get()
|
||||
}
|
||||
|
||||
pub fn stack(&self) -> InvocationStack {
|
||||
self.stack.borrow().clone()
|
||||
}
|
||||
|
||||
/// Bound VM steps per call, preserving the exact pending async step.
|
||||
pub fn poll_slice(&mut self, cx: &mut Context<'_>, budget: NonZeroUsize) -> Slice<T> {
|
||||
let Some(future) = self.future.as_mut() else {
|
||||
|
|
@ -99,7 +155,10 @@ impl<T: FromValue + 'static> Invocation<T> {
|
|||
Err(error) => Slice::Failed(error),
|
||||
};
|
||||
}
|
||||
Poll::Pending if !self.stepped.get() => return Slice::Awaiting,
|
||||
Poll::Pending if !self.stepped.get() => {
|
||||
self.stack.borrow_mut().awaiting = true;
|
||||
return Slice::Awaiting;
|
||||
}
|
||||
Poll::Pending => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -112,11 +171,101 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::CompiledProgram;
|
||||
use rune::Module;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Mutex;
|
||||
use std::task::{Wake, Waker};
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[test]
|
||||
fn stack_frames_map_nested_calls_and_return_addresses_to_exact_source() -> ExperimentResult<()>
|
||||
{
|
||||
let source = "pub fn handle() {\n outer()\n}\nfn outer() {\n inner()\n}\nfn inner() {\n loop {}\n}";
|
||||
let program = CompiledProgram::compile(source)?;
|
||||
let mut invocation = Invocation::<i64>::start(program.vm(), "handle", ())?;
|
||||
let mut cx = Context::from_waker(Waker::noop());
|
||||
for _ in 0..100 {
|
||||
assert!(matches!(
|
||||
invocation.poll_slice(&mut cx, NonZeroUsize::new(1).unwrap()),
|
||||
Slice::BudgetExhausted
|
||||
));
|
||||
let inspection =
|
||||
program.inspect_stack(source, &invocation.stack(), invocation.progress().vm_steps);
|
||||
if inspection.frames.len() == 3
|
||||
&& inspection.frames[0]
|
||||
.range
|
||||
.is_some_and(|range| range.start.line == 7)
|
||||
{
|
||||
assert_eq!(
|
||||
inspection
|
||||
.frames
|
||||
.iter()
|
||||
.map(|frame| frame.function.as_deref().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
["inner", "outer", "handle"]
|
||||
);
|
||||
assert_eq!(
|
||||
inspection
|
||||
.frames
|
||||
.iter()
|
||||
.map(|frame| frame.range.unwrap().start.line)
|
||||
.collect::<Vec<_>>(),
|
||||
[7, 4, 1]
|
||||
);
|
||||
assert!(
|
||||
inspection.frames[1].source_instruction_pointer.unwrap()
|
||||
< inspection.frames[1].instruction_pointer
|
||||
);
|
||||
assert_eq!(inspection.call_depth, 2);
|
||||
assert!(!inspection.awaiting);
|
||||
assert!(!inspection.frames_truncated);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("nested source stack was not observed".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_debug_locations_are_not_guessed() -> ExperimentResult<()> {
|
||||
let source = "pub fn handle() { loop {} }";
|
||||
let program = CompiledProgram::compile(source)?;
|
||||
let other = CompiledProgram::compile("pub fn handle() { 1 }")?;
|
||||
let mut stack = InvocationStack::capture(&program.vm());
|
||||
// A different compiled unit cannot be mapped against this source.
|
||||
let invocation = Invocation::<i64>::start(program.vm(), "handle", ())?;
|
||||
let inspection = other.inspect_stack("pub fn handle() { 1 }", &invocation.stack(), 0);
|
||||
assert!(inspection.frames.iter().all(|frame| frame.range.is_none()));
|
||||
stack.instruction_pointer = usize::MAX;
|
||||
stack.return_addresses.clear();
|
||||
stack.call_depth = 0;
|
||||
let inspection = program.inspect_stack(source, &stack, 0);
|
||||
assert_eq!(inspection.frames[0].range, None);
|
||||
assert_eq!(inspection.frames[0].function, None);
|
||||
assert_eq!(inspection.frames[0].source_instruction_pointer, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_stack_inspection_is_bounded_and_reports_truncation() -> ExperimentResult<()> {
|
||||
let source = "pub fn handle() { recurse(100) } fn recurse(n) { if n == 0 { loop {} } else { recurse(n - 1) } }";
|
||||
let program = CompiledProgram::compile(source)?;
|
||||
let mut invocation = Invocation::<i64>::start(program.vm(), "handle", ())?;
|
||||
let mut cx = Context::from_waker(Waker::noop());
|
||||
for _ in 0..100 {
|
||||
assert!(matches!(
|
||||
invocation.poll_slice(&mut cx, NonZeroUsize::new(32).unwrap()),
|
||||
Slice::BudgetExhausted
|
||||
));
|
||||
let inspection =
|
||||
program.inspect_stack(source, &invocation.stack(), invocation.progress().vm_steps);
|
||||
if inspection.call_depth > MAX_INSPECTION_FRAMES {
|
||||
assert_eq!(inspection.frames.len(), MAX_INSPECTION_FRAMES);
|
||||
assert!(inspection.frames_truncated);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("deep recursive stack was not observed".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_invocation_preserves_state_argument() -> ExperimentResult<()> {
|
||||
let program = CompiledProgram::compile(
|
||||
|
|
@ -186,10 +335,8 @@ mod tests {
|
|||
module
|
||||
.function("wait", move || PendingOperation(Arc::clone(&native_signal)))
|
||||
.build()?;
|
||||
let program = CompiledProgram::compile_with_module(
|
||||
"pub async fn handle() { let n = 0; while n < 10 { n += 1; } let x = wait().await; while n < 20 { n += 1; } n + x }",
|
||||
module,
|
||||
)?;
|
||||
let source = "pub async fn handle() { let n = 0; while n < 10 { n += 1; } let x = wait().await; while n < 20 { n += 1; } n + x }";
|
||||
let program = CompiledProgram::compile_with_module(source, module)?;
|
||||
let counter = Arc::new(WakeCounter(std::sync::atomic::AtomicUsize::new(0)));
|
||||
let waker = Waker::from(Arc::clone(&counter));
|
||||
let mut cx = Context::from_waker(&waker);
|
||||
|
|
@ -210,6 +357,15 @@ mod tests {
|
|||
.unwrap()
|
||||
.take()
|
||||
.expect("await registered waker");
|
||||
let stack = invocation.stack();
|
||||
assert!(stack.awaiting);
|
||||
let inspection = program.inspect_stack(source, &stack, invocation.progress().vm_steps);
|
||||
let range = inspection.frames[0].range.unwrap();
|
||||
assert_eq!(range.start.line, 0);
|
||||
assert_eq!(
|
||||
&source[range.start.character..range.end.character],
|
||||
"wait().await"
|
||||
);
|
||||
assert!(matches!(
|
||||
invocation.poll_slice(&mut cx, budget),
|
||||
Slice::Awaiting
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ pub const RUNE_VERSION: &str = "0.14.2";
|
|||
pub const COMPILER_CONFIGURATION_ID: &str = "default-modules; native-functions:v2:native_double,native_suspend; runa::ActorReference:v1; runa::ActorContext:v3:this,send,send_from,log,trace,debug,info,warn,error,fatal";
|
||||
|
||||
mod execution;
|
||||
pub use execution::{Invocation, InvocationProgress, Slice};
|
||||
pub use execution::{Invocation, InvocationProgress, InvocationStack, Slice};
|
||||
mod value;
|
||||
pub use value::{value_from_rune, value_to_rune};
|
||||
mod context;
|
||||
|
|
@ -52,6 +52,7 @@ pub struct CompiledProgram {
|
|||
runtime: Arc<RuntimeContext>,
|
||||
unit: Arc<Unit>,
|
||||
diagnostics: Vec<runa_core::SourceDiagnostic>,
|
||||
source_id: rune::SourceId,
|
||||
}
|
||||
|
||||
impl CompiledProgram {
|
||||
|
|
@ -146,6 +147,7 @@ impl CompiledProgram {
|
|||
runtime: Arc::new(context.runtime()?),
|
||||
unit: Arc::new(unit),
|
||||
diagnostics,
|
||||
source_id,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +158,71 @@ impl CompiledProgram {
|
|||
pub fn has_function(&self, name: &str) -> bool {
|
||||
self.vm().lookup_function([name]).is_ok()
|
||||
}
|
||||
|
||||
pub fn inspect_stack(
|
||||
&self,
|
||||
source: &str,
|
||||
stack: &InvocationStack,
|
||||
vm_steps: u64,
|
||||
) -> runa_core::ExecutionInspection {
|
||||
use runa_core::{StackFrame, StackFrameKind};
|
||||
let debug = stack.unit.debug_info();
|
||||
let frame = |ip, kind| {
|
||||
let source_ip = debug.and_then(|debug| match kind {
|
||||
StackFrameKind::Current => debug.instruction_at(ip).map(|_| ip),
|
||||
StackFrameKind::Caller => debug
|
||||
.instructions
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|offset| *offset < ip)
|
||||
.max(),
|
||||
});
|
||||
let function = debug.and_then(|debug| {
|
||||
let offset = source_ip?;
|
||||
let entry = debug
|
||||
.functions_rev
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|start| *start <= offset)
|
||||
.max()?;
|
||||
debug
|
||||
.function_at(entry)
|
||||
.map(|(_, signature)| signature.path.to_string())
|
||||
});
|
||||
let range = source_ip.and_then(|ip| {
|
||||
if !Arc::ptr_eq(&self.unit, &stack.unit) {
|
||||
return None;
|
||||
}
|
||||
let instruction = debug?.instruction_at(ip)?;
|
||||
if instruction.source_id != self.source_id {
|
||||
return None;
|
||||
}
|
||||
diagnostics::source_range(source, instruction.span)
|
||||
});
|
||||
StackFrame {
|
||||
kind,
|
||||
instruction_pointer: ip,
|
||||
source_instruction_pointer: source_ip,
|
||||
function,
|
||||
range,
|
||||
}
|
||||
};
|
||||
let frames = std::iter::once(frame(stack.instruction_pointer, StackFrameKind::Current))
|
||||
.chain(
|
||||
stack
|
||||
.return_addresses
|
||||
.iter()
|
||||
.map(|ip| frame(*ip, StackFrameKind::Caller)),
|
||||
)
|
||||
.collect();
|
||||
runa_core::ExecutionInspection {
|
||||
vm_steps,
|
||||
awaiting: stack.awaiting,
|
||||
call_depth: stack.call_depth,
|
||||
frames,
|
||||
frames_truncated: stack.call_depth >= execution::MAX_INSPECTION_FRAMES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
|
|||
|
|
@ -162,6 +162,25 @@ impl Runtime {
|
|||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub fn inspect_execution(
|
||||
&self,
|
||||
id: ActorRef,
|
||||
) -> ExperimentResult<Option<runa_core::ExecutionInspection>> {
|
||||
let actor = self.actors.get(&id).ok_or("unknown actor")?;
|
||||
let Some(invocation) = &actor.active else {
|
||||
return Ok(None);
|
||||
};
|
||||
let artifact = self
|
||||
.implementations
|
||||
.get(&actor.snapshot.implementation)
|
||||
.ok_or("unknown implementation")?;
|
||||
Ok(Some(artifact.program().inspect_stack(
|
||||
artifact.original_source(),
|
||||
&invocation.stack(),
|
||||
invocation.progress().vm_steps,
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn edit_actor(
|
||||
&mut self,
|
||||
id: ActorRef,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,21 @@ impl Server {
|
|||
|
||||
fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> {
|
||||
match command {
|
||||
Command::InspectExecution { actor_ref } => self
|
||||
.image
|
||||
.inspect_execution(parse_actor_ref(&actor_ref)?)
|
||||
.map(|inspection| {
|
||||
Reply::ExecutionInspected(runa_protocol::ExecutionInspectionDto {
|
||||
actor_ref: inspection.actor_ref.to_string(),
|
||||
incarnation: inspection.incarnation,
|
||||
implementation_id: inspection.implementation_id.to_string(),
|
||||
source_id: inspection.source_id.to_string(),
|
||||
source: inspection.source,
|
||||
behavior_path: inspection.behavior_path,
|
||||
execution: inspection.execution,
|
||||
})
|
||||
})
|
||||
.map_err(api_error),
|
||||
Command::CheckSource { source } => self
|
||||
.image
|
||||
.check_source(&source)
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ async fn serve(listener: TcpListener, mut server: Server) -> io::Result<()> {
|
|||
Command::GetBehaviorSource { .. } | Command::InspectActor { .. } |
|
||||
Command::ListActors | Command::GetEvents | Command::ListSources |
|
||||
Command::ReadSource { .. } | Command::SourceHistory { .. } |
|
||||
Command::RevisionSource { .. } | Command::ActorBehavior { .. } | Command::CheckSource { .. } | Command::Logs { .. });
|
||||
Command::RevisionSource { .. } | Command::ActorBehavior { .. } | Command::CheckSource { .. } | Command::InspectExecution { .. } | Command::Logs { .. });
|
||||
let response = server.dispatch(dispatch.request);
|
||||
let changed = mutates && response.result.is_ok();
|
||||
// Dispatch may commit even if the initiating client has disconnected.
|
||||
|
|
|
|||
|
|
@ -534,6 +534,11 @@ protocol into VS Code Problems for open sources and temporary behaviors,
|
|||
including unsaved text. Validation does not mutate the image; source positions
|
||||
use UTF-16 editor coordinates. Single-source checking is implemented, not an LSP
|
||||
or a cross-image module resolver. Detailed runtime event subscriptions remain planned.
|
||||
Debugger foundations now expose bounded head-VM stack frames and UTF-16 source
|
||||
locations through read-only execution inspection. VS Code navigates immutable
|
||||
exact-loaded-source documents, including old code during pending replacements.
|
||||
Await locations represent the last safe snapshot; native/hidden async frames,
|
||||
locals, post-mortem stacks, stepping, breakpoints, and DAP remain unimplemented.
|
||||
Non-destructive image-change notifications and automatic snapshot refresh are
|
||||
implemented over the shared `runa-debug` WebSocket transport.
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ behavior aliases. They exclude the `source/` root and `.rn` extension.
|
|||
| `revision_source` | `path`, `revision` | `behavior_source`: `{source}` |
|
||||
| `rollback_source` | `path`, `revision` | `source`: restored file |
|
||||
| `spawn` | `path`, tagged `state`, positive `mailbox_capacity` | `actor_spawned`: `{actor_ref}` |
|
||||
| `inspect_execution` | `actor_ref` | `execution_inspected`: loaded source and optional execution stack |
|
||||
| `system` | `actor_ref`, `message` | `acknowledged` |
|
||||
|
||||
A file is `{path, source_id, source}` and a commit is
|
||||
|
|
@ -115,6 +116,35 @@ field: absent diagnostics deserialize as an empty list and are omitted when
|
|||
empty. Source checking is single-file; it does not resolve other image files
|
||||
as modules.
|
||||
|
||||
### Execution inspection
|
||||
|
||||
`inspect_execution` is an additive, read-only v5 operation. It returns
|
||||
`{actor_ref, incarnation, implementation_id, source_id, source, behavior_path,
|
||||
execution}`. Source is the exact original text of the installed artifact, not
|
||||
working source or a pending temporary replacement. The nullable behavior path
|
||||
is a logical binding, not a guarantee that a mutable file still matches the code.
|
||||
Idle/failed actors return `execution: null`; removed actors return `not_found`.
|
||||
Inspection does not tick, mutate the image, consume events, or emit `image_changed`.
|
||||
|
||||
A non-null execution is `{vm_steps, awaiting, call_depth, frames,
|
||||
frames_truncated}`. Frames are ordered innermost first, limited to 64 including
|
||||
the current frame; `call_depth` reports the full number of saved callers.
|
||||
Each frame is `{kind: "current"|"caller", instruction_pointer,
|
||||
source_instruction_pointer, function, range}`. Current IP is the next instruction
|
||||
at a completed step boundary; while awaiting, it is the await instruction from
|
||||
the last safe snapshot, not an IP observed inside a borrowed pending future.
|
||||
Caller IPs are saved return addresses; source IPs identify the preceding call
|
||||
instruction. Source IP, function, and range may be null when debug information
|
||||
is unavailable. Ranges use the same zero-based UTF-16/exclusive-end coordinates
|
||||
as diagnostics and refer only to the returned loaded source.
|
||||
|
||||
Only the head Rune VM's visible frames are captured, not native stacks or stacks
|
||||
hidden inside nested futures. Pending async steps are not counted as completed
|
||||
VM steps. Snapshots contain no VM stack values or mutable execution handles.
|
||||
There is no locals inspection, post-mortem stack retention, stepping, breakpoint,
|
||||
or DAP contract. Responses are point-in-time; clients must pause explicitly for
|
||||
stable inspection.
|
||||
|
||||
System message shape is `{kind: "stop"|"kill"|"restart"|"pause"|"resume"|"reload"}`
|
||||
or `{kind: "become", path: "actors/other"}`. This is a trusted host request;
|
||||
actor `ctx.send` and `ctx.send_from` accept only ordinary data. Stop runs optional
|
||||
|
|
|
|||
|
|
@ -1,5 +1,24 @@
|
|||
# Phase 1 actor prototype
|
||||
|
||||
## Execution inspection
|
||||
|
||||
The image API and `inspect_execution` protocol operation expose a read-only,
|
||||
bounded snapshot of an active invocation's head Rune VM frames and the exact
|
||||
installed source/implementation identities. The current frame maps the next
|
||||
instruction; saved caller return addresses map back to their call instructions.
|
||||
While a native await is pending, the snapshot retains the last safe instruction
|
||||
boundary and is marked awaiting. It does not borrow the active VM or expose
|
||||
stack values across actor boundaries. At most 64 innermost frames are copied;
|
||||
depth/truncation remain explicit. Unknown mappings are nullable.
|
||||
|
||||
VS Code can navigate these frames in immutable loaded-source documents.
|
||||
Shared working edits and pending temporary replacements cannot change an active
|
||||
frame's source. Idle/failed actors have no active stack; failures still discard
|
||||
the invocation, so this is not post-mortem debugging. Shutdown hooks use the same
|
||||
mapping. Pause retains a stable active snapshot; restart/kill discard it.
|
||||
Native Rust frames and hidden nested-future stacks are not inspected.
|
||||
Breakpoints, source stepping, variables, and durable stack storage remain planned.
|
||||
|
||||
## Actor repair and logging
|
||||
|
||||
`set-state <actor> <json>` replaces committed state without touching the mailbox.
|
||||
|
|
|
|||
|
|
@ -126,6 +126,30 @@ machine running the extension host, not necessarily the desktop.
|
|||
Notifications are non-destructive invalidations, not a replayable runtime
|
||||
event stream; reconnect fetches the current image rather than replaying history.
|
||||
|
||||
## Execution stack inspection
|
||||
|
||||
Right-click an actor and choose **Inspect Execution Stack...** (also available
|
||||
in the Command Palette). Select a frame to open the exact loaded Rune source
|
||||
in a read-only `runa-execution:` document and highlight its source span.
|
||||
The picker identifies the incarnation, completed VM steps, functions, and source
|
||||
positions. The current frame points to the next instruction, or the pending
|
||||
await instruction at the last safe snapshot; callers point to call sites, not
|
||||
their saved return addresses. At most 64 innermost frames are shown, with an
|
||||
explicit truncation notice. Missing debug locations are labeled unavailable.
|
||||
|
||||
This never advances the scheduler or pauses the actor. Pause first for a stable
|
||||
snapshot; running inspection is a point-in-time view. Idle/failed actors have no
|
||||
active stack, and removed actors cannot be inspected. Pending source/temporary
|
||||
behavior changes do not replace the loaded code. Open loaded documents retain
|
||||
their text after edits, actor removal, and disconnect; connections use distinct
|
||||
URIs. These are not source editors or temporary behavior documents.
|
||||
|
||||
Only the head Rune VM's visible frames are available, not native Rust frames or
|
||||
stacks hidden inside nested futures. There are no locals, post-mortem stacks,
|
||||
breakpoints, source stepping, or Debug Adapter Protocol support yet.
|
||||
For automation, `runa.inspectExecution` accepts `{actor_ref: string}` and an
|
||||
optional zero-based frame index to navigate without the picker.
|
||||
|
||||
For automation, the `runa.connect` command also accepts an explicit endpoint
|
||||
string, and `runa.inspect` accepts an object with an `actor_ref` field.
|
||||
**Copy Actor Reference** in the actor context menu or inline button copies the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { ImageClient } from "../src/client";
|
||||
import { parseValue } from "../src/values";
|
||||
import { startImage } from "./server";
|
||||
|
||||
test(
|
||||
"execution inspection returns exact loaded code and mapped nested frames without mutations",
|
||||
{ timeout: 10000 },
|
||||
async (t) => {
|
||||
const image = await startImage();
|
||||
let changes = 0;
|
||||
const client = await ImageClient.connect(
|
||||
image.address,
|
||||
(error) => assert.equal(error, undefined),
|
||||
() => changes++,
|
||||
);
|
||||
t.after(async () => {
|
||||
await client.close();
|
||||
await image.stop();
|
||||
});
|
||||
const source =
|
||||
"pub fn handle(ctx, state, sender, message) {\n nested()\n}\nfn nested() {\n loop {}\n}";
|
||||
const file = await client.writeSource("actors/debug", null, source);
|
||||
const actor = await client.spawn(file.path, parseValue("null"));
|
||||
assert.equal((await client.inspectExecution(actor)).execution, null);
|
||||
await client.send(actor, parseValue("null"));
|
||||
await client.tick(1, 16);
|
||||
const before = await client.inspectActor(actor);
|
||||
const count = changes;
|
||||
const inspection = await client.inspectExecution(actor);
|
||||
assert.equal(changes, count, "read-only inspection does not broadcast image_changed");
|
||||
assert.deepEqual(await client.inspectActor(actor), before);
|
||||
assert.equal(inspection.source_id, file.source_id);
|
||||
assert.equal(inspection.source, source);
|
||||
assert.equal(inspection.implementation_id, before.implementation_id);
|
||||
const execution = inspection.execution;
|
||||
assert.ok(execution);
|
||||
assert.equal(execution.vm_steps, before.execution?.vm_steps);
|
||||
assert.equal(execution.call_depth, 1n);
|
||||
assert.deepEqual(
|
||||
execution.frames.map((frame) => frame.function),
|
||||
["nested", "handle"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
execution.frames.map((frame) => frame.range?.start.line),
|
||||
[4, 1],
|
||||
);
|
||||
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: "pause" });
|
||||
await client.tick(1, 1);
|
||||
const paused = await client.inspectExecution(actor);
|
||||
await client.tick(1, 100);
|
||||
assert.deepEqual(await client.inspectExecution(actor), paused);
|
||||
await client.system(actor, { kind: "kill" });
|
||||
await assert.rejects(client.inspectExecution(actor), /unknown actor/);
|
||||
},
|
||||
);
|
||||
|
|
@ -6,6 +6,9 @@ import { actorFileUri } from "../src/actorFiles";
|
|||
import type { activate } from "../src/extension";
|
||||
import { SourceDiagnostics } from "../src/diagnostics";
|
||||
import { SourceDiagnostic } from "../src/protocol";
|
||||
import { ImageClient } from "../src/client";
|
||||
import { parseValue } from "../src/values";
|
||||
import { ExecutionDocuments, frameSelection } from "../src/executionProvider";
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
const image = await startImage();
|
||||
|
|
@ -41,6 +44,7 @@ export async function run(): Promise<void> {
|
|||
);
|
||||
await editSources(image.address);
|
||||
await checkSourceDiagnostics(image.address);
|
||||
await inspectExecution(image.address);
|
||||
await editActorFiles(image.address);
|
||||
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
|
||||
path: "actors/counter",
|
||||
|
|
@ -242,6 +246,99 @@ export async function run(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function inspectExecution(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 nested()\n}\nfn nested() {\n loop {}\n}";
|
||||
const file = await client.writeSource("tests/execution", null, source);
|
||||
const actor = await client.spawn(file.path, parseValue("null"));
|
||||
await client.send(actor, parseValue("null"));
|
||||
await client.tick(1, 16);
|
||||
await client.system(actor, { kind: "pause" });
|
||||
await client.tick(1, 1);
|
||||
const loaded = await client.inspectExecution(actor);
|
||||
const location = loaded.execution?.frames[0]?.range;
|
||||
assert.ok(location);
|
||||
await client.writeSource(
|
||||
file.path,
|
||||
file.source_id,
|
||||
"pub fn handle(ctx, state, sender, message) { 42 }",
|
||||
);
|
||||
await client.system(actor, { kind: "reload" });
|
||||
await vscode.commands.executeCommand("runa.inspectExecution", { actor_ref: actor }, 0);
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
assert.ok(editor);
|
||||
const document = editor.document;
|
||||
assert.equal(document.uri.scheme, "runa-execution");
|
||||
assert.equal(document.languageId, "rune");
|
||||
const frame = loaded.execution?.frames[0];
|
||||
assert.ok(frame);
|
||||
assert.throws(
|
||||
() =>
|
||||
frameSelection(document, {
|
||||
...frame,
|
||||
range: {
|
||||
start: { line: document.lineCount, character: 0 },
|
||||
end: { line: document.lineCount, character: 0 },
|
||||
},
|
||||
}),
|
||||
/outside the loaded source/,
|
||||
);
|
||||
assert.equal(frameSelection(document, { ...frame, range: null }), undefined);
|
||||
const cache = new ExecutionDocuments();
|
||||
const cached = cache.set(1, loaded);
|
||||
assert.throws(
|
||||
() => cache.set(1, { ...loaded, source: "changed" }),
|
||||
/Immutable execution source changed/,
|
||||
);
|
||||
assert.equal(cache.provideTextDocumentContent(cached), source);
|
||||
cache.remove(cached);
|
||||
assert.throws(() => cache.provideTextDocumentContent(cached), /no longer cached/);
|
||||
cache.dispose();
|
||||
assert.equal(
|
||||
document.getText(),
|
||||
source,
|
||||
"navigation opens loaded code, not newer shared source",
|
||||
);
|
||||
assert.equal(editor.selection.start.line, location.start.line);
|
||||
assert.equal(editor.selection.start.character, location.start.character);
|
||||
assert.equal(editor.selection.end.character, location.end.character);
|
||||
await vscode.commands.executeCommand("type", { text: "// cannot edit\n" });
|
||||
assert.equal(document.getText(), source);
|
||||
assert.equal(document.isDirty, false);
|
||||
await vscode.commands.executeCommand("runa.inspectExecution", { actor_ref: actor }, 1);
|
||||
assert.equal(vscode.window.activeTextEditor?.document.uri.toString(), document.uri.toString());
|
||||
assert.equal(
|
||||
vscode.window.activeTextEditor?.selection.start.line,
|
||||
1,
|
||||
"caller selects call site, not return location",
|
||||
);
|
||||
await client.system(actor, { kind: "kill" });
|
||||
assert.equal(document.getText(), source, "loaded document survives actor removal");
|
||||
const firstUri = document.uri.toString();
|
||||
await vscode.commands.executeCommand("runa.disconnect");
|
||||
assert.equal(document.getText(), source, "loaded document survives disconnect");
|
||||
await vscode.commands.executeCommand("runa.connect", address);
|
||||
const second = await client.spawn(file.path, parseValue("null"));
|
||||
const temporary = await client.actorBehavior(second);
|
||||
await client.writeActorBehavior(second, temporary.source_id, source);
|
||||
await client.send(second, parseValue("null"));
|
||||
await client.tick(1, 16);
|
||||
await vscode.commands.executeCommand("runa.inspectExecution", { actor_ref: second }, 0);
|
||||
const reopened = vscode.window.activeTextEditor?.document;
|
||||
assert.ok(reopened);
|
||||
assert.notEqual(reopened.uri.toString(), firstUri, "loaded documents are connection-scoped");
|
||||
assert.equal(reopened.getText(), source, "temporary implementations expose their exact source");
|
||||
await client.system(second, { kind: "kill" });
|
||||
console.log(
|
||||
"Runa execution inspection: exact loaded source, caller navigation, read-only documents and session isolation passed",
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function editSources(address: string): Promise<void> {
|
||||
const extension = vscode.extensions.all.find(
|
||||
(candidate) => candidate.packageJSON.name === "runa",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@
|
|||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"command": "runa.inspectExecution",
|
||||
"title": "Inspect Execution Stack...",
|
||||
"category": "Runa",
|
||||
"enablement": "runa.connected"
|
||||
},
|
||||
{
|
||||
"command": "runa.newSource",
|
||||
"title": "New Source File...",
|
||||
|
|
@ -305,6 +311,11 @@
|
|||
}
|
||||
],
|
||||
"view/item/context": [
|
||||
{
|
||||
"command": "runa.inspectExecution",
|
||||
"when": "view == runa.actors && viewItem == runa.actor && runa.connected",
|
||||
"group": "debug@1"
|
||||
},
|
||||
{
|
||||
"command": "runa.newSource",
|
||||
"when": "view == runa.sources && viewItem == runa.sourceDirectory && runa.connected",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import WebSocket, { RawData } from "ws";
|
|||
import { stringify } from "lossless-json";
|
||||
import {
|
||||
ActorSnapshot,
|
||||
ExecutionInspection,
|
||||
decodeNotification,
|
||||
decodeResponse,
|
||||
decodeValue,
|
||||
|
|
@ -226,6 +227,15 @@ export class ImageClient {
|
|||
return reply.data;
|
||||
}
|
||||
|
||||
async inspectExecution(actorRef: string): Promise<ExecutionInspection> {
|
||||
if (!isActorRef(actorRef)) throw new Error("Invalid actor reference");
|
||||
const reply = await this.request({
|
||||
operation: "inspect_execution",
|
||||
parameters: { actor_ref: actorRef },
|
||||
});
|
||||
if (reply.kind !== "execution_inspected") throw new Error("Expected execution inspection");
|
||||
return reply.data;
|
||||
}
|
||||
async listSources(): Promise<SourceFile[]> {
|
||||
const reply = await this.request({ operation: "list_sources" });
|
||||
if (reply.kind !== "sources") throw new Error("Expected source listing");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import * as vscode from "vscode";
|
||||
import { ExecutionInspection, StackFrame } from "./protocol";
|
||||
|
||||
export const EXECUTION_SCHEME = "runa-execution";
|
||||
|
||||
/** Immutable loaded code, independent of mutable source and temporary behavior documents. */
|
||||
export class ExecutionDocuments implements vscode.TextDocumentContentProvider {
|
||||
private readonly sources = new Map<string, string>();
|
||||
|
||||
set(session: number, inspection: ExecutionInspection): vscode.Uri {
|
||||
const uri = vscode.Uri.from({
|
||||
scheme: EXECUTION_SCHEME,
|
||||
authority: String(session),
|
||||
path: `/loaded/${inspection.source_id}.rn`,
|
||||
});
|
||||
const previous = this.sources.get(uri.toString());
|
||||
if (previous !== undefined && previous !== inspection.source)
|
||||
throw new Error("Immutable execution source changed");
|
||||
this.sources.set(uri.toString(), inspection.source);
|
||||
return uri;
|
||||
}
|
||||
|
||||
provideTextDocumentContent(uri: vscode.Uri): string {
|
||||
const source = this.sources.get(uri.toString());
|
||||
if (source === undefined)
|
||||
throw new Error("Execution source is no longer cached; inspect the stack again");
|
||||
return source;
|
||||
}
|
||||
|
||||
remove(uri: vscode.Uri): void {
|
||||
this.sources.delete(uri.toString());
|
||||
}
|
||||
dispose(): void {
|
||||
this.sources.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function frameSelection(
|
||||
document: vscode.TextDocument,
|
||||
frame: StackFrame,
|
||||
): vscode.Range | undefined {
|
||||
if (!frame.range) return undefined;
|
||||
const { start, end } = frame.range;
|
||||
const range = new vscode.Range(start.line, start.character, end.line, end.character);
|
||||
if (!document.validateRange(range).isEqual(range))
|
||||
throw new Error("Execution location is outside the loaded source");
|
||||
return range;
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { parseValue, formatValue } from "./values";
|
|||
import { SnapshotDocuments } from "./snapshotProvider";
|
||||
import { canEditActor, mailboxValue, mailboxEdit } from "./snapshotEdits";
|
||||
import { SourceDiagnostics } from "./diagnostics";
|
||||
import { ExecutionDocuments, EXECUTION_SCHEME, frameSelection } from "./executionProvider";
|
||||
import { ActorFiles, ACTOR_SCHEME, ACTOR_FILES, actorFileUri } from "./actorFiles";
|
||||
import {
|
||||
ImageSourceFileSystem,
|
||||
|
|
@ -122,6 +123,7 @@ export function activate(context: vscode.ExtensionContext): {
|
|||
const sourceTree = new ImageSourceTree(sources);
|
||||
const sourceView = vscode.window.createTreeView("runa.sources", { treeDataProvider: sourceTree });
|
||||
const revisions = new RevisionDocuments();
|
||||
const executions = new ExecutionDocuments();
|
||||
const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree });
|
||||
const output = vscode.window.createOutputChannel("Runa");
|
||||
const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
|
||||
|
|
@ -333,6 +335,58 @@ export function activate(context: vscode.ExtensionContext): {
|
|||
};
|
||||
};
|
||||
|
||||
const inspectExecution = async (selected?: { actor_ref: string }, frameIndex?: number) => {
|
||||
const connection = active();
|
||||
const actorRef = await chooseActor(selected);
|
||||
if (!actorRef) return;
|
||||
connection.check();
|
||||
const inspection = await connection.client.inspectExecution(actorRef);
|
||||
connection.check();
|
||||
if (!inspection.execution) {
|
||||
await vscode.window.showInformationMessage("Runa: this actor has no active invocation.");
|
||||
return;
|
||||
}
|
||||
const execution = inspection.execution;
|
||||
if (
|
||||
frameIndex !== undefined &&
|
||||
(!Number.isInteger(frameIndex) || frameIndex < 0 || frameIndex >= execution.frames.length)
|
||||
)
|
||||
throw new Error("Execution frame index is out of range");
|
||||
const choices = execution.frames.map((frame, index) => ({
|
||||
label: `${index}: ${frame.function ?? "<unknown function>"}`,
|
||||
description: frame.range
|
||||
? `${inspection.behavior_path ?? "loaded source"}:${frame.range.start.line + 1}:${frame.range.start.character + 1}`
|
||||
: "source location unavailable",
|
||||
detail:
|
||||
index === 0
|
||||
? execution.awaiting
|
||||
? "Pending await instruction (last safe snapshot)"
|
||||
: "Next instruction"
|
||||
: "Caller at call instruction",
|
||||
frame,
|
||||
}));
|
||||
const chosen =
|
||||
frameIndex === undefined
|
||||
? await vscode.window.showQuickPick(choices, {
|
||||
title: `@${actorRef} / incarnation ${inspection.incarnation} / ${execution.vm_steps} completed steps`,
|
||||
placeHolder: execution.frames_truncated
|
||||
? "Choose a frame (innermost 64 shown; stack truncated)"
|
||||
: "Choose a frame to open its exact loaded source",
|
||||
})
|
||||
: choices[frameIndex];
|
||||
if (!chosen) return;
|
||||
connection.check();
|
||||
const uri = executions.set(connection.session, inspection);
|
||||
const document = await vscode.workspace.openTextDocument(uri);
|
||||
const selection = frameSelection(document, chosen.frame);
|
||||
connection.check();
|
||||
await vscode.window.showTextDocument(document, { preview: true, selection });
|
||||
if (!selection)
|
||||
await vscode.window.showWarningMessage(
|
||||
"Runa: source location is unavailable for this frame; showing the loaded actor source without a highlight.",
|
||||
);
|
||||
};
|
||||
|
||||
const jsonInput = (title: string, value?: string) =>
|
||||
vscode.window.showInputBox({
|
||||
title,
|
||||
|
|
@ -765,6 +819,7 @@ export function activate(context: vscode.ExtensionContext): {
|
|||
sourceTree,
|
||||
sourceView,
|
||||
revisions,
|
||||
executions,
|
||||
documents,
|
||||
actorFiles,
|
||||
diagnostics,
|
||||
|
|
@ -781,13 +836,16 @@ export function activate(context: vscode.ExtensionContext): {
|
|||
actorFiles.didSave(document);
|
||||
}),
|
||||
vscode.workspace.registerTextDocumentContentProvider(REVISION_SCHEME, revisions),
|
||||
vscode.workspace.registerTextDocumentContentProvider(EXECUTION_SCHEME, executions),
|
||||
vscode.workspace.onDidCloseTextDocument((document) => {
|
||||
revisions.remove(document.uri);
|
||||
executions.remove(document.uri);
|
||||
}),
|
||||
command("runa.connect", connect),
|
||||
command("runa.disconnect", disconnect),
|
||||
command("runa.refresh", refresh),
|
||||
command("runa.inspect", inspect),
|
||||
command("runa.inspectExecution", inspectExecution),
|
||||
command("runa.editBehavior", editBehavior),
|
||||
command("runa.goToBehavior", goToBehavior),
|
||||
command("runa.updateBehavior", updateBehavior),
|
||||
|
|
|
|||
|
|
@ -81,7 +81,32 @@ export interface SourceDiagnostic {
|
|||
} | null;
|
||||
}
|
||||
|
||||
export interface StackFrame {
|
||||
kind: "current" | "caller";
|
||||
instruction_pointer: bigint;
|
||||
source_instruction_pointer: bigint | null;
|
||||
function: string | null;
|
||||
range: SourceDiagnostic["range"];
|
||||
}
|
||||
|
||||
export interface ExecutionInspection {
|
||||
actor_ref: string;
|
||||
incarnation: bigint;
|
||||
implementation_id: string;
|
||||
source_id: string;
|
||||
source: string;
|
||||
behavior_path: string | null;
|
||||
execution: {
|
||||
vm_steps: bigint;
|
||||
awaiting: boolean;
|
||||
call_depth: bigint;
|
||||
frames: StackFrame[];
|
||||
frames_truncated: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type Command =
|
||||
| { operation: "inspect_execution"; parameters: { actor_ref: string } }
|
||||
| { operation: "check_source"; parameters: { source: string } }
|
||||
| { operation: "actor_behavior"; parameters: { actor_ref: string } }
|
||||
| {
|
||||
|
|
@ -318,6 +343,10 @@ function decodeSourceDiagnostic(input: unknown): SourceDiagnostic {
|
|||
throw new Error("Invalid diagnostic severity");
|
||||
const message = text(diagnostic.message);
|
||||
if (!message) throw new Error("Empty diagnostic message");
|
||||
return { severity: diagnostic.severity, message, range: decodeSourceRange(diagnostic.range) };
|
||||
}
|
||||
|
||||
function decodeSourceRange(input: unknown): SourceDiagnostic["range"] {
|
||||
const position = (input: unknown) => {
|
||||
const value = object(input);
|
||||
const line = uint(value.line),
|
||||
|
|
@ -326,20 +355,21 @@ function decodeSourceDiagnostic(input: unknown): SourceDiagnostic {
|
|||
throw new Error("Diagnostic coordinate exceeds editor limits");
|
||||
return { line: Number(line), character: Number(character) };
|
||||
};
|
||||
let range: SourceDiagnostic["range"] = null;
|
||||
if (diagnostic.range !== null) {
|
||||
const value = object(diagnostic.range);
|
||||
range = { start: position(value.start), end: position(value.end) };
|
||||
if (input !== null) {
|
||||
const value = object(input);
|
||||
const range = { start: position(value.start), end: position(value.end) };
|
||||
if (
|
||||
range.start.line > range.end.line ||
|
||||
(range.start.line === range.end.line && range.start.character > range.end.character)
|
||||
)
|
||||
throw new Error("Reversed diagnostic range");
|
||||
return range;
|
||||
}
|
||||
return { severity: diagnostic.severity, message, range };
|
||||
return null;
|
||||
}
|
||||
|
||||
export type Reply =
|
||||
| { kind: "execution_inspected"; data: ExecutionInspection }
|
||||
| { kind: "source_checked"; data: { diagnostics: SourceDiagnostic[] } }
|
||||
| { kind: "actor_behavior"; data: { source: string; source_id: string } }
|
||||
| { kind: "logs"; data: { entries: LogEntry[]; next_sequence: bigint; has_more: boolean } }
|
||||
|
|
@ -408,6 +438,62 @@ export function decodeResponse(line: string, requestId: string, command: Command
|
|||
);
|
||||
}
|
||||
const reply = object(result.Ok);
|
||||
if (command.operation === "inspect_execution" && reply.kind === "execution_inspected") {
|
||||
const data = object(reply.data);
|
||||
if (data.actor_ref !== command.parameters.actor_ref || !isActorRef(data.actor_ref))
|
||||
throw new Error("Execution actor mismatch");
|
||||
const implementation_id = text(data.implementation_id),
|
||||
source_id = text(data.source_id);
|
||||
if (!implementation_id || !source_id) throw new Error("Missing execution source identity");
|
||||
if (data.behavior_path !== null && !isSourcePath(data.behavior_path))
|
||||
throw new Error("Invalid execution behavior path");
|
||||
let execution: ExecutionInspection["execution"] = null;
|
||||
if (data.execution !== null) {
|
||||
const value = object(data.execution);
|
||||
if (typeof value.awaiting !== "boolean" || typeof value.frames_truncated !== "boolean")
|
||||
throw new Error("Invalid execution flags");
|
||||
const frames = array(value.frames).map((input, index): StackFrame => {
|
||||
const frame = object(input);
|
||||
const kind = index === 0 ? "current" : "caller";
|
||||
if (frame.kind !== kind) throw new Error("Invalid stack frame order");
|
||||
return {
|
||||
kind,
|
||||
instruction_pointer: uint(frame.instruction_pointer),
|
||||
source_instruction_pointer:
|
||||
frame.source_instruction_pointer === null
|
||||
? null
|
||||
: uint(frame.source_instruction_pointer),
|
||||
function: frame.function === null ? null : text(frame.function),
|
||||
range: decodeSourceRange(frame.range),
|
||||
};
|
||||
});
|
||||
const call_depth = uint(value.call_depth);
|
||||
if (
|
||||
frames.length !== Number(call_depth < 64n ? call_depth + 1n : 64n) ||
|
||||
value.frames_truncated !== call_depth >= 64n
|
||||
)
|
||||
throw new Error("Invalid stack frame count");
|
||||
execution = {
|
||||
vm_steps: uint(value.vm_steps),
|
||||
awaiting: value.awaiting,
|
||||
call_depth,
|
||||
frames,
|
||||
frames_truncated: value.frames_truncated,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "execution_inspected",
|
||||
data: {
|
||||
actor_ref: data.actor_ref,
|
||||
incarnation: uint(data.incarnation),
|
||||
implementation_id,
|
||||
source_id,
|
||||
source: text(data.source),
|
||||
behavior_path: data.behavior_path,
|
||||
execution,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command.operation === "check_source" && reply.kind === "source_checked") {
|
||||
return {
|
||||
kind: "source_checked",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,105 @@ const command: Command = { operation: "list_actors" };
|
|||
const envelope = (reply: unknown) =>
|
||||
json({ version: 5, request_id: "test", result: { Ok: reply } });
|
||||
|
||||
test("execution inspections preserve u64 fields and validate stack order, bounds, identities and ranges", () => {
|
||||
const command: Command = { operation: "inspect_execution", parameters: { actor_ref: REF } };
|
||||
const frame = {
|
||||
kind: "current",
|
||||
instruction_pointer: 18446744073709551615n,
|
||||
source_instruction_pointer: 18446744073709551615n,
|
||||
function: "nested",
|
||||
range: { start: { line: 1n, character: 2n }, end: { line: 1n, character: 5n } },
|
||||
};
|
||||
const execution = {
|
||||
vm_steps: 18446744073709551615n,
|
||||
awaiting: false,
|
||||
call_depth: 0n,
|
||||
frames: [frame],
|
||||
frames_truncated: false,
|
||||
};
|
||||
const data = {
|
||||
actor_ref: REF,
|
||||
incarnation: 18446744073709551615n,
|
||||
implementation_id: "impl",
|
||||
source_id: "source",
|
||||
source: "exact source",
|
||||
behavior_path: "actors/test",
|
||||
execution,
|
||||
};
|
||||
const decoded = decodeResponse(envelope({ kind: "execution_inspected", data }), "test", command);
|
||||
assert.equal(decoded.kind, "execution_inspected");
|
||||
if (decoded.kind !== "execution_inspected") throw new Error("unexpected reply");
|
||||
assert.equal(decoded.data.incarnation, data.incarnation);
|
||||
assert.deepEqual(decoded.data.execution?.frames[0]?.range, {
|
||||
start: { line: 1, character: 2 },
|
||||
end: { line: 1, character: 5 },
|
||||
});
|
||||
for (const change of [
|
||||
{ actor_ref: "ABCDEFGHIJKLMNOPQRSTU" },
|
||||
{ implementation_id: "" },
|
||||
{ source_id: "" },
|
||||
{ behavior_path: "../bad" },
|
||||
{ incarnation: -1n },
|
||||
{ execution: { ...execution, awaiting: "false" } },
|
||||
{ execution: { ...execution, frames: [] } },
|
||||
{ execution: { ...execution, frames_truncated: true } },
|
||||
{ execution: { ...execution, frames: [{ ...frame, kind: "caller" }] } },
|
||||
{ execution: { ...execution, frames: [{ ...frame, instruction_pointer: -1n }] } },
|
||||
{
|
||||
execution: {
|
||||
...execution,
|
||||
frames: [
|
||||
{
|
||||
...frame,
|
||||
range: { start: { line: 2n, character: 0n }, end: { line: 1n, character: 0n } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
execution: {
|
||||
...execution,
|
||||
frames: [
|
||||
{
|
||||
...frame,
|
||||
range: {
|
||||
start: { line: 9007199254740992n, character: 0n },
|
||||
end: { line: 9007199254740992n, character: 0n },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
assert.throws(() =>
|
||||
decodeResponse(
|
||||
envelope({ kind: "execution_inspected", data: { ...data, ...change } }),
|
||||
"test",
|
||||
command,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
decodeResponse(
|
||||
envelope({ kind: "execution_inspected", data: { ...data, execution: null } }),
|
||||
"test",
|
||||
command,
|
||||
).kind,
|
||||
"execution_inspected",
|
||||
);
|
||||
const unknown = {
|
||||
...execution,
|
||||
frames: [{ ...frame, source_instruction_pointer: null, function: null, range: null }],
|
||||
};
|
||||
assert.equal(
|
||||
decodeResponse(
|
||||
envelope({ kind: "execution_inspected", data: { ...data, execution: unknown } }),
|
||||
"test",
|
||||
command,
|
||||
).kind,
|
||||
"execution_inspected",
|
||||
);
|
||||
});
|
||||
|
||||
test("actor log lines use syslog priorities, exact sequence IDs and single-line messages", () => {
|
||||
const priorities = [135, 135, 134, 132, 131, 130];
|
||||
for (const [index, level] of LOG_LEVELS.entries()) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue