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
|
### 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
|
- Structured Rune compilation errors and warnings through a read-only
|
||||||
`check_source` protocol operation and compilation-failure error details.
|
`check_source` protocol operation and compilation-failure error details.
|
||||||
- Automatic VS Code inline/Problems diagnostics for open image sources and
|
- 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
|
The prototype uses worker-local invocation stacks and cooperative instruction
|
||||||
slices, and actor snapshots report basic active-execution progress. Native
|
slices, and actor snapshots report basic active-execution progress. Native
|
||||||
callbacks are not preemptible; source-level debugging and persistent storage
|
callbacks are not preemptible. The debugger foundation exposes bounded active Rune
|
||||||
are not implemented.
|
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
|
## 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;
|
use std::fmt;
|
||||||
|
|
||||||
mod diagnostic;
|
mod diagnostic;
|
||||||
|
mod execution;
|
||||||
mod log;
|
mod log;
|
||||||
mod value;
|
mod value;
|
||||||
pub use diagnostic::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
|
pub use diagnostic::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
|
||||||
|
pub use execution::{ExecutionInspection, StackFrame, StackFrameKind};
|
||||||
pub use log::{LogEntry, LogLevel};
|
pub use log::{LogEntry, LogLevel};
|
||||||
pub use value::{ActorRef, MAX_VALUE_BYTES, MAX_VALUE_DEPTH, MAX_VALUE_NODES, Value};
|
pub use value::{ActorRef, MAX_VALUE_BYTES, MAX_VALUE_DEPTH, MAX_VALUE_NODES, Value};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,16 @@ pub struct ImageApi {
|
||||||
events: Vec<Event>,
|
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 {
|
impl Default for ImageApi {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
|
|
@ -338,6 +348,23 @@ impl ImageApi {
|
||||||
self.runtime.inspect(actor)
|
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(
|
pub fn edit_actor(
|
||||||
&mut self,
|
&mut self,
|
||||||
actor: ActorRef,
|
actor: ActorRef,
|
||||||
|
|
@ -571,6 +598,113 @@ mod tests {
|
||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn validation_never_mutates_sources_actors_or_compile_cache() -> RuntimeResult<()> {
|
fn validation_never_mutates_sources_actors_or_compile_cache() -> RuntimeResult<()> {
|
||||||
let mut image = ImageApi::new();
|
let mut image = ImageApi::new();
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
pub use runa_core::Value;
|
pub use runa_core::Value;
|
||||||
pub use runa_core::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
|
pub use runa_core::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
|
||||||
|
pub use runa_core::{ExecutionInspection, StackFrame, StackFrameKind};
|
||||||
pub use runa_core::{LogEntry, LogLevel};
|
pub use runa_core::{LogEntry, LogLevel};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
@ -53,7 +54,8 @@ impl Request {
|
||||||
validate_name(target)?;
|
validate_name(target)?;
|
||||||
}
|
}
|
||||||
Command::DeleteSource { path, .. } => validate_name(path)?,
|
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::WriteActorBehavior { actor_ref, .. } => validate_actor_ref(actor_ref)?,
|
||||||
Command::UpdateBehaviorSource {
|
Command::UpdateBehaviorSource {
|
||||||
actor_ref, path, ..
|
actor_ref, path, ..
|
||||||
|
|
@ -173,6 +175,9 @@ impl Request {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "operation", content = "parameters", rename_all = "snake_case")]
|
#[serde(tag = "operation", content = "parameters", rename_all = "snake_case")]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
|
InspectExecution {
|
||||||
|
actor_ref: String,
|
||||||
|
},
|
||||||
CheckSource {
|
CheckSource {
|
||||||
source: String,
|
source: String,
|
||||||
},
|
},
|
||||||
|
|
@ -323,6 +328,7 @@ pub struct Response {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
|
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
|
||||||
pub enum Reply {
|
pub enum Reply {
|
||||||
|
ExecutionInspected(ExecutionInspectionDto),
|
||||||
SourceChecked {
|
SourceChecked {
|
||||||
diagnostics: Vec<SourceDiagnostic>,
|
diagnostics: Vec<SourceDiagnostic>,
|
||||||
},
|
},
|
||||||
|
|
@ -490,6 +496,17 @@ pub struct ExecutionSnapshotDto {
|
||||||
pub call_depth: usize,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ErrorCode {
|
pub enum ErrorCode {
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ pub(crate) fn collect(
|
||||||
output
|
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 range = span.range();
|
||||||
let start = source.get(..range.start)?;
|
let start = source.get(..range.start)?;
|
||||||
let end = source.get(..range.end)?;
|
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::future::{Future, poll_fn};
|
||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::task::{Context, Poll};
|
use std::task::{Context, Poll};
|
||||||
|
|
||||||
use rune::runtime::{Args, VmError};
|
use rune::runtime::{Args, Unit, VmError};
|
||||||
use rune::{FromValue, Vm};
|
use rune::{FromValue, Vm};
|
||||||
|
|
||||||
use crate::ExperimentResult;
|
use crate::ExperimentResult;
|
||||||
|
|
@ -28,10 +29,54 @@ pub enum Slice<T> {
|
||||||
|
|
||||||
type ExecutionFuture<T> = Pin<Box<dyn Future<Output = ExperimentResult<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> {
|
pub struct Invocation<T> {
|
||||||
future: Option<ExecutionFuture<T>>,
|
future: Option<ExecutionFuture<T>>,
|
||||||
stepped: Rc<Cell<bool>>,
|
stepped: Rc<Cell<bool>>,
|
||||||
progress: Rc<Cell<InvocationProgress>>,
|
progress: Rc<Cell<InvocationProgress>>,
|
||||||
|
stack: Rc<RefCell<InvocationStack>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: FromValue + 'static> Invocation<T> {
|
impl<T: FromValue + 'static> Invocation<T> {
|
||||||
|
|
@ -43,13 +88,19 @@ impl<T: FromValue + 'static> Invocation<T> {
|
||||||
execution.vm_mut().set_ip(entrypoint);
|
execution.vm_mut().set_ip(entrypoint);
|
||||||
let stepped = Rc::new(Cell::new(false));
|
let stepped = Rc::new(Cell::new(false));
|
||||||
let marker = Rc::clone(&stepped);
|
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 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 {
|
let future = Box::pin(async move {
|
||||||
loop {
|
loop {
|
||||||
let result = execution.async_step().await;
|
let result = execution.async_step().await;
|
||||||
let vm = execution.vm();
|
let vm = execution.vm();
|
||||||
let previous = inspector.get();
|
let previous = inspector.get();
|
||||||
|
stack_inspector.borrow_mut().update(vm);
|
||||||
inspector.set(InvocationProgress {
|
inspector.set(InvocationProgress {
|
||||||
vm_steps: previous.vm_steps.saturating_add(1),
|
vm_steps: previous.vm_steps.saturating_add(1),
|
||||||
instruction_pointer: vm.ip(),
|
instruction_pointer: vm.ip(),
|
||||||
|
|
@ -76,6 +127,7 @@ impl<T: FromValue + 'static> Invocation<T> {
|
||||||
future: Some(future),
|
future: Some(future),
|
||||||
stepped,
|
stepped,
|
||||||
progress,
|
progress,
|
||||||
|
stack,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +136,10 @@ impl<T: FromValue + 'static> Invocation<T> {
|
||||||
self.progress.get()
|
self.progress.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn stack(&self) -> InvocationStack {
|
||||||
|
self.stack.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Bound VM steps per call, preserving the exact pending async step.
|
/// Bound VM steps per call, preserving the exact pending async step.
|
||||||
pub fn poll_slice(&mut self, cx: &mut Context<'_>, budget: NonZeroUsize) -> Slice<T> {
|
pub fn poll_slice(&mut self, cx: &mut Context<'_>, budget: NonZeroUsize) -> Slice<T> {
|
||||||
let Some(future) = self.future.as_mut() else {
|
let Some(future) = self.future.as_mut() else {
|
||||||
|
|
@ -99,7 +155,10 @@ impl<T: FromValue + 'static> Invocation<T> {
|
||||||
Err(error) => Slice::Failed(error),
|
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 => {}
|
Poll::Pending => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -112,11 +171,101 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::CompiledProgram;
|
use crate::CompiledProgram;
|
||||||
use rune::Module;
|
use rune::Module;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Mutex;
|
||||||
use std::task::{Wake, Waker};
|
use std::task::{Wake, Waker};
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
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]
|
#[test]
|
||||||
fn shutdown_invocation_preserves_state_argument() -> ExperimentResult<()> {
|
fn shutdown_invocation_preserves_state_argument() -> ExperimentResult<()> {
|
||||||
let program = CompiledProgram::compile(
|
let program = CompiledProgram::compile(
|
||||||
|
|
@ -186,10 +335,8 @@ mod tests {
|
||||||
module
|
module
|
||||||
.function("wait", move || PendingOperation(Arc::clone(&native_signal)))
|
.function("wait", move || PendingOperation(Arc::clone(&native_signal)))
|
||||||
.build()?;
|
.build()?;
|
||||||
let program = CompiledProgram::compile_with_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 }";
|
||||||
"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)?;
|
||||||
module,
|
|
||||||
)?;
|
|
||||||
let counter = Arc::new(WakeCounter(std::sync::atomic::AtomicUsize::new(0)));
|
let counter = Arc::new(WakeCounter(std::sync::atomic::AtomicUsize::new(0)));
|
||||||
let waker = Waker::from(Arc::clone(&counter));
|
let waker = Waker::from(Arc::clone(&counter));
|
||||||
let mut cx = Context::from_waker(&waker);
|
let mut cx = Context::from_waker(&waker);
|
||||||
|
|
@ -210,6 +357,15 @@ mod tests {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.take()
|
.take()
|
||||||
.expect("await registered waker");
|
.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!(
|
assert!(matches!(
|
||||||
invocation.poll_slice(&mut cx, budget),
|
invocation.poll_slice(&mut cx, budget),
|
||||||
Slice::Awaiting
|
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";
|
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;
|
mod execution;
|
||||||
pub use execution::{Invocation, InvocationProgress, Slice};
|
pub use execution::{Invocation, InvocationProgress, InvocationStack, Slice};
|
||||||
mod value;
|
mod value;
|
||||||
pub use value::{value_from_rune, value_to_rune};
|
pub use value::{value_from_rune, value_to_rune};
|
||||||
mod context;
|
mod context;
|
||||||
|
|
@ -52,6 +52,7 @@ pub struct CompiledProgram {
|
||||||
runtime: Arc<RuntimeContext>,
|
runtime: Arc<RuntimeContext>,
|
||||||
unit: Arc<Unit>,
|
unit: Arc<Unit>,
|
||||||
diagnostics: Vec<runa_core::SourceDiagnostic>,
|
diagnostics: Vec<runa_core::SourceDiagnostic>,
|
||||||
|
source_id: rune::SourceId,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CompiledProgram {
|
impl CompiledProgram {
|
||||||
|
|
@ -146,6 +147,7 @@ impl CompiledProgram {
|
||||||
runtime: Arc::new(context.runtime()?),
|
runtime: Arc::new(context.runtime()?),
|
||||||
unit: Arc::new(unit),
|
unit: Arc::new(unit),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
|
source_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,6 +158,71 @@ impl CompiledProgram {
|
||||||
pub fn has_function(&self, name: &str) -> bool {
|
pub fn has_function(&self, name: &str) -> bool {
|
||||||
self.vm().lookup_function([name]).is_ok()
|
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)]
|
#[derive(Debug)]
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,25 @@ impl Runtime {
|
||||||
Ok(snapshot)
|
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(
|
pub fn edit_actor(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: ActorRef,
|
id: ActorRef,
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,21 @@ impl Server {
|
||||||
|
|
||||||
fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> {
|
fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> {
|
||||||
match command {
|
match command {
|
||||||
|
Command::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
|
Command::CheckSource { source } => self
|
||||||
.image
|
.image
|
||||||
.check_source(&source)
|
.check_source(&source)
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ async fn serve(listener: TcpListener, mut server: Server) -> io::Result<()> {
|
||||||
Command::GetBehaviorSource { .. } | Command::InspectActor { .. } |
|
Command::GetBehaviorSource { .. } | Command::InspectActor { .. } |
|
||||||
Command::ListActors | Command::GetEvents | Command::ListSources |
|
Command::ListActors | Command::GetEvents | Command::ListSources |
|
||||||
Command::ReadSource { .. } | Command::SourceHistory { .. } |
|
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 response = server.dispatch(dispatch.request);
|
||||||
let changed = mutates && response.result.is_ok();
|
let changed = mutates && response.result.is_ok();
|
||||||
// Dispatch may commit even if the initiating client has disconnected.
|
// 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
|
including unsaved text. Validation does not mutate the image; source positions
|
||||||
use UTF-16 editor coordinates. Single-source checking is implemented, not an LSP
|
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.
|
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
|
Non-destructive image-change notifications and automatic snapshot refresh are
|
||||||
implemented over the shared `runa-debug` WebSocket transport.
|
implemented over the shared `runa-debug` WebSocket transport.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ behavior aliases. They exclude the `source/` root and `.rn` extension.
|
||||||
| `revision_source` | `path`, `revision` | `behavior_source`: `{source}` |
|
| `revision_source` | `path`, `revision` | `behavior_source`: `{source}` |
|
||||||
| `rollback_source` | `path`, `revision` | `source`: restored file |
|
| `rollback_source` | `path`, `revision` | `source`: restored file |
|
||||||
| `spawn` | `path`, tagged `state`, positive `mailbox_capacity` | `actor_spawned`: `{actor_ref}` |
|
| `spawn` | `path`, tagged `state`, positive `mailbox_capacity` | `actor_spawned`: `{actor_ref}` |
|
||||||
|
| `inspect_execution` | `actor_ref` | `execution_inspected`: loaded source and optional execution stack |
|
||||||
| `system` | `actor_ref`, `message` | `acknowledged` |
|
| `system` | `actor_ref`, `message` | `acknowledged` |
|
||||||
|
|
||||||
A file is `{path, source_id, source}` and a commit is
|
A file is `{path, source_id, source}` and a commit is
|
||||||
|
|
@ -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
|
empty. Source checking is single-file; it does not resolve other image files
|
||||||
as modules.
|
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"}`
|
System message shape is `{kind: "stop"|"kill"|"restart"|"pause"|"resume"|"reload"}`
|
||||||
or `{kind: "become", path: "actors/other"}`. This is a trusted host request;
|
or `{kind: "become", path: "actors/other"}`. This is a trusted host request;
|
||||||
actor `ctx.send` and `ctx.send_from` accept only ordinary data. Stop runs optional
|
actor `ctx.send` and `ctx.send_from` accept only ordinary data. Stop runs optional
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,24 @@
|
||||||
# Phase 1 actor prototype
|
# 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
|
## Actor repair and logging
|
||||||
|
|
||||||
`set-state <actor> <json>` replaces committed state without touching the mailbox.
|
`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
|
Notifications are non-destructive invalidations, not a replayable runtime
|
||||||
event stream; reconnect fetches the current image rather than replaying history.
|
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
|
For automation, the `runa.connect` command also accepts an explicit endpoint
|
||||||
string, and `runa.inspect` accepts an object with an `actor_ref` field.
|
string, and `runa.inspect` accepts an object with an `actor_ref` field.
|
||||||
**Copy Actor Reference** in the actor context menu or inline button copies the
|
**Copy Actor Reference** in the actor context menu or inline button copies the
|
||||||
|
|
|
||||||
|
|
@ -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 type { activate } from "../src/extension";
|
||||||
import { SourceDiagnostics } from "../src/diagnostics";
|
import { SourceDiagnostics } from "../src/diagnostics";
|
||||||
import { SourceDiagnostic } from "../src/protocol";
|
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> {
|
export async function run(): Promise<void> {
|
||||||
const image = await startImage();
|
const image = await startImage();
|
||||||
|
|
@ -41,6 +44,7 @@ export async function run(): Promise<void> {
|
||||||
);
|
);
|
||||||
await editSources(image.address);
|
await editSources(image.address);
|
||||||
await checkSourceDiagnostics(image.address);
|
await checkSourceDiagnostics(image.address);
|
||||||
|
await inspectExecution(image.address);
|
||||||
await editActorFiles(image.address);
|
await editActorFiles(image.address);
|
||||||
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
|
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
|
||||||
path: "actors/counter",
|
path: "actors/counter",
|
||||||
|
|
@ -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> {
|
async function editSources(address: string): Promise<void> {
|
||||||
const extension = vscode.extensions.all.find(
|
const extension = vscode.extensions.all.find(
|
||||||
(candidate) => candidate.packageJSON.name === "runa",
|
(candidate) => candidate.packageJSON.name === "runa",
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,12 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"commands": [
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "runa.inspectExecution",
|
||||||
|
"title": "Inspect Execution Stack...",
|
||||||
|
"category": "Runa",
|
||||||
|
"enablement": "runa.connected"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "runa.newSource",
|
"command": "runa.newSource",
|
||||||
"title": "New Source File...",
|
"title": "New Source File...",
|
||||||
|
|
@ -305,6 +311,11 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"view/item/context": [
|
"view/item/context": [
|
||||||
|
{
|
||||||
|
"command": "runa.inspectExecution",
|
||||||
|
"when": "view == runa.actors && viewItem == runa.actor && runa.connected",
|
||||||
|
"group": "debug@1"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "runa.newSource",
|
"command": "runa.newSource",
|
||||||
"when": "view == runa.sources && viewItem == runa.sourceDirectory && runa.connected",
|
"when": "view == runa.sources && viewItem == runa.sourceDirectory && runa.connected",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import WebSocket, { RawData } from "ws";
|
||||||
import { stringify } from "lossless-json";
|
import { stringify } from "lossless-json";
|
||||||
import {
|
import {
|
||||||
ActorSnapshot,
|
ActorSnapshot,
|
||||||
|
ExecutionInspection,
|
||||||
decodeNotification,
|
decodeNotification,
|
||||||
decodeResponse,
|
decodeResponse,
|
||||||
decodeValue,
|
decodeValue,
|
||||||
|
|
@ -226,6 +227,15 @@ export class ImageClient {
|
||||||
return reply.data;
|
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[]> {
|
async listSources(): Promise<SourceFile[]> {
|
||||||
const reply = await this.request({ operation: "list_sources" });
|
const reply = await this.request({ operation: "list_sources" });
|
||||||
if (reply.kind !== "sources") throw new Error("Expected source listing");
|
if (reply.kind !== "sources") throw new Error("Expected source listing");
|
||||||
|
|
|
||||||
|
|
@ -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 { SnapshotDocuments } from "./snapshotProvider";
|
||||||
import { canEditActor, mailboxValue, mailboxEdit } from "./snapshotEdits";
|
import { canEditActor, mailboxValue, mailboxEdit } from "./snapshotEdits";
|
||||||
import { SourceDiagnostics } from "./diagnostics";
|
import { SourceDiagnostics } from "./diagnostics";
|
||||||
|
import { ExecutionDocuments, EXECUTION_SCHEME, frameSelection } from "./executionProvider";
|
||||||
import { ActorFiles, ACTOR_SCHEME, ACTOR_FILES, actorFileUri } from "./actorFiles";
|
import { ActorFiles, ACTOR_SCHEME, ACTOR_FILES, actorFileUri } from "./actorFiles";
|
||||||
import {
|
import {
|
||||||
ImageSourceFileSystem,
|
ImageSourceFileSystem,
|
||||||
|
|
@ -122,6 +123,7 @@ export function activate(context: vscode.ExtensionContext): {
|
||||||
const sourceTree = new ImageSourceTree(sources);
|
const sourceTree = new ImageSourceTree(sources);
|
||||||
const sourceView = vscode.window.createTreeView("runa.sources", { treeDataProvider: sourceTree });
|
const sourceView = vscode.window.createTreeView("runa.sources", { treeDataProvider: sourceTree });
|
||||||
const revisions = new RevisionDocuments();
|
const revisions = new RevisionDocuments();
|
||||||
|
const executions = new ExecutionDocuments();
|
||||||
const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree });
|
const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree });
|
||||||
const output = vscode.window.createOutputChannel("Runa");
|
const output = vscode.window.createOutputChannel("Runa");
|
||||||
const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
|
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) =>
|
const jsonInput = (title: string, value?: string) =>
|
||||||
vscode.window.showInputBox({
|
vscode.window.showInputBox({
|
||||||
title,
|
title,
|
||||||
|
|
@ -765,6 +819,7 @@ export function activate(context: vscode.ExtensionContext): {
|
||||||
sourceTree,
|
sourceTree,
|
||||||
sourceView,
|
sourceView,
|
||||||
revisions,
|
revisions,
|
||||||
|
executions,
|
||||||
documents,
|
documents,
|
||||||
actorFiles,
|
actorFiles,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
|
|
@ -781,13 +836,16 @@ export function activate(context: vscode.ExtensionContext): {
|
||||||
actorFiles.didSave(document);
|
actorFiles.didSave(document);
|
||||||
}),
|
}),
|
||||||
vscode.workspace.registerTextDocumentContentProvider(REVISION_SCHEME, revisions),
|
vscode.workspace.registerTextDocumentContentProvider(REVISION_SCHEME, revisions),
|
||||||
|
vscode.workspace.registerTextDocumentContentProvider(EXECUTION_SCHEME, executions),
|
||||||
vscode.workspace.onDidCloseTextDocument((document) => {
|
vscode.workspace.onDidCloseTextDocument((document) => {
|
||||||
revisions.remove(document.uri);
|
revisions.remove(document.uri);
|
||||||
|
executions.remove(document.uri);
|
||||||
}),
|
}),
|
||||||
command("runa.connect", connect),
|
command("runa.connect", connect),
|
||||||
command("runa.disconnect", disconnect),
|
command("runa.disconnect", disconnect),
|
||||||
command("runa.refresh", refresh),
|
command("runa.refresh", refresh),
|
||||||
command("runa.inspect", inspect),
|
command("runa.inspect", inspect),
|
||||||
|
command("runa.inspectExecution", inspectExecution),
|
||||||
command("runa.editBehavior", editBehavior),
|
command("runa.editBehavior", editBehavior),
|
||||||
command("runa.goToBehavior", goToBehavior),
|
command("runa.goToBehavior", goToBehavior),
|
||||||
command("runa.updateBehavior", updateBehavior),
|
command("runa.updateBehavior", updateBehavior),
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,32 @@ export interface SourceDiagnostic {
|
||||||
} | null;
|
} | 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 =
|
export type Command =
|
||||||
|
| { operation: "inspect_execution"; parameters: { actor_ref: string } }
|
||||||
| { operation: "check_source"; parameters: { source: string } }
|
| { operation: "check_source"; parameters: { source: string } }
|
||||||
| { operation: "actor_behavior"; parameters: { actor_ref: string } }
|
| { operation: "actor_behavior"; parameters: { actor_ref: string } }
|
||||||
| {
|
| {
|
||||||
|
|
@ -318,6 +343,10 @@ function decodeSourceDiagnostic(input: unknown): SourceDiagnostic {
|
||||||
throw new Error("Invalid diagnostic severity");
|
throw new Error("Invalid diagnostic severity");
|
||||||
const message = text(diagnostic.message);
|
const message = text(diagnostic.message);
|
||||||
if (!message) throw new Error("Empty 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 position = (input: unknown) => {
|
||||||
const value = object(input);
|
const value = object(input);
|
||||||
const line = uint(value.line),
|
const line = uint(value.line),
|
||||||
|
|
@ -326,20 +355,21 @@ function decodeSourceDiagnostic(input: unknown): SourceDiagnostic {
|
||||||
throw new Error("Diagnostic coordinate exceeds editor limits");
|
throw new Error("Diagnostic coordinate exceeds editor limits");
|
||||||
return { line: Number(line), character: Number(character) };
|
return { line: Number(line), character: Number(character) };
|
||||||
};
|
};
|
||||||
let range: SourceDiagnostic["range"] = null;
|
if (input !== null) {
|
||||||
if (diagnostic.range !== null) {
|
const value = object(input);
|
||||||
const value = object(diagnostic.range);
|
const range = { start: position(value.start), end: position(value.end) };
|
||||||
range = { start: position(value.start), end: position(value.end) };
|
|
||||||
if (
|
if (
|
||||||
range.start.line > range.end.line ||
|
range.start.line > range.end.line ||
|
||||||
(range.start.line === range.end.line && range.start.character > range.end.character)
|
(range.start.line === range.end.line && range.start.character > range.end.character)
|
||||||
)
|
)
|
||||||
throw new Error("Reversed diagnostic range");
|
throw new Error("Reversed diagnostic range");
|
||||||
|
return range;
|
||||||
}
|
}
|
||||||
return { severity: diagnostic.severity, message, range };
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Reply =
|
export type Reply =
|
||||||
|
| { kind: "execution_inspected"; data: ExecutionInspection }
|
||||||
| { kind: "source_checked"; data: { diagnostics: SourceDiagnostic[] } }
|
| { kind: "source_checked"; data: { diagnostics: SourceDiagnostic[] } }
|
||||||
| { kind: "actor_behavior"; data: { source: string; source_id: string } }
|
| { kind: "actor_behavior"; data: { source: string; source_id: string } }
|
||||||
| { kind: "logs"; data: { entries: LogEntry[]; next_sequence: bigint; has_more: boolean } }
|
| { 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);
|
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") {
|
if (command.operation === "check_source" && reply.kind === "source_checked") {
|
||||||
return {
|
return {
|
||||||
kind: "source_checked",
|
kind: "source_checked",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,105 @@ const command: Command = { operation: "list_actors" };
|
||||||
const envelope = (reply: unknown) =>
|
const envelope = (reply: unknown) =>
|
||||||
json({ version: 5, request_id: "test", result: { Ok: reply } });
|
json({ version: 5, request_id: "test", result: { Ok: reply } });
|
||||||
|
|
||||||
|
test("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", () => {
|
test("actor log lines use syslog priorities, exact sequence IDs and single-line messages", () => {
|
||||||
const priorities = [135, 135, 134, 132, 131, 130];
|
const priorities = [135, 135, 134, 132, 131, 130];
|
||||||
for (const [index, level] of LOG_LEVELS.entries()) {
|
for (const [index, level] of LOG_LEVELS.entries()) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue