From b8080fe02ef2c70ad4a3279a43bf025b75dfa9dc Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Tue, 3 Nov 2026 20:37:05 +0100 Subject: [PATCH] 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> --- CHANGELOG.md | 5 + README.md | 6 +- crates/runa-core/src/execution.rs | 30 +++++ crates/runa-core/src/lib.rs | 2 + crates/runa-image-api/src/lib.rs | 134 +++++++++++++++++++++ crates/runa-protocol/src/lib.rs | 19 ++- crates/runa-rune/src/diagnostics.rs | 2 +- crates/runa-rune/src/execution.rs | 174 +++++++++++++++++++++++++-- crates/runa-rune/src/lib.rs | 69 ++++++++++- crates/runa-runtime/src/lib.rs | 19 +++ crates/runa/src/lib.rs | 15 +++ crates/runa/src/network.rs | 2 +- docs/architecture.md | 5 + docs/protocol.md | 30 +++++ docs/runtime.md | 19 +++ vscode/README.md | 24 ++++ vscode/integration/execution.test.ts | 64 ++++++++++ vscode/integration/host.ts | 97 +++++++++++++++ vscode/package.json | 11 ++ vscode/src/client.ts | 10 ++ vscode/src/executionProvider.ts | 48 ++++++++ vscode/src/extension.ts | 58 +++++++++ vscode/src/protocol.ts | 96 ++++++++++++++- vscode/test/protocol.test.ts | 99 +++++++++++++++ 24 files changed, 1018 insertions(+), 20 deletions(-) create mode 100644 crates/runa-core/src/execution.rs create mode 100644 vscode/integration/execution.test.ts create mode 100644 vscode/src/executionProvider.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a1c72f9..750b69d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 677ba35..7340923 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/runa-core/src/execution.rs b/crates/runa-core/src/execution.rs new file mode 100644 index 0000000..01046ae --- /dev/null +++ b/crates/runa-core/src/execution.rs @@ -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, + pub function: Option, + pub range: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExecutionInspection { + pub vm_steps: u64, + pub awaiting: bool, + pub call_depth: usize, + pub frames: Vec, + pub frames_truncated: bool, +} diff --git a/crates/runa-core/src/lib.rs b/crates/runa-core/src/lib.rs index ea169be..0e94d34 100644 --- a/crates/runa-core/src/lib.rs +++ b/crates/runa-core/src/lib.rs @@ -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}; diff --git a/crates/runa-image-api/src/lib.rs b/crates/runa-image-api/src/lib.rs index afa70a9..f5928ba 100644 --- a/crates/runa-image-api/src/lib.rs +++ b/crates/runa-image-api/src/lib.rs @@ -18,6 +18,16 @@ pub struct ImageApi { events: Vec, } +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, + pub execution: Option, +} + 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 { + 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(); diff --git a/crates/runa-protocol/src/lib.rs b/crates/runa-protocol/src/lib.rs index 0aedfd3..094d1bc 100644 --- a/crates/runa-protocol/src/lib.rs +++ b/crates/runa-protocol/src/lib.rs @@ -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, }, @@ -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, + pub execution: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ErrorCode { diff --git a/crates/runa-rune/src/diagnostics.rs b/crates/runa-rune/src/diagnostics.rs index f961753..c848a32 100644 --- a/crates/runa-rune/src/diagnostics.rs +++ b/crates/runa-rune/src/diagnostics.rs @@ -79,7 +79,7 @@ pub(crate) fn collect( output } -fn source_range(source: &str, span: Span) -> Option { +pub(crate) fn source_range(source: &str, span: Span) -> Option { let range = span.range(); let start = source.get(..range.start)?; let end = source.get(..range.end)?; diff --git a/crates/runa-rune/src/execution.rs b/crates/runa-rune/src/execution.rs index 436a0db..0bb40c4 100644 --- a/crates/runa-rune/src/execution.rs +++ b/crates/runa-rune/src/execution.rs @@ -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 { type ExecutionFuture = Pin>>>; +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, + pub instruction_pointer: usize, + pub return_addresses: Vec, + 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 { future: Option>, stepped: Rc>, progress: Rc>, + stack: Rc>, } impl Invocation { @@ -43,13 +88,19 @@ impl Invocation { 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 Invocation { future: Some(future), stepped, progress, + stack, }) } @@ -84,6 +136,10 @@ impl Invocation { 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 { let Some(future) = self.future.as_mut() else { @@ -99,7 +155,10 @@ impl Invocation { 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::::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::>(), + ["inner", "outer", "handle"] + ); + assert_eq!( + inspection + .frames + .iter() + .map(|frame| frame.range.unwrap().start.line) + .collect::>(), + [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::::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::::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 diff --git a/crates/runa-rune/src/lib.rs b/crates/runa-rune/src/lib.rs index c6208a0..ba8998d 100644 --- a/crates/runa-rune/src/lib.rs +++ b/crates/runa-rune/src/lib.rs @@ -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, unit: Arc, diagnostics: Vec, + 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)] diff --git a/crates/runa-runtime/src/lib.rs b/crates/runa-runtime/src/lib.rs index 5a46ab6..d0de09c 100644 --- a/crates/runa-runtime/src/lib.rs +++ b/crates/runa-runtime/src/lib.rs @@ -162,6 +162,25 @@ impl Runtime { Ok(snapshot) } + pub fn inspect_execution( + &self, + id: ActorRef, + ) -> ExperimentResult> { + 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, diff --git a/crates/runa/src/lib.rs b/crates/runa/src/lib.rs index cf406d2..fb9f346 100644 --- a/crates/runa/src/lib.rs +++ b/crates/runa/src/lib.rs @@ -65,6 +65,21 @@ impl Server { fn dispatch_command(&mut self, command: Command) -> Result { 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) diff --git a/crates/runa/src/network.rs b/crates/runa/src/network.rs index 7d6602f..339cf3e 100644 --- a/crates/runa/src/network.rs +++ b/crates/runa/src/network.rs @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 2c45e77..37859a4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/protocol.md b/docs/protocol.md index 08bf851..002d76c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 diff --git a/docs/runtime.md b/docs/runtime.md index 4a480af..69936c8 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -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 ` replaces committed state without touching the mailbox. diff --git a/vscode/README.md b/vscode/README.md index ef9ba3f..668cffd 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -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 diff --git a/vscode/integration/execution.test.ts b/vscode/integration/execution.test.ts new file mode 100644 index 0000000..6f8a61b --- /dev/null +++ b/vscode/integration/execution.test.ts @@ -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/); + }, +); diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index 763f963..f9f324d 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -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 { const image = await startImage(); @@ -41,6 +44,7 @@ export async function run(): Promise { ); await editSources(image.address); await checkSourceDiagnostics(image.address); + await inspectExecution(image.address); await editActorFiles(image.address); const spawned = await vscode.commands.executeCommand("runa.spawn", { path: "actors/counter", @@ -242,6 +246,99 @@ export async function run(): Promise { } } +async function inspectExecution(address: string): Promise { + const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); + try { + const source = + "pub fn handle(ctx, state, sender, message) {\n 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 { const extension = vscode.extensions.all.find( (candidate) => candidate.packageJSON.name === "runa", diff --git a/vscode/package.json b/vscode/package.json index 60a0061..37b178e 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -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", diff --git a/vscode/src/client.ts b/vscode/src/client.ts index 72f95fc..1dcf2e7 100644 --- a/vscode/src/client.ts +++ b/vscode/src/client.ts @@ -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 { + 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 { const reply = await this.request({ operation: "list_sources" }); if (reply.kind !== "sources") throw new Error("Expected source listing"); diff --git a/vscode/src/executionProvider.ts b/vscode/src/executionProvider.ts new file mode 100644 index 0000000..f3ab016 --- /dev/null +++ b/vscode/src/executionProvider.ts @@ -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(); + + 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; +} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index aca5472..cabed90 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -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 ?? ""}`, + 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), diff --git a/vscode/src/protocol.ts b/vscode/src/protocol.ts index e0eec8f..7e87731 100644 --- a/vscode/src/protocol.ts +++ b/vscode/src/protocol.ts @@ -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", diff --git a/vscode/test/protocol.test.ts b/vscode/test/protocol.test.ts index 1eae020..15ccd89 100644 --- a/vscode/test/protocol.test.ts +++ b/vscode/test/protocol.test.ts @@ -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()) {