feat: add standalone Rust debug adapter and actor behavior files
Expose behavior.rn for every actor and launch runa-dap from VS Code. Serve framed stdio DAP with actor-local breakpoints, exact loaded sources, bounded stepping, committed snapshot inspection, and guarded lifecycle handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
35902b6b4d
commit
5be7bb6a01
|
|
@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Add a standalone Rust `runa-dap` stdio debug adapter for VS Code actor debugging.
|
||||||
|
- Expose `behavior.rn` for every actor in the graphical and VS Code trees, with
|
||||||
|
actor-specific gutter breakpoints and source diagnostics.
|
||||||
- Add an egui/eframe graphical client for browsing and inspecting actors in a
|
- Add an egui/eframe graphical client for browsing and inspecting actors in a
|
||||||
running image, with message, lifecycle, breakpoint, stepping, stack, and log
|
running image, with message, lifecycle, breakpoint, stepping, stack, and log
|
||||||
views.
|
views.
|
||||||
|
|
|
||||||
|
|
@ -2614,6 +2614,19 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "runa-dap"
|
||||||
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"runa",
|
||||||
|
"runa-client",
|
||||||
|
"runa-core",
|
||||||
|
"runa-protocol",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "runa-image-api"
|
name = "runa-image-api"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ members = [
|
||||||
"crates/runa-client",
|
"crates/runa-client",
|
||||||
"crates/runa-code",
|
"crates/runa-code",
|
||||||
"crates/runa-core",
|
"crates/runa-core",
|
||||||
|
"crates/runa-dap",
|
||||||
"crates/runa-image-api",
|
"crates/runa-image-api",
|
||||||
"crates/runa-protocol",
|
"crates/runa-protocol",
|
||||||
"crates/runa-rune",
|
"crates/runa-rune",
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -89,7 +89,7 @@ scheduler ticks, bounded debugger stepping, source breakpoints,
|
||||||
execution-stack inspection, and retained logs. A resizable source file tree on
|
execution-stack inspection, and retained logs. A resizable source file tree on
|
||||||
the left opens behavior files and selects the source used by Spawn. A resizable
|
the left opens behavior files and selects the source used by Spawn. A resizable
|
||||||
actor tree on the right expands actors into metadata/inspector, state, mailbox,
|
actor tree on the right expands actors into metadata/inspector, state, mailbox,
|
||||||
log, and behavior entries, opening the corresponding view in the center.
|
log, and `behavior.rn` entries, opening the corresponding view in the center.
|
||||||
Its source view supports
|
Its source view supports
|
||||||
editing image sources with optimistic conflict checks, compilation diagnostics,
|
editing image sources with optimistic conflict checks, compilation diagnostics,
|
||||||
explicit history commits, and revision preview/rollback. Rollback requires
|
explicit history commits, and revision preview/rollback. Rollback requires
|
||||||
|
|
@ -289,7 +289,12 @@ is rejected with a stop-first error.
|
||||||
**Edit Current Behavior** on an actor opens its loaded script (or existing
|
**Edit Current Behavior** on an actor opens its loaded script (or existing
|
||||||
temporary script). Saving compiles an actor-specific override and installs it
|
temporary script). Saving compiles an actor-specific override and installs it
|
||||||
at the next handler boundary without changing shared source, state, or mailbox.
|
at the next handler boundary without changing shared source, state, or mailbox.
|
||||||
A `behavior` file then appears inside the actor directory. **Reload Actor Source**
|
Every actor directory includes `behavior.rn`, even without a temporary override.
|
||||||
|
Set breakpoints there and use **Debug Actor** to debug that actor with VS Code's
|
||||||
|
Run and Debug controls. Build the standalone Rust DAP server with
|
||||||
|
`cargo build -p runa-dap` and set `runa.debugAdapterPath` to its absolute path,
|
||||||
|
or install it on PATH with `cargo install --path crates/runa-dap`.
|
||||||
|
**Reload Actor Source**
|
||||||
clears the override, **Go to Behavior** opens the shared source, and **Update Behavior**
|
clears the override, **Go to Behavior** opens the shared source, and **Update Behavior**
|
||||||
asks before copying the temporary text into shared working source without committing.
|
asks before copying the temporary text into shared working source without committing.
|
||||||
|
|
||||||
|
|
@ -381,6 +386,7 @@ use `xvfb-run -a mise run test-extension-host`.
|
||||||
├── crates/
|
├── crates/
|
||||||
│ ├── runa/ # Image server, protocol dispatcher, WebSocket transport
|
│ ├── runa/ # Image server, protocol dispatcher, WebSocket transport
|
||||||
│ ├── runa-cli/ # Interactive REPL client
|
│ ├── runa-cli/ # Interactive REPL client
|
||||||
|
│ ├── runa-dap/ # Standalone stdio Debug Adapter Protocol server
|
||||||
│ ├── runa-core/ # Shared stable identities and portable actor values
|
│ ├── runa-core/ # Shared stable identities and portable actor values
|
||||||
│ ├── runa-actor/ # Actor value contracts
|
│ ├── runa-actor/ # Actor value contracts
|
||||||
│ ├── runa-code/ # Source identities and compile cache
|
│ ├── runa-code/ # Source identities and compile cache
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
[package]
|
||||||
|
name = "runa-dap"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap.workspace = true
|
||||||
|
runa-client = { path = "../runa-client" }
|
||||||
|
runa-core = { path = "../runa-core" }
|
||||||
|
runa-protocol = { path = "../runa-protocol" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
runa = { path = "../runa" }
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
use std::io::{self, BufRead, Read, Write};
|
||||||
|
|
||||||
|
pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
|
||||||
|
const MAX_HEADER_BYTES: usize = 8192;
|
||||||
|
|
||||||
|
/// Read a single DAP envelope without allocating from an unchecked Content-Length.
|
||||||
|
pub fn read_frame(reader: &mut impl BufRead) -> io::Result<Option<serde_json::Value>> {
|
||||||
|
let invalid = |message| io::Error::new(io::ErrorKind::InvalidData, message);
|
||||||
|
let mut length = None;
|
||||||
|
let mut header_bytes = 0;
|
||||||
|
loop {
|
||||||
|
let mut line = Vec::new();
|
||||||
|
let count = reader
|
||||||
|
.by_ref()
|
||||||
|
.take((MAX_HEADER_BYTES - header_bytes + 1) as u64)
|
||||||
|
.read_until(b'\n', &mut line)?;
|
||||||
|
if count == 0 && header_bytes == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
header_bytes += count;
|
||||||
|
if count == 0 || !line.ends_with(b"\r\n") || header_bytes > MAX_HEADER_BYTES {
|
||||||
|
return Err(invalid("invalid, oversized, or incomplete DAP headers"));
|
||||||
|
}
|
||||||
|
if line == b"\r\n" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let line = std::str::from_utf8(&line[..line.len() - 2])
|
||||||
|
.map_err(|_| invalid("DAP headers must be UTF-8"))?;
|
||||||
|
let (key, value) = line
|
||||||
|
.split_once(':')
|
||||||
|
.ok_or_else(|| invalid("invalid DAP header"))?;
|
||||||
|
if key.eq_ignore_ascii_case("Content-Length") {
|
||||||
|
if length.is_some() {
|
||||||
|
return Err(invalid("duplicate Content-Length"));
|
||||||
|
}
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||||
|
return Err(invalid("invalid Content-Length"));
|
||||||
|
}
|
||||||
|
let size: usize = value
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| invalid("invalid Content-Length"))?;
|
||||||
|
if size == 0 || size > MAX_REQUEST_BYTES {
|
||||||
|
return Err(invalid("DAP request exceeds size bound or is empty"));
|
||||||
|
}
|
||||||
|
length = Some(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut body = vec![0; length.ok_or_else(|| invalid("missing Content-Length"))?];
|
||||||
|
reader.read_exact(&mut body)?;
|
||||||
|
serde_json::from_slice(&body).map(Some).map_err(|error| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!("invalid DAP JSON: {error}"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_frame(writer: &mut impl Write, message: &serde_json::Value) -> io::Result<()> {
|
||||||
|
let bytes = serde_json::to_vec(message)?;
|
||||||
|
write!(writer, "Content-Length: {}\r\n\r\n", bytes.len())?;
|
||||||
|
writer.write_all(&bytes)?;
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_and_multiple_messages_round_trip() {
|
||||||
|
let message = serde_json::json!({"seq": 1, "text": "Rune 🦀"});
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
write_frame(&mut bytes, &message).unwrap();
|
||||||
|
write_frame(&mut bytes, &message).unwrap();
|
||||||
|
let mut input = Cursor::new(bytes);
|
||||||
|
assert_eq!(read_frame(&mut input).unwrap(), Some(message.clone()));
|
||||||
|
assert_eq!(read_frame(&mut input).unwrap(), Some(message));
|
||||||
|
assert_eq!(read_frame(&mut input).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_and_oversized_frames_are_explicit_errors() {
|
||||||
|
for bytes in [
|
||||||
|
"Content-Length: 2\n\n{}",
|
||||||
|
"X: header\r\n\r\n{}",
|
||||||
|
"Content-Length: 2\r\nContent-Length: 2\r\n\r\n{}",
|
||||||
|
"Content-Length: -1\r\n\r\n",
|
||||||
|
"Content-Length: 0\r\n\r\n",
|
||||||
|
"Content-Length: 1048577\r\n\r\n",
|
||||||
|
"Content-Length: 9999999999999999999999999999999\r\n\r\n",
|
||||||
|
"Content-Length: 4\r\n\r\n{}",
|
||||||
|
"Content-Length: 2\r\n\r\nno",
|
||||||
|
"Content-Length: 2\r\n",
|
||||||
|
] {
|
||||||
|
assert!(read_frame(&mut Cursor::new(bytes)).is_err(), "{bytes}");
|
||||||
|
}
|
||||||
|
assert!(read_frame(&mut Cursor::new(vec![b'x'; MAX_HEADER_BYTES + 1])).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,811 @@
|
||||||
|
//! One-actor DAP sessions over the loopback image protocol. Debug controls never tick the image.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use runa_client::WebSocketClient;
|
||||||
|
use runa_protocol::{
|
||||||
|
ActorSnapshotDto, ActorStatusDto, Command, ExecutionInspectionDto, Reply, SourceBreakpoint,
|
||||||
|
StackFrame, StepMode, SystemMessageDto,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value as Json, json};
|
||||||
|
|
||||||
|
pub mod framing;
|
||||||
|
mod variables;
|
||||||
|
|
||||||
|
pub trait Connection {
|
||||||
|
fn request(&mut self, command: Command) -> Result<Reply, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WebSocketConnection {
|
||||||
|
address: SocketAddr,
|
||||||
|
client: Option<WebSocketClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebSocketConnection {
|
||||||
|
pub fn new(address: SocketAddr) -> Self {
|
||||||
|
Self {
|
||||||
|
address,
|
||||||
|
client: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Connection for WebSocketConnection {
|
||||||
|
fn request(&mut self, command: Command) -> Result<Reply, String> {
|
||||||
|
if self.client.is_none() {
|
||||||
|
self.client =
|
||||||
|
Some(WebSocketClient::connect(self.address).map_err(|error| error.to_string())?);
|
||||||
|
}
|
||||||
|
self.client
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.request(command)
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.result
|
||||||
|
.map_err(|error| error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Request {
|
||||||
|
seq: u64,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: String,
|
||||||
|
command: String,
|
||||||
|
#[serde(default = "empty_object")]
|
||||||
|
arguments: Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_object() -> Json {
|
||||||
|
json!({})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Breakpoint {
|
||||||
|
id: u64,
|
||||||
|
line: usize,
|
||||||
|
source_matches: bool,
|
||||||
|
unsupported: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BreakpointGroup {
|
||||||
|
source: Json,
|
||||||
|
entries: Vec<Breakpoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Adapter<C> {
|
||||||
|
connection: C,
|
||||||
|
sequence: u64,
|
||||||
|
messages: Vec<Json>,
|
||||||
|
closed: bool,
|
||||||
|
initialized: bool,
|
||||||
|
configured: bool,
|
||||||
|
actor_ref: Option<String>,
|
||||||
|
behavior_uri: Option<String>,
|
||||||
|
working_uri: Option<String>,
|
||||||
|
inspection: Option<ExecutionInspectionDto>,
|
||||||
|
snapshot: Option<ActorSnapshotDto>,
|
||||||
|
stop_key: Option<String>,
|
||||||
|
paused: bool,
|
||||||
|
frames: BTreeMap<u64, StackFrame>,
|
||||||
|
next_frame: u64,
|
||||||
|
variables: variables::Variables,
|
||||||
|
line_base: usize,
|
||||||
|
column_base: usize,
|
||||||
|
next_breakpoint: u64,
|
||||||
|
breakpoint_groups: BTreeMap<&'static str, BreakpointGroup>,
|
||||||
|
breakpoint_states: BTreeMap<u64, bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: Connection> Adapter<C> {
|
||||||
|
pub fn new(connection: C) -> Self {
|
||||||
|
Self {
|
||||||
|
connection,
|
||||||
|
sequence: 0,
|
||||||
|
messages: Vec::new(),
|
||||||
|
closed: false,
|
||||||
|
initialized: false,
|
||||||
|
configured: false,
|
||||||
|
actor_ref: None,
|
||||||
|
behavior_uri: None,
|
||||||
|
working_uri: None,
|
||||||
|
inspection: None,
|
||||||
|
snapshot: None,
|
||||||
|
stop_key: None,
|
||||||
|
paused: false,
|
||||||
|
frames: BTreeMap::new(),
|
||||||
|
next_frame: 0,
|
||||||
|
variables: variables::Variables::default(),
|
||||||
|
line_base: 1,
|
||||||
|
column_base: 1,
|
||||||
|
next_breakpoint: 0,
|
||||||
|
breakpoint_groups: BTreeMap::new(),
|
||||||
|
breakpoint_states: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_closed(&self) -> bool {
|
||||||
|
self.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle(&mut self, message: Json) -> Vec<Json> {
|
||||||
|
if self.closed {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let request: Request = match serde_json::from_value(message) {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(error) => return self.terminate(&format!("Invalid DAP request: {error}")),
|
||||||
|
};
|
||||||
|
if request.kind != "request" || !request.arguments.is_object() {
|
||||||
|
return self.terminate("Expected a DAP request with object arguments");
|
||||||
|
}
|
||||||
|
let result = self.dispatch(&request.command, &request.arguments);
|
||||||
|
let response = match result {
|
||||||
|
Ok(body) => {
|
||||||
|
json!({"type": "response", "request_seq": request.seq, "command": request.command, "success": true, "body": body})
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
if request.command == "attach" && !self.closed {
|
||||||
|
self.end(Some("Attach failed"));
|
||||||
|
}
|
||||||
|
json!({"type": "response", "request_seq": request.seq, "command": request.command, "success": false, "message": message})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.messages.insert(0, response);
|
||||||
|
self.drain()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn poll(&mut self) -> Vec<Json> {
|
||||||
|
if self.closed || !self.configured || self.actor_ref.is_none() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if let Err(message) = self.synchronize() {
|
||||||
|
self.end(Some(&message));
|
||||||
|
}
|
||||||
|
self.drain()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn terminate(&mut self, message: &str) -> Vec<Json> {
|
||||||
|
self.end(Some(message));
|
||||||
|
self.drain()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain(&mut self) -> Vec<Json> {
|
||||||
|
std::mem::take(&mut self.messages)
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut message| {
|
||||||
|
self.sequence += 1;
|
||||||
|
message["seq"] = json!(self.sequence);
|
||||||
|
message
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(&mut self, event: &str, body: Json) {
|
||||||
|
self.messages
|
||||||
|
.push(json!({"type": "event", "event": event, "body": body}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output(&mut self, message: &str) {
|
||||||
|
self.event(
|
||||||
|
"output",
|
||||||
|
json!({"category": "console", "output": format!("Runa: {message}\n")}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end(&mut self, message: Option<&str>) {
|
||||||
|
if self.closed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(message) = message {
|
||||||
|
self.output(message);
|
||||||
|
}
|
||||||
|
self.event("terminated", json!({}));
|
||||||
|
self.closed = true;
|
||||||
|
self.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor(&self) -> Result<String, String> {
|
||||||
|
self.actor_ref
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "Attach to an actor first".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_actor(&mut self) -> Result<ActorSnapshotDto, String> {
|
||||||
|
let actor_ref = self.actor()?;
|
||||||
|
match self.connection.request(Command::InspectActor {
|
||||||
|
actor_ref: actor_ref.clone(),
|
||||||
|
})? {
|
||||||
|
Reply::Actor(actor) if actor.actor_ref == actor_ref => {
|
||||||
|
if let Some(inspection) = &self.inspection {
|
||||||
|
self.check_identity(
|
||||||
|
&actor.implementation_id,
|
||||||
|
actor.incarnation,
|
||||||
|
&inspection.implementation_id.clone(),
|
||||||
|
inspection.incarnation,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(actor)
|
||||||
|
}
|
||||||
|
_ => Err("Image returned an unexpected actor reply".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_identity(
|
||||||
|
&mut self,
|
||||||
|
implementation: &str,
|
||||||
|
incarnation: u64,
|
||||||
|
previous: &str,
|
||||||
|
previous_incarnation: u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if implementation != previous || incarnation != previous_incarnation {
|
||||||
|
let message = "Actor implementation or incarnation changed; attach again.";
|
||||||
|
self.end(Some(message));
|
||||||
|
return Err(message.to_owned());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&mut self) -> Result<ExecutionInspectionDto, String> {
|
||||||
|
let actor_ref = self.actor()?;
|
||||||
|
let Reply::ExecutionInspected(inspection) =
|
||||||
|
self.connection.request(Command::InspectExecution {
|
||||||
|
actor_ref: actor_ref.clone(),
|
||||||
|
})?
|
||||||
|
else {
|
||||||
|
return Err("Image returned an unexpected execution reply".to_owned());
|
||||||
|
};
|
||||||
|
if inspection.actor_ref != actor_ref {
|
||||||
|
return Err("Image returned another actor's execution".to_owned());
|
||||||
|
}
|
||||||
|
if let Some(previous) = &self.inspection {
|
||||||
|
let implementation = previous.implementation_id.clone();
|
||||||
|
let incarnation = previous.incarnation;
|
||||||
|
self.check_identity(
|
||||||
|
&inspection.implementation_id,
|
||||||
|
inspection.incarnation,
|
||||||
|
&implementation,
|
||||||
|
incarnation,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
self.inspection = Some(inspection.clone());
|
||||||
|
Ok(inspection)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source(&self) -> Json {
|
||||||
|
let path = self.behavior_uri.clone().unwrap_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"runa-dap://{}/behavior.rn",
|
||||||
|
self.actor_ref.as_deref().unwrap_or("unattached")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
json!({"name": "behavior.rn", "path": path, "sourceReference": 1})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate(&mut self) {
|
||||||
|
self.frames.clear();
|
||||||
|
self.variables.clear();
|
||||||
|
self.snapshot = None;
|
||||||
|
self.stop_key = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn synchronize(&mut self) -> Result<(), String> {
|
||||||
|
let actor = self.inspect_actor()?;
|
||||||
|
if actor.status == ActorStatusDto::Stopped {
|
||||||
|
self.end(Some("Actor stopped."));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let inspection = self.read()?;
|
||||||
|
self.verify_breakpoints(&inspection.debugger.breakpoints);
|
||||||
|
let paused = matches!(
|
||||||
|
actor.status,
|
||||||
|
ActorStatusDto::Paused | ActorStatusDto::Failed(_)
|
||||||
|
);
|
||||||
|
if !paused {
|
||||||
|
if self.paused {
|
||||||
|
self.event(
|
||||||
|
"continued",
|
||||||
|
json!({"threadId": 1, "allThreadsContinued": true}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.paused = false;
|
||||||
|
self.invalidate();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let key = format!(
|
||||||
|
"{:?}:{:?}:{:?}",
|
||||||
|
actor.status, inspection.execution, inspection.debugger.stop_reason
|
||||||
|
);
|
||||||
|
if self.stop_key.as_ref() == Some(&key) && self.snapshot.as_ref() == Some(&actor) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.invalidate();
|
||||||
|
self.stop_key = Some(key);
|
||||||
|
self.paused = true;
|
||||||
|
self.variables.capture(&actor);
|
||||||
|
if let Some(execution) = &inspection.execution {
|
||||||
|
for frame in &execution.frames {
|
||||||
|
self.next_frame += 1;
|
||||||
|
self.frames.insert(self.next_frame, frame.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let failed = matches!(actor.status, ActorStatusDto::Failed(_));
|
||||||
|
let stop_reason = inspection.debugger.stop_reason;
|
||||||
|
let reason = if failed {
|
||||||
|
"exception"
|
||||||
|
} else {
|
||||||
|
match stop_reason {
|
||||||
|
Some(runa_protocol::DebugStopReason::Breakpoint) => "breakpoint",
|
||||||
|
Some(runa_protocol::DebugStopReason::Step) => "step",
|
||||||
|
_ => "pause",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let description = match &actor.status {
|
||||||
|
ActorStatusDto::Failed(message) => message.clone(),
|
||||||
|
_ => {
|
||||||
|
if let Some(execution) = &inspection.execution {
|
||||||
|
format!(
|
||||||
|
"{stop_reason:?}{}{}",
|
||||||
|
if execution.awaiting {
|
||||||
|
" (last safe await snapshot)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
if execution.frames_truncated {
|
||||||
|
"; innermost 64 frames shown"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"Paused at handler boundary; no active invocation".to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.snapshot = Some(actor);
|
||||||
|
self.event("stopped", json!({"reason": reason, "description": description, "threadId": 1, "allThreadsStopped": true}));
|
||||||
|
if failed {
|
||||||
|
self.output("Actor failed. No post-mortem stack is retained. Inspect actor/committed_state and actor/mailbox/reserved; repair/restart is explicit.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn thread(args: &Json) -> Result<(), String> {
|
||||||
|
if args["threadId"] != 1 {
|
||||||
|
return Err("Unknown actor thread".to_owned());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(&self, args: &Json) -> Result<StackFrame, String> {
|
||||||
|
self.frames
|
||||||
|
.get(&integer(args, "frameId", None)?)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "Stale or unknown frame".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_nonfailed(&mut self) -> Result<(), String> {
|
||||||
|
let actor = self.inspect_actor()?;
|
||||||
|
if matches!(actor.status, ActorStatusDto::Failed(_)) {
|
||||||
|
return Err("Actor failed; repair/restart explicitly before running. Debug controls never retry a failed invocation.".to_owned());
|
||||||
|
}
|
||||||
|
if actor.status == ActorStatusDto::Stopped {
|
||||||
|
return Err("Actor is stopped".to_owned());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system(&mut self, message: SystemMessageDto) -> Result<(), String> {
|
||||||
|
let actor_ref = self.actor()?;
|
||||||
|
match self
|
||||||
|
.connection
|
||||||
|
.request(Command::System { actor_ref, message })?
|
||||||
|
{
|
||||||
|
Reply::Acknowledged => Ok(()),
|
||||||
|
_ => Err("Image returned an unexpected system reply".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch(&mut self, command: &str, args: &Json) -> Result<Json, String> {
|
||||||
|
match command {
|
||||||
|
"initialize" => {
|
||||||
|
if self.initialized {
|
||||||
|
return Err("Debugger is already initialized".to_owned());
|
||||||
|
}
|
||||||
|
self.initialized = true;
|
||||||
|
self.line_base = usize::from(args["linesStartAt1"] != false);
|
||||||
|
self.column_base = usize::from(args["columnsStartAt1"] != false);
|
||||||
|
return Ok(json!({
|
||||||
|
"supportsConfigurationDoneRequest": true, "supportsLoadedSourcesRequest": true,
|
||||||
|
"supportsExceptionInfoRequest": true, "supportsTerminateDebuggee": false,
|
||||||
|
"supportSuspendDebuggee": false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
"disconnect" => {
|
||||||
|
if args["terminateDebuggee"] == true || args["suspendDebuggee"] == true {
|
||||||
|
return Err("Disconnect cannot terminate or suspend the actor".to_owned());
|
||||||
|
}
|
||||||
|
self.end(None);
|
||||||
|
return Ok(json!({}));
|
||||||
|
}
|
||||||
|
"attach" => {
|
||||||
|
if !self.initialized {
|
||||||
|
return Err("Initialize the debugger first".to_owned());
|
||||||
|
}
|
||||||
|
if self.actor_ref.is_some() {
|
||||||
|
return Err("Debugger is already attached".to_owned());
|
||||||
|
}
|
||||||
|
let actor = args["actorRef"].as_str().ok_or("Expected actorRef")?;
|
||||||
|
if runa_core::ActorRef::parse(actor).is_none() {
|
||||||
|
return Err("Invalid actor reference".to_owned());
|
||||||
|
}
|
||||||
|
self.behavior_uri = optional_uri(args, "behaviorSourceUri")?;
|
||||||
|
self.working_uri = optional_uri(args, "workingSourceUri")?;
|
||||||
|
self.actor_ref = Some(actor.to_owned());
|
||||||
|
let actor = self.inspect_actor()?;
|
||||||
|
if actor.status == ActorStatusDto::Stopped {
|
||||||
|
return Err("Cannot attach to a stopped actor".to_owned());
|
||||||
|
}
|
||||||
|
let inspection = self.read()?;
|
||||||
|
self.check_identity(
|
||||||
|
&actor.implementation_id,
|
||||||
|
actor.incarnation,
|
||||||
|
&inspection.implementation_id,
|
||||||
|
inspection.incarnation,
|
||||||
|
)?;
|
||||||
|
if !matches!(
|
||||||
|
actor.status,
|
||||||
|
ActorStatusDto::Paused | ActorStatusDto::Failed(_)
|
||||||
|
) {
|
||||||
|
self.system(SystemMessageDto::Pause)?;
|
||||||
|
}
|
||||||
|
self.output("Attached to one actor. Continue resumes scheduling eligibility but does not tick the image. Disconnect leaves actor status and breakpoints unchanged. Variables read committed snapshots, not invocation drafts or Rune expressions.");
|
||||||
|
self.event("initialized", json!({}));
|
||||||
|
return Ok(json!({}));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.actor()?;
|
||||||
|
match command {
|
||||||
|
"configurationDone" => {
|
||||||
|
self.configured = true;
|
||||||
|
Ok(json!({}))
|
||||||
|
}
|
||||||
|
"threads" => Ok(json!({"threads": [{"id": 1, "name": format!("@{}", self.actor()?)}]})),
|
||||||
|
"loadedSources" => {
|
||||||
|
self.read()?;
|
||||||
|
Ok(json!({"sources": [self.source()]}))
|
||||||
|
}
|
||||||
|
"source" => {
|
||||||
|
if args["sourceReference"] != 1 {
|
||||||
|
return Err("Unknown loaded source".to_owned());
|
||||||
|
}
|
||||||
|
let inspection = self.read()?;
|
||||||
|
Ok(json!({"content": inspection.source, "mimeType": "text/x-rune"}))
|
||||||
|
}
|
||||||
|
"setBreakpoints" => self.set_breakpoints(args),
|
||||||
|
"setExceptionBreakpoints"
|
||||||
|
if args["filters"] == json!([])
|
||||||
|
&& args
|
||||||
|
.get("filterOptions")
|
||||||
|
.is_none_or(|value| value == &json!([]))
|
||||||
|
&& args
|
||||||
|
.get("exceptionOptions")
|
||||||
|
.is_none_or(|value| value == &json!([])) =>
|
||||||
|
{
|
||||||
|
Ok(json!({"breakpoints": []}))
|
||||||
|
}
|
||||||
|
"stackTrace" => {
|
||||||
|
Self::thread(args)?;
|
||||||
|
let start = integer(args, "startFrame", Some(0))? as usize;
|
||||||
|
let levels = integer(args, "levels", Some(0))? as usize;
|
||||||
|
let frames: Vec<_> = self.frames.iter().skip(start).take(if levels == 0 {usize::MAX} else {levels}).map(|(id, frame)| {
|
||||||
|
let mut result = json!({"id": id, "name": frame.function.as_deref().unwrap_or("<unknown function>"), "line": 0, "column": 0, "presentationHint": "subtle"});
|
||||||
|
if let Some(range) = &frame.range {
|
||||||
|
result["source"] = self.source();
|
||||||
|
result["line"] = json!(range.start.line + self.line_base);
|
||||||
|
result["column"] = json!(range.start.character + self.column_base);
|
||||||
|
result["endLine"] = json!(range.end.line + self.line_base);
|
||||||
|
result["endColumn"] = json!(range.end.character + self.column_base);
|
||||||
|
result["presentationHint"] = json!("normal");
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}).collect();
|
||||||
|
Ok(json!({"stackFrames": frames, "totalFrames": self.frames.len()}))
|
||||||
|
}
|
||||||
|
"scopes" => {
|
||||||
|
let frame = self.frame(args)?;
|
||||||
|
let scope = self.variables.scope()?;
|
||||||
|
let metadata = self.variables.frame_scope(&frame);
|
||||||
|
Ok(json!({"scopes": [scope, metadata]}))
|
||||||
|
}
|
||||||
|
"variables" => self.variables.variables(args),
|
||||||
|
"evaluate" => {
|
||||||
|
if args.get("frameId").is_some() {
|
||||||
|
self.frame(args)?;
|
||||||
|
}
|
||||||
|
self.variables.evaluate(args)
|
||||||
|
}
|
||||||
|
"exceptionInfo" => {
|
||||||
|
Self::thread(args)?;
|
||||||
|
let Some(ActorSnapshotDto {
|
||||||
|
status: ActorStatusDto::Failed(message),
|
||||||
|
..
|
||||||
|
}) = &self.snapshot
|
||||||
|
else {
|
||||||
|
return Err("No sampled actor failure at the current stop".to_owned());
|
||||||
|
};
|
||||||
|
Ok(
|
||||||
|
json!({"exceptionId": "runa.actorFailure", "description": message, "breakMode": "always", "details": {"typeName": "Runa actor failure", "message": message}}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"pause" | "continue" | "next" | "stepIn" | "stepOut" => {
|
||||||
|
Self::thread(args)?;
|
||||||
|
self.require_nonfailed()?;
|
||||||
|
let inspection = self.read()?;
|
||||||
|
match command {
|
||||||
|
"pause" => {
|
||||||
|
self.system(SystemMessageDto::Pause)?;
|
||||||
|
self.stop_key = None;
|
||||||
|
}
|
||||||
|
"continue" => {
|
||||||
|
self.system(SystemMessageDto::Resume)?;
|
||||||
|
self.paused = false;
|
||||||
|
self.invalidate();
|
||||||
|
self.event(
|
||||||
|
"continued",
|
||||||
|
json!({"threadId": 1, "allThreadsContinued": true}),
|
||||||
|
);
|
||||||
|
self.output("Actor resumed. Advance the scheduler explicitly to execute image ticks.");
|
||||||
|
return Ok(json!({"allThreadsContinued": true}));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(granularity) = args.get("granularity")
|
||||||
|
&& !matches!(granularity.as_str(), Some("line" | "statement"))
|
||||||
|
{
|
||||||
|
return Err("Only source-line stepping is supported".to_owned());
|
||||||
|
}
|
||||||
|
if args.get("targetId").is_some() {
|
||||||
|
return Err("Step targets are not supported".to_owned());
|
||||||
|
}
|
||||||
|
let mode = match command {
|
||||||
|
"next" => StepMode::Over,
|
||||||
|
"stepOut" => StepMode::Out,
|
||||||
|
_ => StepMode::Into,
|
||||||
|
};
|
||||||
|
let actor_ref = self.actor()?;
|
||||||
|
let Reply::DebugStepCompleted { reason } =
|
||||||
|
self.connection.request(Command::StepSource {
|
||||||
|
actor_ref,
|
||||||
|
implementation_id: inspection.implementation_id,
|
||||||
|
instruction_budget: 1000,
|
||||||
|
mode,
|
||||||
|
})?
|
||||||
|
else {
|
||||||
|
return Err("Image returned an unexpected step reply".to_owned());
|
||||||
|
};
|
||||||
|
self.invalidate();
|
||||||
|
self.event(
|
||||||
|
"continued",
|
||||||
|
json!({"threadId": 1, "allThreadsContinued": true}),
|
||||||
|
);
|
||||||
|
self.output(&format!(
|
||||||
|
"Source step {}: {}.",
|
||||||
|
serde_json::to_value(mode).unwrap().as_str().unwrap(),
|
||||||
|
serde_json::to_value(reason).unwrap().as_str().unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(json!({}))
|
||||||
|
}
|
||||||
|
_ => Err(format!("Unsupported debug request: {command}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_breakpoints(&mut self, args: &Json) -> Result<Json, String> {
|
||||||
|
let source = args
|
||||||
|
.get("source")
|
||||||
|
.filter(|source| source.is_object())
|
||||||
|
.ok_or("Expected breakpoint source")?
|
||||||
|
.clone();
|
||||||
|
let requested = match args.get("breakpoints") {
|
||||||
|
None => &[][..],
|
||||||
|
Some(Json::Array(entries)) => entries.as_slice(),
|
||||||
|
_ => return Err("Expected source breakpoints".to_owned()),
|
||||||
|
};
|
||||||
|
if requested.len() > 128 {
|
||||||
|
return Err("At most 128 breakpoints are supported".to_owned());
|
||||||
|
}
|
||||||
|
let inspection = self.read()?;
|
||||||
|
let loaded = source["sourceReference"] == 1;
|
||||||
|
let actor_path = source["path"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|path| self.source()["path"] == path);
|
||||||
|
let working = source["path"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|path| self.working_uri.as_deref() == Some(path));
|
||||||
|
let key = if loaded {
|
||||||
|
"loaded"
|
||||||
|
} else if actor_path {
|
||||||
|
"actor"
|
||||||
|
} else {
|
||||||
|
"working"
|
||||||
|
};
|
||||||
|
let dirty = match args.get("sourceModified") {
|
||||||
|
None => false,
|
||||||
|
Some(Json::Bool(value)) => *value,
|
||||||
|
_ => return Err("Expected boolean sourceModified".to_owned()),
|
||||||
|
};
|
||||||
|
let mut matches = loaded;
|
||||||
|
if !loaded && !dirty {
|
||||||
|
if actor_path {
|
||||||
|
let actor_ref = self.actor()?;
|
||||||
|
let Reply::ActorBehavior { source, .. } = self
|
||||||
|
.connection
|
||||||
|
.request(Command::ActorBehavior { actor_ref })?
|
||||||
|
else {
|
||||||
|
return Err("Image returned an unexpected actor behavior reply".to_owned());
|
||||||
|
};
|
||||||
|
matches = source == inspection.source;
|
||||||
|
} else if working && let Some(path) = &inspection.behavior_path {
|
||||||
|
let Reply::Source(source) = self
|
||||||
|
.connection
|
||||||
|
.request(Command::ReadSource { path: path.clone() })?
|
||||||
|
else {
|
||||||
|
return Err("Image returned an unexpected working source reply".to_owned());
|
||||||
|
};
|
||||||
|
matches = source.source == inspection.source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for entry in requested {
|
||||||
|
if !entry.is_object() {
|
||||||
|
return Err("Expected source breakpoint object".to_owned());
|
||||||
|
}
|
||||||
|
let line = integer(entry, "line", None)? as usize;
|
||||||
|
if line < self.line_base || line - self.line_base > u32::MAX as usize {
|
||||||
|
return Err("Invalid breakpoint line".to_owned());
|
||||||
|
}
|
||||||
|
self.next_breakpoint += 1;
|
||||||
|
entries.push(Breakpoint {
|
||||||
|
id: self.next_breakpoint,
|
||||||
|
line: line - self.line_base,
|
||||||
|
source_matches: matches,
|
||||||
|
unsupported: ["condition", "hitCondition", "logMessage", "column"]
|
||||||
|
.iter()
|
||||||
|
.any(|key| entry.get(key).is_some()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !loaded && !actor_path && !working {
|
||||||
|
return Ok(
|
||||||
|
json!({"breakpoints": entries.iter().map(|entry| self.breakpoint_result(entry, &source, &[])).collect::<Vec<_>>()}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut lines = BTreeSet::new();
|
||||||
|
for (group_key, group) in &self.breakpoint_groups {
|
||||||
|
if *group_key != key {
|
||||||
|
lines.extend(
|
||||||
|
group
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.source_matches && !entry.unsupported)
|
||||||
|
.map(|entry| entry.line),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.extend(
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.source_matches && !entry.unsupported)
|
||||||
|
.map(|entry| entry.line),
|
||||||
|
);
|
||||||
|
if lines.len() > 128 {
|
||||||
|
return Err(
|
||||||
|
"At most 128 combined loaded/working breakpoint lines are supported".to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let actor_ref = self.actor()?;
|
||||||
|
let Reply::BreakpointsSet { breakpoints } =
|
||||||
|
self.connection.request(Command::SetBreakpoints {
|
||||||
|
actor_ref,
|
||||||
|
implementation_id: inspection.implementation_id,
|
||||||
|
lines: lines.into_iter().collect(),
|
||||||
|
})?
|
||||||
|
else {
|
||||||
|
return Err("Image returned an unexpected breakpoints reply".to_owned());
|
||||||
|
};
|
||||||
|
let results: Vec<_> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| self.breakpoint_result(entry, &source, &breakpoints))
|
||||||
|
.collect();
|
||||||
|
if let Some(previous) = self
|
||||||
|
.breakpoint_groups
|
||||||
|
.insert(key, BreakpointGroup { source, entries })
|
||||||
|
{
|
||||||
|
for entry in previous.entries {
|
||||||
|
self.breakpoint_states.remove(&entry.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.verify_breakpoints(&breakpoints);
|
||||||
|
Ok(json!({"breakpoints": results}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn breakpoint_result(
|
||||||
|
&self,
|
||||||
|
entry: &Breakpoint,
|
||||||
|
source: &Json,
|
||||||
|
resolved: &[SourceBreakpoint],
|
||||||
|
) -> Json {
|
||||||
|
let breakpoint = resolved
|
||||||
|
.iter()
|
||||||
|
.find(|breakpoint| breakpoint.line == entry.line);
|
||||||
|
let verified = entry.source_matches
|
||||||
|
&& !entry.unsupported
|
||||||
|
&& breakpoint.is_some_and(|breakpoint| !breakpoint.instruction_pointers.is_empty());
|
||||||
|
let mut result = json!({"id": entry.id, "verified": verified, "line": entry.line + self.line_base, "source": source});
|
||||||
|
if !verified {
|
||||||
|
result["message"] = json!(if !entry.source_matches {
|
||||||
|
"Not this actor's exact loaded source; use its loaded source (sourceReference 1)"
|
||||||
|
} else if entry.unsupported {
|
||||||
|
"Conditions, hit counts, logpoints and column breakpoints are not supported"
|
||||||
|
} else if breakpoint.is_some() {
|
||||||
|
"No executable line entry in the loaded source"
|
||||||
|
} else {
|
||||||
|
"Actor breakpoint list changed outside this source document"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_breakpoints(&mut self, resolved: &[SourceBreakpoint]) {
|
||||||
|
let results: Vec<_> = self
|
||||||
|
.breakpoint_groups
|
||||||
|
.values()
|
||||||
|
.flat_map(|group| {
|
||||||
|
group.entries.iter().map(|entry| {
|
||||||
|
(
|
||||||
|
entry.id,
|
||||||
|
self.breakpoint_result(entry, &group.source, resolved),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (id, result) in results {
|
||||||
|
let verified = result["verified"].as_bool().unwrap();
|
||||||
|
if self
|
||||||
|
.breakpoint_states
|
||||||
|
.insert(id, verified)
|
||||||
|
.is_some_and(|previous| previous != verified)
|
||||||
|
{
|
||||||
|
self.event(
|
||||||
|
"breakpoint",
|
||||||
|
json!({"reason": "changed", "breakpoint": result}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn integer(args: &Json, key: &str, default: Option<u64>) -> Result<u64, String> {
|
||||||
|
match args.get(key) {
|
||||||
|
Some(value) => value
|
||||||
|
.as_u64()
|
||||||
|
.filter(|value| *value <= 9_007_199_254_740_991)
|
||||||
|
.ok_or_else(|| format!("Expected nonnegative safe integer {key}")),
|
||||||
|
None => default.ok_or_else(|| format!("Expected integer {key}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_uri(args: &Json, key: &str) -> Result<Option<String>, String> {
|
||||||
|
match args.get(key) {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(Json::String(value))
|
||||||
|
if !value.is_empty() && value.len() <= 8192 && !value.contains('\0') =>
|
||||||
|
{
|
||||||
|
Ok(Some(value.clone()))
|
||||||
|
}
|
||||||
|
_ => Err(format!("Expected nonempty {key} string")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
use std::io::{self, BufReader};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::mpsc::{self, RecvTimeoutError};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use runa_dap::{Adapter, WebSocketConnection, framing};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(about = "Stdio Debug Adapter Protocol server for one Runa actor")]
|
||||||
|
struct Args {
|
||||||
|
#[arg(long, env = "RUNA_CONNECT", default_value = "127.0.0.1:3737")]
|
||||||
|
connect: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> io::Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
if !args.connect.ip().is_loopback() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"--connect must be a loopback address",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (sender, receiver) = mpsc::sync_channel(16);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut input = BufReader::new(io::stdin().lock());
|
||||||
|
loop {
|
||||||
|
let result = framing::read_frame(&mut input);
|
||||||
|
let finished = !matches!(&result, Ok(Some(_)));
|
||||||
|
if sender.send(result).is_err() || finished {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut adapter = Adapter::new(WebSocketConnection::new(args.connect));
|
||||||
|
let mut output = io::stdout().lock();
|
||||||
|
while !adapter.is_closed() {
|
||||||
|
let messages = match receiver.recv_timeout(Duration::from_millis(150)) {
|
||||||
|
Ok(Ok(Some(message))) => adapter.handle(message),
|
||||||
|
Ok(Ok(None)) | Err(RecvTimeoutError::Disconnected) => break,
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
for message in adapter.terminate(&format!("Invalid DAP framing: {error}")) {
|
||||||
|
framing::write_frame(&mut output, &message)?;
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Err(RecvTimeoutError::Timeout) => adapter.poll(),
|
||||||
|
};
|
||||||
|
for message in messages {
|
||||||
|
framing::write_frame(&mut output, &message)?;
|
||||||
|
}
|
||||||
|
// Poll even under a steady stream of input; requests cannot starve lifecycle checks.
|
||||||
|
for message in adapter.poll() {
|
||||||
|
framing::write_frame(&mut output, &message)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use runa_protocol::{ActorSnapshotDto, StackFrame, Value};
|
||||||
|
use serde_json::{Value as Json, json};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Variables {
|
||||||
|
next: u64,
|
||||||
|
values: BTreeMap<u64, (Value, String)>,
|
||||||
|
references: BTreeMap<String, u64>,
|
||||||
|
root: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Variables {
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.values.clear();
|
||||||
|
self.references.clear();
|
||||||
|
self.root = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reference(&mut self, value: Value, path: String) -> u64 {
|
||||||
|
if !path.is_empty() {
|
||||||
|
if let Some(reference) = self.references.get(&path) {
|
||||||
|
return *reference;
|
||||||
|
}
|
||||||
|
self.references.insert(path.clone(), self.next + 1);
|
||||||
|
}
|
||||||
|
self.next += 1;
|
||||||
|
self.values.insert(self.next, (value, path));
|
||||||
|
self.next
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capture(&mut self, actor: &ActorSnapshotDto) {
|
||||||
|
self.clear();
|
||||||
|
let mut metadata = plain(json!({
|
||||||
|
"mailbox_id": actor.mailbox_id,
|
||||||
|
"implementation_id": actor.implementation_id,
|
||||||
|
"incarnation": actor.incarnation,
|
||||||
|
"behavior_path": actor.behavior_path,
|
||||||
|
"status": serde_json::to_value(&actor.status).unwrap()["status"],
|
||||||
|
}));
|
||||||
|
if let Value::Record(entries) = &mut metadata {
|
||||||
|
entries.insert(
|
||||||
|
"actor_ref".to_owned(),
|
||||||
|
runa_core::ActorRef::parse(&actor.actor_ref)
|
||||||
|
.map(Value::ActorRef)
|
||||||
|
.unwrap_or_else(|| Value::String(actor.actor_ref.clone())),
|
||||||
|
);
|
||||||
|
if let runa_protocol::ActorStatusDto::Failed(message) = &actor.status {
|
||||||
|
entries.insert("failure".to_owned(), Value::String(message.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let value = record([
|
||||||
|
("committed_state", actor.state.clone()),
|
||||||
|
(
|
||||||
|
"mailbox",
|
||||||
|
record([
|
||||||
|
(
|
||||||
|
"reserved",
|
||||||
|
Value::List(actor.reserved.clone().into_iter().collect()),
|
||||||
|
),
|
||||||
|
("queued", Value::List(actor.queued.clone())),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
("config", plain(json!({"log_level": actor.log_level}))),
|
||||||
|
("metadata", metadata),
|
||||||
|
]);
|
||||||
|
self.root = Some(self.reference(value, "actor".to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scope(&self) -> Result<Json, String> {
|
||||||
|
let reference = self
|
||||||
|
.root
|
||||||
|
.ok_or("Actor snapshot unavailable while running")?;
|
||||||
|
Ok(json!({
|
||||||
|
"name": "Actor snapshot (committed; not frame locals)",
|
||||||
|
"variablesReference": reference, "expensive": false, "namedVariables": 4
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn frame_scope(&mut self, frame: &StackFrame) -> Json {
|
||||||
|
let value = plain(serde_json::to_value(frame).unwrap());
|
||||||
|
let reference = self.reference(value, String::new());
|
||||||
|
json!({"name": "Frame metadata (not locals)", "variablesReference": reference, "expensive": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn variables(&mut self, args: &Json) -> Result<Json, String> {
|
||||||
|
let reference = super::integer(args, "variablesReference", None)?;
|
||||||
|
let (value, path) = self
|
||||||
|
.values
|
||||||
|
.get(&reference)
|
||||||
|
.cloned()
|
||||||
|
.ok_or("Stale or unknown variables reference")?;
|
||||||
|
let indexed = matches!(value, Value::List(_));
|
||||||
|
match args.get("filter") {
|
||||||
|
Some(Json::String(filter))
|
||||||
|
if (filter == "named" && indexed) || (filter == "indexed" && !indexed) =>
|
||||||
|
{
|
||||||
|
return Ok(json!({"variables": []}));
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
Some(Json::String(filter)) if matches!(filter.as_str(), "named" | "indexed") => {}
|
||||||
|
_ => return Err("Invalid variables filter".to_owned()),
|
||||||
|
}
|
||||||
|
let entries: Vec<_> = match value {
|
||||||
|
Value::Record(entries) => entries.into_iter().collect(),
|
||||||
|
Value::List(entries) => entries
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, value)| (index.to_string(), value))
|
||||||
|
.collect(),
|
||||||
|
_ => return Err("Variable has no children".to_owned()),
|
||||||
|
};
|
||||||
|
let start = super::integer(args, "start", Some(0))? as usize;
|
||||||
|
let count = super::integer(args, "count", Some(0))? as usize;
|
||||||
|
let variables: Vec<_> = entries
|
||||||
|
.into_iter()
|
||||||
|
.skip(start)
|
||||||
|
.take(if count == 0 { usize::MAX } else { count })
|
||||||
|
.map(|(name, value)| {
|
||||||
|
let child_path = if path.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("{path}/{}", name.replace('~', "~0").replace('/', "~1"))
|
||||||
|
};
|
||||||
|
self.variable(&name, value, child_path)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(json!({"variables": variables}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(&mut self, args: &Json) -> Result<Json, String> {
|
||||||
|
if let Some(context) = args.get("context")
|
||||||
|
&& !matches!(context.as_str(), Some("watch" | "repl" | "variables"))
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"Snapshot paths are supported only in Watch, Debug Console and Variables"
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let expression = args["expression"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("Expected a snapshot path")?;
|
||||||
|
let mut tokens = expression.split('/');
|
||||||
|
if expression.len() > 4096 || tokens.next() != Some("actor") || tokens.clone().count() > 64
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"Use actor or actor/<JSON Pointer path>; Rune expressions are unsupported"
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let root = self
|
||||||
|
.root
|
||||||
|
.ok_or("Actor snapshot unavailable while running")?;
|
||||||
|
let mut value = self.values[&root].0.clone();
|
||||||
|
for token in tokens {
|
||||||
|
let mut chars = token.chars();
|
||||||
|
let mut key = String::new();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
key.push(if ch == '~' {
|
||||||
|
match chars.next() {
|
||||||
|
Some('0') => '~',
|
||||||
|
Some('1') => '/',
|
||||||
|
_ => return Err("Invalid JSON Pointer escape; use ~0 or ~1".to_owned()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ch
|
||||||
|
});
|
||||||
|
}
|
||||||
|
value = match value {
|
||||||
|
Value::Record(mut entries) => {
|
||||||
|
entries.remove(&key).ok_or("Snapshot field not found")?
|
||||||
|
}
|
||||||
|
Value::List(entries) => {
|
||||||
|
if key.is_empty()
|
||||||
|
|| (key.starts_with('0') && key.len() > 1)
|
||||||
|
|| !key.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return Err("Invalid snapshot list index".to_owned());
|
||||||
|
}
|
||||||
|
let index: usize = key.parse().map_err(|_| "Invalid snapshot list index")?;
|
||||||
|
entries
|
||||||
|
.get(index)
|
||||||
|
.cloned()
|
||||||
|
.ok_or("Snapshot list index out of range")?
|
||||||
|
}
|
||||||
|
_ => return Err("Snapshot path traverses a scalar value".to_owned()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mut variable = self.variable(expression, value, expression.to_owned());
|
||||||
|
variable["result"] = variable["value"].take();
|
||||||
|
let object = variable.as_object_mut().unwrap();
|
||||||
|
object.remove("name");
|
||||||
|
object.remove("value");
|
||||||
|
Ok(variable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn variable(&mut self, name: &str, value: Value, path: String) -> Json {
|
||||||
|
let (kind, preview, children, indexed) = match &value {
|
||||||
|
Value::Null => ("null", "null".to_owned(), 0, false),
|
||||||
|
Value::Bool(value) => ("bool", value.to_string(), 0, false),
|
||||||
|
Value::Integer(value) => ("integer", value.to_string(), 0, false),
|
||||||
|
Value::String(value) => {
|
||||||
|
let preview: String = value.chars().take(256).collect();
|
||||||
|
let preview = if preview.len() < value.len() {
|
||||||
|
format!("{}… ({} bytes)", json!(preview), value.len())
|
||||||
|
} else {
|
||||||
|
json!(preview).to_string()
|
||||||
|
};
|
||||||
|
("string", preview, 0, false)
|
||||||
|
}
|
||||||
|
Value::ActorRef(value) => ("actor_ref", format!("@{value}"), 0, false),
|
||||||
|
Value::List(entries) => (
|
||||||
|
"list",
|
||||||
|
format!("List({})", entries.len()),
|
||||||
|
entries.len(),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
Value::Record(entries) => (
|
||||||
|
"record",
|
||||||
|
format!("Record({})", entries.len()),
|
||||||
|
entries.len(),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let reference = if children == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.reference(value, path.clone())
|
||||||
|
};
|
||||||
|
let mut result = json!({
|
||||||
|
"name": name, "value": preview, "type": kind, "variablesReference": reference,
|
||||||
|
"presentationHint": {"attributes": ["readOnly"]}
|
||||||
|
});
|
||||||
|
if !path.is_empty() {
|
||||||
|
result["evaluateName"] = json!(path);
|
||||||
|
}
|
||||||
|
if matches!(kind, "list" | "record") {
|
||||||
|
result[if indexed {
|
||||||
|
"indexedVariables"
|
||||||
|
} else {
|
||||||
|
"namedVariables"
|
||||||
|
}] = json!(children);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record<const N: usize>(entries: [(&str, Value); N]) -> Value {
|
||||||
|
Value::Record(
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, value)| (key.to_owned(), value))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plain(value: Json) -> Value {
|
||||||
|
match value {
|
||||||
|
Json::Null => Value::Null,
|
||||||
|
Json::Bool(value) => Value::Bool(value),
|
||||||
|
Json::Number(value) => value
|
||||||
|
.as_i64()
|
||||||
|
.map(Value::Integer)
|
||||||
|
.unwrap_or_else(|| Value::String(value.to_string())),
|
||||||
|
Json::String(value) => Value::String(value),
|
||||||
|
Json::Array(values) => Value::List(values.into_iter().map(plain).collect()),
|
||||||
|
Json::Object(values) => Value::Record(
|
||||||
|
values
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, value)| (key, plain(value)))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,893 @@
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use runa::Server;
|
||||||
|
use runa_dap::{Adapter, Connection};
|
||||||
|
use runa_protocol::{Command, PROTOCOL_VERSION, Reply, Request, StepMode, Value};
|
||||||
|
use serde_json::{Value as Json, json};
|
||||||
|
|
||||||
|
const SOURCE: &str = r#"fn helper(value) {
|
||||||
|
value + 1
|
||||||
|
}
|
||||||
|
pub fn handle(ctx, state, sender, message) {
|
||||||
|
let next = helper(state);
|
||||||
|
next + message
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Image {
|
||||||
|
server: Rc<RefCell<Server>>,
|
||||||
|
commands: Rc<RefCell<Vec<Command>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Image {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
server: Rc::new(RefCell::new(Server::new())),
|
||||||
|
commands: Rc::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor(&mut self, path: &str, source: &str) -> String {
|
||||||
|
self.request(Command::WriteSource {
|
||||||
|
path: path.to_owned(),
|
||||||
|
expected_source_id: None,
|
||||||
|
source: source.to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let Reply::ActorSpawned { actor_ref } = self
|
||||||
|
.request(Command::Spawn {
|
||||||
|
behavior_name: path.to_owned(),
|
||||||
|
mailbox_capacity: 32,
|
||||||
|
state: Value::Integer(10),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected spawned actor")
|
||||||
|
};
|
||||||
|
actor_ref
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect(&mut self, actor: &str) -> runa_protocol::ActorSnapshotDto {
|
||||||
|
let Reply::Actor(actor) = self
|
||||||
|
.request(Command::InspectActor {
|
||||||
|
actor_ref: actor.to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
actor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Connection for Image {
|
||||||
|
fn request(&mut self, command: Command) -> Result<Reply, String> {
|
||||||
|
self.commands.borrow_mut().push(command.clone());
|
||||||
|
self.server
|
||||||
|
.borrow_mut()
|
||||||
|
.dispatch(Request {
|
||||||
|
version: PROTOCOL_VERSION,
|
||||||
|
request_id: "dap-test".to_owned(),
|
||||||
|
command,
|
||||||
|
})
|
||||||
|
.result
|
||||||
|
.map_err(|error| error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(adapter: &mut Adapter<Image>, command: &str, args: Json) -> Vec<Json> {
|
||||||
|
adapter.handle(json!({"seq": 7, "type": "request", "command": command, "arguments": args}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ok(adapter: &mut Adapter<Image>, command: &str, args: Json) -> Json {
|
||||||
|
let messages = request(adapter, command, args);
|
||||||
|
assert_eq!(messages[0]["success"], true, "{messages:?}");
|
||||||
|
messages[0]["body"].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach(image: &Image, actor: &str) -> Adapter<Image> {
|
||||||
|
let mut adapter = Adapter::new(image.clone());
|
||||||
|
ok(&mut adapter, "initialize", json!({}));
|
||||||
|
let messages = request(
|
||||||
|
&mut adapter,
|
||||||
|
"attach",
|
||||||
|
json!({
|
||||||
|
"actorRef": actor, "behaviorSourceUri": format!("runa-actor://session/{actor}/behavior.rn"),
|
||||||
|
"workingSourceUri": "runa-image://localhost/source/tests/handler.rn"
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(messages[0]["success"], true, "{messages:?}");
|
||||||
|
assert!(
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.any(|message| message["event"] == "initialized")
|
||||||
|
);
|
||||||
|
ok(&mut adapter, "configurationDone", json!({}));
|
||||||
|
adapter
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> (Image, String) {
|
||||||
|
let mut image = Image::new();
|
||||||
|
let actor = image.actor("tests/handler", SOURCE);
|
||||||
|
(image, actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_pause_source_and_resume_never_tick_or_clear_breakpoints() {
|
||||||
|
let (mut image, actor) = fixture();
|
||||||
|
let mut adapter = attach(&image, &actor);
|
||||||
|
assert_eq!(
|
||||||
|
image.inspect(&actor).status,
|
||||||
|
runa_protocol::ActorStatusDto::Paused
|
||||||
|
);
|
||||||
|
let stopped = adapter.poll();
|
||||||
|
assert!(
|
||||||
|
stopped
|
||||||
|
.iter()
|
||||||
|
.any(|message| message["event"] == "stopped" && message["body"]["reason"] == "pause")
|
||||||
|
);
|
||||||
|
assert!(adapter.poll().is_empty());
|
||||||
|
let sources = ok(&mut adapter, "loadedSources", json!({}));
|
||||||
|
assert_eq!(sources["sources"][0]["name"], "behavior.rn");
|
||||||
|
assert_eq!(sources["sources"][0]["sourceReference"], 1);
|
||||||
|
assert_eq!(
|
||||||
|
sources["sources"][0]["path"],
|
||||||
|
format!("runa-actor://session/{actor}/behavior.rn")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ok(&mut adapter, "source", json!({"sourceReference": 1}))["content"],
|
||||||
|
SOURCE
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ok(&mut adapter, "threads", json!({}))["threads"][0]["id"],
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let breakpoints = ok(
|
||||||
|
&mut adapter,
|
||||||
|
"setBreakpoints",
|
||||||
|
json!({"source": {"sourceReference": 1}, "breakpoints": [{"line": 5}]}),
|
||||||
|
);
|
||||||
|
assert_eq!(breakpoints["breakpoints"][0]["verified"], true);
|
||||||
|
ok(&mut adapter, "continue", json!({"threadId": 1}));
|
||||||
|
assert!(
|
||||||
|
!image
|
||||||
|
.commands
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.any(|command| matches!(command, Command::Tick { .. }))
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
image.inspect(&actor).status,
|
||||||
|
runa_protocol::ActorStatusDto::Paused
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
request(&mut adapter, "variables", json!({"variablesReference": 1}))[0]["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Stale")
|
||||||
|
);
|
||||||
|
ok(&mut adapter, "disconnect", json!({}));
|
||||||
|
let Reply::ExecutionInspected(inspection) = image
|
||||||
|
.request(Command::InspectExecution { actor_ref: actor })
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(inspection.debugger.breakpoints.len(), 1);
|
||||||
|
assert!(adapter.is_closed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breakpoints_are_actor_specific_and_all_mutable_sources_are_guarded() {
|
||||||
|
let (mut image, actor) = fixture();
|
||||||
|
let second = image.actor("tests/other", SOURCE);
|
||||||
|
let mut adapter = attach(&image, &actor);
|
||||||
|
let source = json!({"path": format!("runa-actor://session/{actor}/behavior.rn")});
|
||||||
|
let set = |source: Json, dirty: bool| json!({"source": source, "sourceModified": dirty, "breakpoints": [{"line": 5}]});
|
||||||
|
assert_eq!(
|
||||||
|
ok(&mut adapter, "setBreakpoints", set(source.clone(), false))["breakpoints"][0]["verified"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
let Reply::ExecutionInspected(other) = image
|
||||||
|
.request(Command::InspectExecution {
|
||||||
|
actor_ref: second.clone(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert!(other.debugger.breakpoints.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
ok(&mut adapter, "setBreakpoints", set(source.clone(), true))["breakpoints"][0]["verified"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
let Reply::ActorBehavior { source_id, .. } = image
|
||||||
|
.request(Command::ActorBehavior {
|
||||||
|
actor_ref: actor.clone(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
image
|
||||||
|
.request(Command::WriteActorBehavior {
|
||||||
|
actor_ref: actor.clone(),
|
||||||
|
expected_source_id: source_id,
|
||||||
|
source: SOURCE.replace("value + 1", "value + 2"),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ok(&mut adapter, "setBreakpoints", set(source, false))["breakpoints"][0]["verified"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"setBreakpoints",
|
||||||
|
set(json!({"sourceReference": 1}), true)
|
||||||
|
)["breakpoints"][0]["verified"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
let working = json!({"path": "runa-image://localhost/source/tests/handler.rn"});
|
||||||
|
assert_eq!(
|
||||||
|
ok(&mut adapter, "setBreakpoints", set(working.clone(), false))["breakpoints"][0]["verified"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ok(&mut adapter, "setBreakpoints", set(working.clone(), true))["breakpoints"][0]["verified"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
let Reply::Source(file) = image
|
||||||
|
.request(Command::ReadSource {
|
||||||
|
path: "tests/handler".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
image
|
||||||
|
.request(Command::WriteSource {
|
||||||
|
path: file.path,
|
||||||
|
expected_source_id: Some(file.source_id),
|
||||||
|
source: SOURCE.replace("value + 1", "value + 3"),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ok(&mut adapter, "setBreakpoints", set(working, false))["breakpoints"][0]["verified"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
let before = image
|
||||||
|
.commands
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.filter(|command| matches!(command, Command::SetBreakpoints { .. }))
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"setBreakpoints",
|
||||||
|
set(
|
||||||
|
json!({"path": format!("runa-actor://session/{second}/behavior.rn")}),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)["breakpoints"][0]["verified"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
let after = image
|
||||||
|
.commands
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.filter(|command| matches!(command, Command::SetBreakpoints { .. }))
|
||||||
|
.count();
|
||||||
|
assert_eq!(before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loaded_and_mutable_breakpoint_groups_merge_and_external_changes_emit_events() {
|
||||||
|
let (mut image, actor) = fixture();
|
||||||
|
let mut adapter = attach(&image, &actor);
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"setBreakpoints",
|
||||||
|
json!({"source": {"sourceReference": 1}, "breakpoints": [{"line": 5}]}),
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"setBreakpoints",
|
||||||
|
json!({"source": {"path": "runa-image://localhost/source/tests/handler.rn"}, "breakpoints": [{"line": 6}]}),
|
||||||
|
);
|
||||||
|
let Reply::ExecutionInspected(inspection) = image
|
||||||
|
.request(Command::InspectExecution {
|
||||||
|
actor_ref: actor.clone(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
inspection
|
||||||
|
.debugger
|
||||||
|
.breakpoints
|
||||||
|
.iter()
|
||||||
|
.map(|bp| bp.line)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[4, 5]
|
||||||
|
);
|
||||||
|
image
|
||||||
|
.request(Command::SetBreakpoints {
|
||||||
|
actor_ref: actor,
|
||||||
|
implementation_id: inspection.implementation_id,
|
||||||
|
lines: vec![],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let events = adapter.poll();
|
||||||
|
assert_eq!(
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event["event"] == "breakpoint"
|
||||||
|
&& event["body"]["breakpoint"]["verified"] == false)
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn variable_expansion_and_evaluation_share_snapshot_handles() {
|
||||||
|
let (mut image, actor) = fixture();
|
||||||
|
image
|
||||||
|
.request(Command::Send {
|
||||||
|
actor_ref: actor.clone(),
|
||||||
|
message: Value::Record(std::collections::BTreeMap::from([(
|
||||||
|
"a/b~c".to_owned(),
|
||||||
|
Value::List(vec![Value::Integer(3)]),
|
||||||
|
)])),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mut adapter = attach(&image, &actor);
|
||||||
|
adapter.poll();
|
||||||
|
let root =
|
||||||
|
ok(&mut adapter, "evaluate", json!({"expression": "actor"}))["variablesReference"].clone();
|
||||||
|
let evaluated_mailbox = ok(
|
||||||
|
&mut adapter,
|
||||||
|
"evaluate",
|
||||||
|
json!({"expression": "actor/mailbox"}),
|
||||||
|
)["variablesReference"]
|
||||||
|
.clone();
|
||||||
|
let root_variables = ok(
|
||||||
|
&mut adapter,
|
||||||
|
"variables",
|
||||||
|
json!({"variablesReference": root}),
|
||||||
|
);
|
||||||
|
let mailbox = root_variables["variables"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|variable| variable["name"] == "mailbox")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(mailbox["variablesReference"], evaluated_mailbox);
|
||||||
|
assert_eq!(
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"variables",
|
||||||
|
json!({"variablesReference": root})
|
||||||
|
),
|
||||||
|
root_variables
|
||||||
|
);
|
||||||
|
let queued = ok(
|
||||||
|
&mut adapter,
|
||||||
|
"variables",
|
||||||
|
json!({"variablesReference": evaluated_mailbox}),
|
||||||
|
)["variables"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|variable| variable["name"] == "queued")
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
assert_eq!(
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"evaluate",
|
||||||
|
json!({"expression": "actor/mailbox/queued"})
|
||||||
|
)["variablesReference"],
|
||||||
|
queued["variablesReference"]
|
||||||
|
);
|
||||||
|
let message = ok(
|
||||||
|
&mut adapter,
|
||||||
|
"variables",
|
||||||
|
json!({"variablesReference": queued["variablesReference"]}),
|
||||||
|
)["variables"][0]
|
||||||
|
.clone();
|
||||||
|
let field = ok(
|
||||||
|
&mut adapter,
|
||||||
|
"variables",
|
||||||
|
json!({"variablesReference": message["variablesReference"]}),
|
||||||
|
)["variables"][0]
|
||||||
|
.clone();
|
||||||
|
assert_eq!(field["evaluateName"], "actor/mailbox/queued/0/a~1b~0c");
|
||||||
|
assert_eq!(
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"evaluate",
|
||||||
|
json!({"expression": field["evaluateName"]})
|
||||||
|
)["variablesReference"],
|
||||||
|
field["variablesReference"]
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"setBreakpoints",
|
||||||
|
json!({"source": {"sourceReference": 1}, "breakpoints": [{"line": 5}]}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!adapter
|
||||||
|
.poll()
|
||||||
|
.iter()
|
||||||
|
.any(|message| message["event"] == "stopped")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"evaluate",
|
||||||
|
json!({"expression": "actor/mailbox"})
|
||||||
|
)["variablesReference"],
|
||||||
|
evaluated_mailbox
|
||||||
|
);
|
||||||
|
image
|
||||||
|
.request(Command::EditActor {
|
||||||
|
actor_ref: actor,
|
||||||
|
state: Some(Value::Integer(99)),
|
||||||
|
messages: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
adapter.poll();
|
||||||
|
let replacement = ok(
|
||||||
|
&mut adapter,
|
||||||
|
"evaluate",
|
||||||
|
json!({"expression": "actor/mailbox"}),
|
||||||
|
)["variablesReference"]
|
||||||
|
.clone();
|
||||||
|
assert_ne!(replacement, evaluated_mailbox);
|
||||||
|
let stale = request(
|
||||||
|
&mut adapter,
|
||||||
|
"variables",
|
||||||
|
json!({"variablesReference": evaluated_mailbox}),
|
||||||
|
);
|
||||||
|
assert!(stale[0]["message"].as_str().unwrap().contains("Stale"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_paths_readonly_pagination_escaping_and_stale_references() {
|
||||||
|
let (mut image, actor) = fixture();
|
||||||
|
let mut adapter = attach(&image, &actor);
|
||||||
|
adapter.poll();
|
||||||
|
let state = ok(
|
||||||
|
&mut adapter,
|
||||||
|
"evaluate",
|
||||||
|
json!({"expression": "actor/committed_state", "context": "watch"}),
|
||||||
|
);
|
||||||
|
assert_eq!(state["result"], "10");
|
||||||
|
assert_eq!(state["evaluateName"], "actor/committed_state");
|
||||||
|
assert_eq!(state["presentationHint"]["attributes"], json!(["readOnly"]));
|
||||||
|
let root = ok(&mut adapter, "evaluate", json!({"expression": "actor"}))["variablesReference"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap();
|
||||||
|
let variables = ok(
|
||||||
|
&mut adapter,
|
||||||
|
"variables",
|
||||||
|
json!({"variablesReference": root, "start": 1, "count": 1}),
|
||||||
|
);
|
||||||
|
assert_eq!(variables["variables"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"variables",
|
||||||
|
json!({"variablesReference": root, "filter": "indexed"})
|
||||||
|
)["variables"],
|
||||||
|
json!([])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"evaluate",
|
||||||
|
json!({"expression": "actor/mailbox/queued"})
|
||||||
|
)["result"],
|
||||||
|
"List(0)"
|
||||||
|
);
|
||||||
|
let mut fields = std::collections::BTreeMap::new();
|
||||||
|
fields.insert("a/b~c".to_owned(), Value::Integer(42));
|
||||||
|
image
|
||||||
|
.request(Command::EditActor {
|
||||||
|
actor_ref: actor.clone(),
|
||||||
|
state: Some(Value::Record(fields)),
|
||||||
|
messages: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
adapter
|
||||||
|
.poll()
|
||||||
|
.iter()
|
||||||
|
.any(|message| message["event"] == "stopped")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request(
|
||||||
|
&mut adapter,
|
||||||
|
"variables",
|
||||||
|
json!({"variablesReference": root})
|
||||||
|
)[0]["success"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"evaluate",
|
||||||
|
json!({"expression": "actor/committed_state/a~1b~0c"})
|
||||||
|
)["result"],
|
||||||
|
"42"
|
||||||
|
);
|
||||||
|
for expression in [
|
||||||
|
"state + 1",
|
||||||
|
"actor/__proto__",
|
||||||
|
"actor/committed_state/a~2b",
|
||||||
|
"actor/mailbox/queued/01",
|
||||||
|
"actor/mailbox/queued/0",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
request(&mut adapter, "evaluate", json!({"expression": expression}))[0]["success"],
|
||||||
|
false,
|
||||||
|
"{expression}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ok(&mut adapter, "continue", json!({"threadId": 1}));
|
||||||
|
assert_eq!(
|
||||||
|
request(&mut adapter, "evaluate", json!({"expression": "actor"}))[0]["success"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_steps_use_bounded_modes_and_real_frames_without_locals() {
|
||||||
|
let (mut image, actor) = fixture();
|
||||||
|
image
|
||||||
|
.request(Command::Send {
|
||||||
|
actor_ref: actor.clone(),
|
||||||
|
message: Value::Integer(2),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
image
|
||||||
|
.request(Command::Tick {
|
||||||
|
ticks: 1,
|
||||||
|
instruction_budget: 1,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mut adapter = attach(&image, &actor);
|
||||||
|
adapter.poll();
|
||||||
|
let old_root =
|
||||||
|
ok(&mut adapter, "evaluate", json!({"expression": "actor"}))["variablesReference"].clone();
|
||||||
|
for (command, mode) in [
|
||||||
|
("stepIn", StepMode::Into),
|
||||||
|
("next", StepMode::Over),
|
||||||
|
("stepOut", StepMode::Out),
|
||||||
|
] {
|
||||||
|
let messages = request(&mut adapter, command, json!({"threadId": 1}));
|
||||||
|
assert_eq!(messages[0]["success"], true, "{messages:?}");
|
||||||
|
assert!(
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.any(|message| message["event"] == "continued")
|
||||||
|
);
|
||||||
|
assert!(image.commands.borrow().iter().any(|command| matches!(command, Command::StepSource {instruction_budget: 1000, mode: actual, ..} if *actual == mode)));
|
||||||
|
let events = adapter.poll();
|
||||||
|
assert!(
|
||||||
|
events.iter().any(|event| event["event"] == "stopped"),
|
||||||
|
"{events:?}"
|
||||||
|
);
|
||||||
|
let trace = ok(&mut adapter, "stackTrace", json!({"threadId": 1}));
|
||||||
|
if command == "stepIn" {
|
||||||
|
assert!(trace["totalFrames"].as_u64().unwrap() > 0);
|
||||||
|
}
|
||||||
|
for frame in trace["stackFrames"].as_array().unwrap() {
|
||||||
|
let scopes = ok(&mut adapter, "scopes", json!({"frameId": frame["id"]}));
|
||||||
|
assert_eq!(scopes["scopes"].as_array().unwrap().len(), 2);
|
||||||
|
assert!(
|
||||||
|
scopes["scopes"][0]["name"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("not frame locals")
|
||||||
|
);
|
||||||
|
let metadata = ok(
|
||||||
|
&mut adapter,
|
||||||
|
"variables",
|
||||||
|
json!({"variablesReference": scopes["scopes"][1]["variablesReference"]}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
metadata["variables"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|variable| variable["name"] == "instruction_pointer")
|
||||||
|
);
|
||||||
|
assert_eq!(frame["source"]["name"], "behavior.rn");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
request(
|
||||||
|
&mut adapter,
|
||||||
|
"variables",
|
||||||
|
json!({"variablesReference": old_root})
|
||||||
|
)[0]["success"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_step_reports_budget_without_claiming_to_reach_a_source_line() {
|
||||||
|
let mut image = Image::new();
|
||||||
|
let actor = image.actor(
|
||||||
|
"tests/loop",
|
||||||
|
"pub fn handle(ctx, state, sender, message) { loop { let value = state + 1; } }",
|
||||||
|
);
|
||||||
|
image
|
||||||
|
.request(Command::Send {
|
||||||
|
actor_ref: actor.clone(),
|
||||||
|
message: Value::Null,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
image
|
||||||
|
.request(Command::Tick {
|
||||||
|
ticks: 1,
|
||||||
|
instruction_budget: 1,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mut adapter = attach(&image, &actor);
|
||||||
|
adapter.poll();
|
||||||
|
let response = request(&mut adapter, "stepOut", json!({"threadId": 1}));
|
||||||
|
assert_eq!(response[0]["success"], true, "{response:?}");
|
||||||
|
assert!(response.iter().any(|message| {
|
||||||
|
message["event"] == "output"
|
||||||
|
&& message["body"]["output"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("budget")
|
||||||
|
}));
|
||||||
|
let stopped = adapter.poll();
|
||||||
|
assert!(
|
||||||
|
stopped
|
||||||
|
.iter()
|
||||||
|
.any(|message| message["event"] == "stopped" && message["body"]["reason"] == "pause")
|
||||||
|
);
|
||||||
|
assert_eq!(image.inspect(&actor).state, Value::Integer(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_attach_is_nonmutating_and_controls_cannot_retry_invocation() {
|
||||||
|
let mut image = Image::new();
|
||||||
|
let actor = image.actor(
|
||||||
|
"tests/failure",
|
||||||
|
"pub fn handle(ctx, state, sender, message) { panic(\"bad invocation\") }",
|
||||||
|
);
|
||||||
|
image
|
||||||
|
.request(Command::Send {
|
||||||
|
actor_ref: actor.clone(),
|
||||||
|
message: Value::Integer(1),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
image
|
||||||
|
.request(Command::Tick {
|
||||||
|
ticks: 1,
|
||||||
|
instruction_budget: 1000,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let before = image.inspect(&actor);
|
||||||
|
assert!(matches!(
|
||||||
|
before.status,
|
||||||
|
runa_protocol::ActorStatusDto::Failed(_)
|
||||||
|
));
|
||||||
|
image.commands.borrow_mut().clear();
|
||||||
|
let mut adapter = attach(&image, &actor);
|
||||||
|
assert_eq!(image.inspect(&actor), before);
|
||||||
|
assert!(
|
||||||
|
!image
|
||||||
|
.commands
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.any(|command| matches!(command, Command::System { .. }))
|
||||||
|
);
|
||||||
|
let events = adapter.poll();
|
||||||
|
assert!(
|
||||||
|
events.iter().any(
|
||||||
|
|message| message["event"] == "stopped" && message["body"]["reason"] == "exception"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let failure = ok(&mut adapter, "exceptionInfo", json!({"threadId": 1}));
|
||||||
|
assert_eq!(failure["breakMode"], "always");
|
||||||
|
assert!(
|
||||||
|
failure["description"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("bad invocation")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ok(&mut adapter, "stackTrace", json!({"threadId": 1}))["stackFrames"],
|
||||||
|
json!([])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"evaluate",
|
||||||
|
json!({"expression": "actor/mailbox/reserved"})
|
||||||
|
)["result"],
|
||||||
|
"List(1)"
|
||||||
|
);
|
||||||
|
for command in ["pause", "continue", "next", "stepIn", "stepOut"] {
|
||||||
|
let response = request(&mut adapter, command, json!({"threadId": 1}));
|
||||||
|
assert_eq!(response[0]["success"], false);
|
||||||
|
assert!(
|
||||||
|
response[0]["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("repair/restart")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(image.inspect(&actor), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_actor_is_guarded_even_before_first_poll() {
|
||||||
|
let mut image = Image::new();
|
||||||
|
let actor = image.actor(
|
||||||
|
"tests/failure",
|
||||||
|
"pub fn handle(ctx, state, sender, message) { panic(\"failed\") }",
|
||||||
|
);
|
||||||
|
image
|
||||||
|
.request(Command::Send {
|
||||||
|
actor_ref: actor.clone(),
|
||||||
|
message: Value::Null,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
image
|
||||||
|
.request(Command::Tick {
|
||||||
|
ticks: 1,
|
||||||
|
instruction_budget: 1000,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mut adapter = attach(&image, &actor);
|
||||||
|
assert_eq!(
|
||||||
|
request(&mut adapter, "continue", json!({"threadId": 1}))[0]["success"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incarnation_change_ends_session_before_mutating_actor() {
|
||||||
|
let (mut image, actor) = fixture();
|
||||||
|
let mut adapter = attach(&image, &actor);
|
||||||
|
image
|
||||||
|
.request(Command::RestartActor {
|
||||||
|
actor_ref: actor.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
image.commands.borrow_mut().clear();
|
||||||
|
let response = request(&mut adapter, "continue", json!({"threadId": 1}));
|
||||||
|
assert_eq!(response[0]["success"], false);
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.iter()
|
||||||
|
.any(|message| message["event"] == "terminated")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!image
|
||||||
|
.commands
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.any(|command| matches!(command, Command::System { .. }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_stop_and_implementation_change_terminate_on_poll() {
|
||||||
|
for stop in [false, true] {
|
||||||
|
let (mut image, actor) = fixture();
|
||||||
|
let mut adapter = attach(&image, &actor);
|
||||||
|
if stop {
|
||||||
|
image
|
||||||
|
.request(Command::StopActor { actor_ref: actor })
|
||||||
|
.unwrap();
|
||||||
|
image
|
||||||
|
.request(Command::Tick {
|
||||||
|
ticks: 1,
|
||||||
|
instruction_budget: 1,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
} else {
|
||||||
|
image
|
||||||
|
.request(Command::PublishBehavior {
|
||||||
|
name: "tests/replacement".to_owned(),
|
||||||
|
expected_revision: None,
|
||||||
|
source: "pub fn handle(ctx, state, sender, message) { state }".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
image
|
||||||
|
.request(Command::ResumeActor {
|
||||||
|
actor_ref: actor.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
image
|
||||||
|
.request(Command::InstallBehavior {
|
||||||
|
actor_ref: actor,
|
||||||
|
behavior_name: "tests/replacement".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let events = adapter.poll();
|
||||||
|
assert!(
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.any(|message| message["event"] == "terminated"),
|
||||||
|
"stop={stop}, events={events:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_requests_and_unsupported_commands_never_silently_succeed() {
|
||||||
|
let (image, actor) = fixture();
|
||||||
|
let mut adapter = attach(&image, &actor);
|
||||||
|
for (command, args) in [
|
||||||
|
("launch", json!({})),
|
||||||
|
("terminate", json!({})),
|
||||||
|
("setVariable", json!({})),
|
||||||
|
("setExceptionBreakpoints", json!({"filters": ["all"]})),
|
||||||
|
("continue", json!({"threadId": 2})),
|
||||||
|
("source", json!({"sourceReference": 9})),
|
||||||
|
("next", json!({"threadId": 1, "granularity": "instruction"})),
|
||||||
|
("stackTrace", json!({"threadId": 1, "startFrame": -1})),
|
||||||
|
(
|
||||||
|
"evaluate",
|
||||||
|
json!({"expression": "actor", "context": "hover"}),
|
||||||
|
),
|
||||||
|
("disconnect", json!({"terminateDebuggee": true})),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
request(&mut adapter, command, args)[0]["success"],
|
||||||
|
false,
|
||||||
|
"{command}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"setExceptionBreakpoints",
|
||||||
|
json!({"filters": []})
|
||||||
|
)["breakpoints"],
|
||||||
|
json!([])
|
||||||
|
);
|
||||||
|
let invalid = adapter.handle(json!({"type": "request", "command": "threads", "seq": -1}));
|
||||||
|
assert!(
|
||||||
|
invalid
|
||||||
|
.iter()
|
||||||
|
.any(|message| message["event"] == "terminated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_based_locations_and_unsupported_breakpoint_features() {
|
||||||
|
let (image, actor) = fixture();
|
||||||
|
let mut adapter = Adapter::new(image);
|
||||||
|
ok(
|
||||||
|
&mut adapter,
|
||||||
|
"initialize",
|
||||||
|
json!({"linesStartAt1": false, "columnsStartAt1": false}),
|
||||||
|
);
|
||||||
|
ok(&mut adapter, "attach", json!({"actorRef": actor}));
|
||||||
|
let response = ok(
|
||||||
|
&mut adapter,
|
||||||
|
"setBreakpoints",
|
||||||
|
json!({"source": {"sourceReference": 1}, "breakpoints": [{"line": 4}, {"line": 5, "condition": "true"}, {"line": 9999}]}),
|
||||||
|
);
|
||||||
|
assert_eq!(response["breakpoints"][0]["line"], 4);
|
||||||
|
assert_eq!(response["breakpoints"][0]["verified"], true);
|
||||||
|
assert_eq!(response["breakpoints"][1]["verified"], false);
|
||||||
|
assert_eq!(response["breakpoints"][2]["verified"], false);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
use std::io::{Cursor, Write};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use runa_dap::framing::{read_frame, write_frame};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
fn run(input: &[u8], args: &[&str]) -> std::process::Output {
|
||||||
|
let mut child = Command::new(env!("CARGO_BIN_EXE_runa-dap"))
|
||||||
|
.args(args)
|
||||||
|
.env_remove("RUNA_CONNECT")
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
child.stdin.take().unwrap().write_all(input).unwrap();
|
||||||
|
child.wait_with_output().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn messages(output: &[u8]) -> Vec<Value> {
|
||||||
|
let mut reader = Cursor::new(output);
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
while let Some(message) = read_frame(&mut reader).unwrap() {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn executable_exchanges_standard_frames_and_disconnects_without_a_server() {
|
||||||
|
let mut input = Vec::new();
|
||||||
|
write_frame(
|
||||||
|
&mut input,
|
||||||
|
&json!({"seq": 1, "type": "request", "command": "initialize"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write_frame(
|
||||||
|
&mut input,
|
||||||
|
&json!({"seq": 2, "type": "request", "command": "launch"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write_frame(
|
||||||
|
&mut input,
|
||||||
|
&json!({"seq": 3, "type": "request", "command": "disconnect"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let output = run(&input, &[]);
|
||||||
|
assert!(output.status.success(), "{:?}", output.stderr);
|
||||||
|
let messages = messages(&output.stdout);
|
||||||
|
assert_eq!(
|
||||||
|
messages[0]["body"]["supportsConfigurationDoneRequest"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(messages[1]["success"], false);
|
||||||
|
assert_eq!(messages[2]["success"], true);
|
||||||
|
assert_eq!(messages[3]["event"], "terminated");
|
||||||
|
for (index, message) in messages.iter().enumerate() {
|
||||||
|
assert_eq!(message["seq"], index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn executable_reports_bad_framing_on_protocol_and_stderr() {
|
||||||
|
let output = run(b"Content-Length: 1048577\r\n\r\n", &[]);
|
||||||
|
assert!(!output.status.success());
|
||||||
|
assert!(!output.stderr.is_empty());
|
||||||
|
let messages = messages(&output.stdout);
|
||||||
|
assert_eq!(messages[0]["event"], "output");
|
||||||
|
assert!(
|
||||||
|
messages[0]["body"]["output"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Invalid DAP framing")
|
||||||
|
);
|
||||||
|
assert_eq!(messages[1]["event"], "terminated");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_documents_connect_and_rejects_nonloopback_addresses() {
|
||||||
|
let help = run(&[], &["--help"]);
|
||||||
|
assert!(help.status.success());
|
||||||
|
let help = String::from_utf8(help.stdout).unwrap();
|
||||||
|
assert!(help.contains("RUNA_CONNECT"));
|
||||||
|
assert!(help.contains("127.0.0.1:3737"));
|
||||||
|
for address in ["0.0.0.0:3737", "192.0.2.1:3737", "invalid"] {
|
||||||
|
let output = run(&[], &["--connect", address]);
|
||||||
|
assert!(!output.status.success());
|
||||||
|
assert!(!output.stderr.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -620,7 +620,7 @@ impl WorldApp {
|
||||||
("state.json", ActorFile::State),
|
("state.json", ActorFile::State),
|
||||||
("mailbox.json", ActorFile::Mailbox),
|
("mailbox.json", ActorFile::Mailbox),
|
||||||
("actor.log", ActorFile::Logs),
|
("actor.log", ActorFile::Logs),
|
||||||
("behavior", ActorFile::Behavior),
|
("behavior.rn", ActorFile::Behavior),
|
||||||
] {
|
] {
|
||||||
if ui
|
if ui
|
||||||
.selectable_label(
|
.selectable_label(
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ behavior and explicitly update its shared source or reload from that source.
|
||||||
Resizable side panels provide a source file tree on the left and an actor tree
|
Resizable side panels provide a source file tree on the left and an actor tree
|
||||||
on the right. Source files open in the central editor and select the behavior
|
on the right. Source files open in the central editor and select the behavior
|
||||||
for spawning; actor entries expand into metadata/inspector, state, mailbox, log,
|
for spawning; actor entries expand into metadata/inspector, state, mailbox, log,
|
||||||
and behavior views.
|
and `behavior.rn` views.
|
||||||
The events view polls a bounded, non-destructive event history with an
|
The events view polls a bounded, non-destructive event history with an
|
||||||
independent cursor per client; reading events does not consume them for other
|
independent cursor per client; reading events does not consume them for other
|
||||||
clients. The server retains up to 4096 events in memory, and this process-local
|
clients. The server retains up to 4096 events in memory, and this process-local
|
||||||
|
|
@ -459,6 +459,7 @@ runa/
|
||||||
│ ├── runa-protocol/
|
│ ├── runa-protocol/
|
||||||
│ ├── runa-cli/
|
│ ├── runa-cli/
|
||||||
│ ├── runa-client/
|
│ ├── runa-client/
|
||||||
|
│ ├── runa-dap/
|
||||||
│ ├── runa-graphics/
|
│ ├── runa-graphics/
|
||||||
│ ├── runa-world/
|
│ ├── runa-world/
|
||||||
│ └── runa/
|
│ └── runa/
|
||||||
|
|
@ -577,8 +578,11 @@ exact-loaded-source documents, including old code during pending replacements.
|
||||||
Await locations represent the last safe snapshot; native/hidden async frames,
|
Await locations represent the last safe snapshot; native/hidden async frames,
|
||||||
locals and post-mortem stacks remain unimplemented. Actor-specific
|
locals and post-mortem stacks remain unimplemented. Actor-specific
|
||||||
implementation-bound line breakpoints and bounded step-into/over/out now preserve active
|
implementation-bound line breakpoints and bounded step-into/over/out now preserve active
|
||||||
drafts/effects and use exact loaded-source mappings. An attach-only inline DAP
|
drafts/effects and use exact loaded-source mappings. A standalone Rust `runa-dap`
|
||||||
adapter now exposes one actor as one VS Code debug thread with exact loaded
|
server speaks DAP over stdio and connects to the image over loopback WebSocket.
|
||||||
|
The VS Code extension launches it as an executable adapter. Every actor exposes
|
||||||
|
`behavior.rn`; its gutter breakpoints bind only to that actor's exact loaded code.
|
||||||
|
The attach-only adapter exposes one actor as one VS Code debug thread with exact loaded
|
||||||
sources, gutter breakpoints, call stacks, pause/resume, and bounded toolbar
|
sources, gutter breakpoints, call stacks, pause/resume, and bounded toolbar
|
||||||
stepping. Continue does not implicitly tick the shared scheduler. Sessions bind
|
stepping. Continue does not implicitly tick the shared scheduler. Sessions bind
|
||||||
to the connection, implementation and incarnation; a change terminates the
|
to the connection, implementation and incarnation; a change terminates the
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,9 @@ as diagnostics and refer only to the returned loaded source.
|
||||||
Only the head Rune VM's visible frames are captured, not native stacks or stacks
|
Only the head Rune VM's visible frames are captured, not native stacks or stacks
|
||||||
hidden inside nested futures. Pending async steps are not counted as completed
|
hidden inside nested futures. Pending async steps are not counted as completed
|
||||||
VM steps. Snapshots contain no VM stack values or mutable execution handles.
|
VM steps. Snapshots contain no VM stack values or mutable execution handles.
|
||||||
There is no locals inspection, post-mortem stack retention, or DAP contract.
|
There is no locals inspection or post-mortem stack retention. The separate
|
||||||
|
`runa-dap` adapter maps these inspections to DAP; WebSocket messages themselves
|
||||||
|
remain the image protocol.
|
||||||
Responses are point-in-time; clients must pause explicitly for stable inspection.
|
Responses are point-in-time; clients must pause explicitly for stable inspection.
|
||||||
|
|
||||||
### Source debugger controls
|
### Source debugger controls
|
||||||
|
|
@ -206,7 +208,8 @@ configuration and no reason. `stop_reason` is nullable and uses the same reason
|
||||||
strings as step replies. Installation clears breakpoints; restart/cancel retain
|
strings as step replies. Installation clears breakpoints; restart/cancel retain
|
||||||
them for unchanged loaded code and clear stop reasons. Debugger state is actor
|
them for unchanged loaded code and clear stop reasons. Debugger state is actor
|
||||||
local, shared by clients, and in-memory; it is not owned by a client connection.
|
local, shared by clients, and in-memory; it is not owned by a client connection.
|
||||||
There are no conditions, hit counts, or gutter/DAP breakpoints yet.
|
There are no conditions or hit counts. The standalone `runa-dap` adapter maps
|
||||||
|
DAP gutter breakpoints to these actor-local controls.
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -221,6 +224,23 @@ restarted. Reload/become defer installation to
|
||||||
the next handler boundary. Normal scheduling advances through `tick`; the
|
the next handler boundary. Normal scheduling advances through `tick`; the
|
||||||
explicit debugger step operation advances only its selected paused actor.
|
explicit debugger step operation advances only its selected paused actor.
|
||||||
|
|
||||||
|
### Standalone Debug Adapter Protocol server
|
||||||
|
|
||||||
|
`runa-dap --connect 127.0.0.1:3737` speaks standard Content-Length-framed DAP
|
||||||
|
on stdin/stdout and connects to an existing trusted loopback image using the
|
||||||
|
WebSocket operations above. Diagnostics go to stderr. It does not start an
|
||||||
|
image, tick the scheduler implicitly, or expose another network listener.
|
||||||
|
|
||||||
|
An attach request supplies `actorRef`, optionally `behaviorSourceUri` (the
|
||||||
|
actor's `behavior.rn` editor URI) and `workingSourceUri` (its shared image source
|
||||||
|
URI). VS Code supplies these URIs automatically. The adapter exposes one actor
|
||||||
|
as one thread and binds the session to its installed implementation/incarnation.
|
||||||
|
It supports actor-local breakpoints, exact loaded source references, stack
|
||||||
|
inspection, committed snapshot scopes and watches, pause/continue, bounded
|
||||||
|
source stepping, and failure inspection. Mutable-file breakpoints must match
|
||||||
|
the exact loaded text; modified documents remain unverified. Disconnect leaves
|
||||||
|
actor status and breakpoints unchanged.
|
||||||
|
|
||||||
Responses echo the request ID and carry either a typed reply or a protocol
|
Responses echo the request ID and carry either a typed reply or a protocol
|
||||||
error. Events use a separate versioned envelope. Actor and source snapshots
|
error. Events use a separate versioned envelope. Actor and source snapshots
|
||||||
are wire DTOs, not serialized runtime objects; implementation identities are
|
are wire DTOs, not serialized runtime objects; implementation identities are
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,8 @@ or provide automatic crash recovery. See
|
||||||
sequence IDs preserve ordering without inventing timestamps. Embedded
|
sequence IDs preserve ordering without inventing timestamps. Embedded
|
||||||
CR/LF characters are escaped so each entry occupies one line. Only the server's
|
CR/LF characters are escaped so each entry occupies one line. Only the server's
|
||||||
latest 1,000 accepted entries per actor are available.
|
latest 1,000 accepted entries per actor are available.
|
||||||
- `behavior`: present after an actor-specific temporary behavior is saved.
|
- `behavior.rn`: available for every actor; opens its loaded behavior or
|
||||||
|
actor-specific temporary script. Saving compiles an actor-local override.
|
||||||
These files use connection-scoped `runa-actor:` URIs. Removed/disconnected
|
These files use connection-scoped `runa-actor:` URIs. Removed/disconnected
|
||||||
actors' open JSON files retain their last values and become read-only; already
|
actors' open JSON files retain their last values and become read-only; already
|
||||||
opened log/behavior files retain cached text.
|
opened log/behavior files retain cached text.
|
||||||
|
|
@ -241,16 +242,21 @@ configuration (omit `actorRef` to choose an actor):
|
||||||
|
|
||||||
**Debug Actor** does not save open editors. Starting an attach configuration
|
**Debug Actor** does not save open editors. Starting an attach configuration
|
||||||
directly through Run and Debug uses VS Code's `debug.saveBeforeStart` setting.
|
directly through Run and Debug uses VS Code's `debug.saveBeforeStart` setting.
|
||||||
The extension's inline Debug Adapter Protocol adapter uses the existing image
|
The extension launches the standalone Rust `runa-dap` Debug Adapter Protocol
|
||||||
connection; it does not start a server, spawn an actor, or open a separate debug
|
server over standard input/output. Build it with `cargo build -p runa-dap`
|
||||||
port. Each session represents one actor/thread and is bound to the current
|
and set `runa.debugAdapterPath` to the executable's absolute path (for example
|
||||||
|
`/path/to/runa/target/debug/runa-dap`), or install it on PATH with
|
||||||
|
`cargo install --path crates/runa-dap` from the repository root.
|
||||||
|
The adapter connects to the selected image over loopback WebSocket; it does not
|
||||||
|
start an image, spawn an actor, or open a separate debug port.
|
||||||
|
Each session represents one actor/thread and is bound to the current
|
||||||
connection, installed implementation, and incarnation. Only one session per
|
connection, installed implementation, and incarnation. Only one session per
|
||||||
actor is allowed. Attach pauses a live nonfailed actor, retaining its active
|
actor is allowed. Attach pauses a live nonfailed actor, retaining its active
|
||||||
invocation if any. An idle actor can be attached, but has no frames until it
|
invocation if any. An idle actor can be attached, but has no frames until it
|
||||||
starts a handler. An already-failed actor can also be attached without changing
|
starts a handler. An already-failed actor can also be attached without changing
|
||||||
its status, incarnation, state or mailbox.
|
its status, incarnation, state or mailbox.
|
||||||
|
|
||||||
- Set gutter breakpoints in the actor's immutable loaded source, opened by the
|
- Set gutter breakpoints in each actor's `behavior.rn`, or its immutable loaded source, opened by the
|
||||||
command or Call Stack view. The debugger serves the exact compiled text,
|
command or Call Stack view. The debugger serves the exact compiled text,
|
||||||
including temporary/older implementations, using source references.
|
including temporary/older implementations, using source references.
|
||||||
Breakpoints in the image source editor are accepted only when both the saved
|
Breakpoints in the image source editor are accepted only when both the saved
|
||||||
|
|
@ -444,7 +450,7 @@ returns the new `source_id` after confirmation.
|
||||||
|
|
||||||
## Compilation diagnostics
|
## Compilation diagnostics
|
||||||
|
|
||||||
Open image sources and actor-specific `behavior` documents are checked after a
|
Open image sources and actor-specific `behavior.rn` documents are checked after a
|
||||||
300 ms edit debounce. Rune errors and warnings appear inline and in **Problems**,
|
300 ms edit debounce. Rune errors and warnings appear inline and in **Problems**,
|
||||||
with zero-based UTF-16 ranges converted from Rune's UTF-8 byte spans. Non-ASCII
|
with zero-based UTF-16 ranges converted from Rune's UTF-8 byte spans. Non-ASCII
|
||||||
text and CRLF line endings retain correct editor locations. Diagnostics without
|
text and CRLF line endings retain correct editor locations. Diagnostics without
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { EventEmitter, once } from "node:events";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { ImageClient } from "../src/client";
|
||||||
|
import { parseValue } from "../src/values";
|
||||||
|
import { startImage } from "./server";
|
||||||
|
|
||||||
|
interface DapMessage {
|
||||||
|
type: string;
|
||||||
|
request_seq?: number;
|
||||||
|
success?: boolean;
|
||||||
|
message?: string;
|
||||||
|
event?: string;
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAdapter(address: string) {
|
||||||
|
const child = spawn(
|
||||||
|
resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../../target/debug",
|
||||||
|
process.platform === "win32" ? "runa-dap.exe" : "runa-dap",
|
||||||
|
),
|
||||||
|
["--connect", address],
|
||||||
|
{ stdio: ["pipe", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
const messages: DapMessage[] = [];
|
||||||
|
const changed = new EventEmitter();
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
let sequence = 0;
|
||||||
|
let failure: Error | undefined;
|
||||||
|
let diagnostics = "";
|
||||||
|
const fail = (error: Error) => {
|
||||||
|
failure = error;
|
||||||
|
changed.emit("message");
|
||||||
|
};
|
||||||
|
child.on("error", fail);
|
||||||
|
child.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
diagnostics += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => fail(new Error(`DAP exited (${code}): ${diagnostics}`)));
|
||||||
|
child.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
try {
|
||||||
|
buffer = Buffer.concat([buffer, chunk]);
|
||||||
|
for (;;) {
|
||||||
|
const end = buffer.indexOf("\r\n\r\n");
|
||||||
|
if (end < 0) return;
|
||||||
|
const match = /^Content-Length: (\d+)$/im.exec(buffer.subarray(0, end).toString());
|
||||||
|
assert.ok(match, "DAP stdout must contain framed messages only");
|
||||||
|
const length = Number(match[1]);
|
||||||
|
if (buffer.length < end + 4 + length) return;
|
||||||
|
const message: DapMessage = JSON.parse(
|
||||||
|
buffer.subarray(end + 4, end + 4 + length).toString(),
|
||||||
|
);
|
||||||
|
buffer = buffer.subarray(end + 4 + length);
|
||||||
|
messages.push(message);
|
||||||
|
changed.emit("message");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const wait = (predicate: (message: DapMessage) => boolean, since = 0): Promise<DapMessage> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
changed.off("message", check);
|
||||||
|
};
|
||||||
|
const check = () => {
|
||||||
|
const found = messages.slice(since).find(predicate);
|
||||||
|
if (found) {
|
||||||
|
cleanup();
|
||||||
|
resolve(found);
|
||||||
|
} else if (failure) {
|
||||||
|
cleanup();
|
||||||
|
reject(failure);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`DAP response timed out: ${diagnostics}; ${JSON.stringify(messages.slice(-5))}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, 5000);
|
||||||
|
changed.on("message", check);
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
wait,
|
||||||
|
async request<T>(command: string, args: unknown = {}): Promise<T> {
|
||||||
|
const seq = ++sequence;
|
||||||
|
const body = Buffer.from(JSON.stringify({ seq, type: "request", command, arguments: args }));
|
||||||
|
child.stdin.write(
|
||||||
|
Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body]),
|
||||||
|
);
|
||||||
|
const response = await wait(
|
||||||
|
(message) => message.type === "response" && message.request_seq === seq,
|
||||||
|
);
|
||||||
|
if (!response.success) throw new Error(response.message);
|
||||||
|
return response.body as T;
|
||||||
|
},
|
||||||
|
async stop() {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||||
|
const exited = once(child, "exit");
|
||||||
|
child.kill();
|
||||||
|
await exited;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
"standalone Rust DAP binds behavior.rn breakpoints to one actor and serves exact loaded code",
|
||||||
|
{ timeout: 20000 },
|
||||||
|
async (t) => {
|
||||||
|
const image = await startImage();
|
||||||
|
t.after(async () => {
|
||||||
|
try {
|
||||||
|
await client.close();
|
||||||
|
} finally {
|
||||||
|
await image.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const client = await ImageClient.connect(image.address, (error) =>
|
||||||
|
assert.equal(error, undefined),
|
||||||
|
);
|
||||||
|
const source =
|
||||||
|
"pub fn handle(ctx, state, sender, message) {\n let next = state + 1;\n next\n}";
|
||||||
|
const file = await client.writeSource("tests/dap_server", null, source);
|
||||||
|
const actor = await client.spawn(file.path, parseValue("10"));
|
||||||
|
const other = await client.spawn(file.path, parseValue("20"));
|
||||||
|
const uri = `runa-actor://1/${actor}/behavior.rn`;
|
||||||
|
const adapter = startAdapter(image.address);
|
||||||
|
t.after(() => adapter.stop());
|
||||||
|
await adapter.request("initialize", { linesStartAt1: true, columnsStartAt1: true });
|
||||||
|
await adapter.request("attach", { actorRef: actor, behaviorSourceUri: uri });
|
||||||
|
await adapter.request("configurationDone");
|
||||||
|
await adapter.wait((message) => message.event === "stopped");
|
||||||
|
assert.equal((await client.inspectActor(actor)).status.status, "paused");
|
||||||
|
assert.equal((await client.inspectActor(other)).status.status, "idle");
|
||||||
|
const breakpoints = await adapter.request<{ breakpoints: { verified: boolean }[] }>(
|
||||||
|
"setBreakpoints",
|
||||||
|
{ source: { path: uri }, breakpoints: [{ line: 2 }] },
|
||||||
|
);
|
||||||
|
assert.equal(breakpoints.breakpoints[0]?.verified, true);
|
||||||
|
assert.equal((await client.inspectExecution(other)).debugger?.breakpoints.length ?? 0, 0);
|
||||||
|
const dirty = await adapter.request<{ breakpoints: { verified: boolean }[] }>(
|
||||||
|
"setBreakpoints",
|
||||||
|
{ source: { path: uri }, sourceModified: true, breakpoints: [{ line: 2 }] },
|
||||||
|
);
|
||||||
|
assert.equal(dirty.breakpoints[0]?.verified, false);
|
||||||
|
await adapter.request("setBreakpoints", { source: { path: uri }, breakpoints: [{ line: 2 }] });
|
||||||
|
assert.deepEqual(await adapter.request("source", { sourceReference: 1 }), {
|
||||||
|
content: source,
|
||||||
|
mimeType: "text/x-rune",
|
||||||
|
});
|
||||||
|
await adapter.request("continue", { threadId: 1 });
|
||||||
|
assert.equal((await client.inspectActor(actor)).execution, null, "continue must not tick");
|
||||||
|
const since = adapter.messages.length;
|
||||||
|
await client.send(actor, parseValue("null"));
|
||||||
|
await client.tick(1, 1000);
|
||||||
|
await adapter.wait((message) => message.event === "stopped", since);
|
||||||
|
const stack = await adapter.request<{
|
||||||
|
stackFrames: { id: number; line: number; source: { name: string } }[];
|
||||||
|
}>("stackTrace", { threadId: 1 });
|
||||||
|
assert.equal(stack.stackFrames[0]?.line, 2);
|
||||||
|
assert.equal(stack.stackFrames[0]?.source.name, "behavior.rn");
|
||||||
|
assert.deepEqual((await client.inspectActor(actor)).state, parseValue("10"));
|
||||||
|
await assert.rejects(adapter.request("evaluate", { expression: "state + 1", context: "repl" }));
|
||||||
|
await adapter.request("disconnect");
|
||||||
|
assert.equal((await client.inspectActor(actor)).status.status, "paused");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"standalone Rust DAP inspects failed actors without retrying their invocation",
|
||||||
|
{ timeout: 20000 },
|
||||||
|
async (t) => {
|
||||||
|
const image = await startImage();
|
||||||
|
t.after(async () => {
|
||||||
|
try {
|
||||||
|
await client.close();
|
||||||
|
} finally {
|
||||||
|
await image.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const client = await ImageClient.connect(image.address, (error) =>
|
||||||
|
assert.equal(error, undefined),
|
||||||
|
);
|
||||||
|
const file = await client.writeSource(
|
||||||
|
"tests/dap_failure",
|
||||||
|
null,
|
||||||
|
'pub fn handle(ctx, state, sender, message) { panic("DAP failure probe") }',
|
||||||
|
);
|
||||||
|
const actor = await client.spawn(file.path, parseValue("42"));
|
||||||
|
await client.send(actor, parseValue('{"op":"fail"}'));
|
||||||
|
await client.tick(1, 1000);
|
||||||
|
const before = await client.inspectActor(actor);
|
||||||
|
assert.equal(before.status.status, "failed");
|
||||||
|
const adapter = startAdapter(image.address);
|
||||||
|
t.after(() => adapter.stop());
|
||||||
|
await adapter.request("initialize");
|
||||||
|
await adapter.request("attach", { actorRef: actor });
|
||||||
|
await adapter.request("configurationDone");
|
||||||
|
const stop = await adapter.wait((message) => message.event === "stopped");
|
||||||
|
assert.equal((stop.body as { reason: string }).reason, "exception");
|
||||||
|
const exception = await adapter.request<{ description: string }>("exceptionInfo", {
|
||||||
|
threadId: 1,
|
||||||
|
});
|
||||||
|
assert.equal(exception.description, before.status.message);
|
||||||
|
assert.deepEqual(await adapter.request("stackTrace", { threadId: 1 }), {
|
||||||
|
stackFrames: [],
|
||||||
|
totalFrames: 0,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await adapter.request<{ result: string }>("evaluate", {
|
||||||
|
expression: "actor/committed_state",
|
||||||
|
context: "watch",
|
||||||
|
})
|
||||||
|
).result,
|
||||||
|
"42",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await adapter.request<{ result: string }>("evaluate", {
|
||||||
|
expression: "actor/mailbox/reserved/0/op",
|
||||||
|
context: "repl",
|
||||||
|
})
|
||||||
|
).result,
|
||||||
|
'"fail"',
|
||||||
|
);
|
||||||
|
for (const command of ["continue", "pause", "next", "stepIn", "stepOut"]) {
|
||||||
|
await assert.rejects(adapter.request(command, { threadId: 1 }), /repair\/restart explicitly/);
|
||||||
|
}
|
||||||
|
await adapter.request("disconnect");
|
||||||
|
assert.deepEqual(await client.inspectActor(actor), before);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -23,6 +23,19 @@ export async function run(): Promise<void> {
|
||||||
(candidate) => candidate.packageJSON.name === "runa",
|
(candidate) => candidate.packageJSON.name === "runa",
|
||||||
);
|
);
|
||||||
assert.ok(extension);
|
assert.ok(extension);
|
||||||
|
await vscode.workspace
|
||||||
|
.getConfiguration("runa")
|
||||||
|
.update(
|
||||||
|
"debugAdapterPath",
|
||||||
|
join(
|
||||||
|
extension.extensionPath,
|
||||||
|
"..",
|
||||||
|
"target",
|
||||||
|
"debug",
|
||||||
|
process.platform === "win32" ? "runa-dap.exe" : "runa-dap",
|
||||||
|
),
|
||||||
|
vscode.ConfigurationTarget.Global,
|
||||||
|
);
|
||||||
const contributions = extension.packageJSON.contributes;
|
const contributions = extension.packageJSON.contributes;
|
||||||
assert.ok(
|
assert.ok(
|
||||||
contributions.viewsContainers.activitybar.some(
|
contributions.viewsContainers.activitybar.some(
|
||||||
|
|
@ -544,11 +557,23 @@ async function nativeDebugging(address: string): Promise<void> {
|
||||||
await session.customRequest("loadedSources");
|
await session.customRequest("loadedSources");
|
||||||
const debugSource = loaded.sources[0];
|
const debugSource = loaded.sources[0];
|
||||||
assert.ok(debugSource);
|
assert.ok(debugSource);
|
||||||
|
assert.equal(debugSource.name, "behavior.rn");
|
||||||
const uri = vscode.debug.asDebugSourceUri(debugSource, session);
|
const uri = vscode.debug.asDebugSourceUri(debugSource, session);
|
||||||
const document = await vscode.workspace.openTextDocument(uri);
|
const document = await vscode.workspace.openTextDocument(uri);
|
||||||
assert.equal(document.getText(), source);
|
assert.equal(document.getText(), source);
|
||||||
assert.equal(document.languageId, "rune");
|
assert.equal(document.languageId, "rune");
|
||||||
breakpoint = new vscode.SourceBreakpoint(new vscode.Location(uri, new vscode.Position(1, 0)));
|
const extension = vscode.extensions.all.find(
|
||||||
|
(candidate) => candidate.packageJSON.name === "runa",
|
||||||
|
)!;
|
||||||
|
const actorTree = (extension.exports as ReturnType<typeof activate>).actorTree;
|
||||||
|
const behaviorUri = actorFileUri(actorTree.session, actor, "behavior.rn");
|
||||||
|
const behaviorDocument = await vscode.workspace.openTextDocument(behaviorUri);
|
||||||
|
assert.equal(behaviorDocument.getText(), source);
|
||||||
|
assert.equal(behaviorDocument.languageId, "rune");
|
||||||
|
await replaceText(behaviorDocument, source + "\n// unsaved actor edit");
|
||||||
|
breakpoint = new vscode.SourceBreakpoint(
|
||||||
|
new vscode.Location(behaviorUri, new vscode.Position(1, 0)),
|
||||||
|
);
|
||||||
let since = messages.length;
|
let since = messages.length;
|
||||||
vscode.debug.addBreakpoints([breakpoint]);
|
vscode.debug.addBreakpoints([breakpoint]);
|
||||||
const response = await wait(
|
const response = await wait(
|
||||||
|
|
@ -558,8 +583,28 @@ async function nativeDebugging(address: string): Promise<void> {
|
||||||
assert.equal(response.success, true);
|
assert.equal(response.success, true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(response.body as { breakpoints: { verified: boolean }[] }).breakpoints[0]?.verified,
|
(response.body as { breakpoints: { verified: boolean }[] }).breakpoints[0]?.verified,
|
||||||
true,
|
false,
|
||||||
);
|
);
|
||||||
|
vscode.debug.removeBreakpoints([breakpoint]);
|
||||||
|
breakpoint = undefined;
|
||||||
|
await vscode.window.showTextDocument(behaviorDocument);
|
||||||
|
await vscode.commands.executeCommand("workbench.action.files.revert");
|
||||||
|
assert.equal(behaviorDocument.isDirty, false);
|
||||||
|
assert.equal(behaviorDocument.getText(), source);
|
||||||
|
breakpoint = new vscode.SourceBreakpoint(
|
||||||
|
new vscode.Location(behaviorUri, new vscode.Position(1, 0)),
|
||||||
|
);
|
||||||
|
since = messages.length;
|
||||||
|
vscode.debug.addBreakpoints([breakpoint]);
|
||||||
|
const savedResponse = await wait(
|
||||||
|
(message) =>
|
||||||
|
message.type === "response" &&
|
||||||
|
message.command === "setBreakpoints" &&
|
||||||
|
(message.body as { breakpoints?: { verified: boolean }[] })?.breakpoints?.[0]?.verified ===
|
||||||
|
true,
|
||||||
|
since,
|
||||||
|
);
|
||||||
|
assert.equal(savedResponse.success, true);
|
||||||
since = messages.length;
|
since = messages.length;
|
||||||
await vscode.commands.executeCommand("workbench.action.debug.continue");
|
await vscode.commands.executeCommand("workbench.action.debug.continue");
|
||||||
await wait((message) => message.event === "continued", since);
|
await wait((message) => message.event === "continued", since);
|
||||||
|
|
@ -1194,7 +1239,7 @@ async function editActorFiles(address: string): Promise<void> {
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
actorTree.getChildren(parent).map((node) => ("file" in node ? node.file : "")),
|
actorTree.getChildren(parent).map((node) => ("file" in node ? node.file : "")),
|
||||||
["meta.json", "config.json", "state.json", "mailbox.json", "actor.log"],
|
["meta.json", "config.json", "state.json", "mailbox.json", "actor.log", "behavior.rn"],
|
||||||
);
|
);
|
||||||
const session = actorTree.session;
|
const session = actorTree.session;
|
||||||
const uri = (name: string) => actorFileUri(session, ref, name);
|
const uri = (name: string) => actorFileUri(session, ref, name);
|
||||||
|
|
@ -1266,7 +1311,8 @@ async function editActorFiles(address: string): Promise<void> {
|
||||||
await vscode.commands.executeCommand("runa.editBehavior", selected);
|
await vscode.commands.executeCommand("runa.editBehavior", selected);
|
||||||
const behavior = vscode.window.activeTextEditor?.document;
|
const behavior = vscode.window.activeTextEditor?.document;
|
||||||
assert.ok(behavior);
|
assert.ok(behavior);
|
||||||
assert.equal(behavior.uri.toString(), uri("behavior").toString());
|
assert.equal(behavior.uri.toString(), uri("behavior.rn").toString());
|
||||||
|
assert.equal(behavior.languageId, "rune");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
behavior.getText(),
|
behavior.getText(),
|
||||||
original,
|
original,
|
||||||
|
|
@ -1287,7 +1333,9 @@ async function editActorFiles(address: string): Promise<void> {
|
||||||
const withTemporary = actorTree.getChildren().find((node) => node.actor_ref === ref);
|
const withTemporary = actorTree.getChildren().find((node) => node.actor_ref === ref);
|
||||||
assert.ok(withTemporary);
|
assert.ok(withTemporary);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
actorTree.getChildren(withTemporary).some((node) => "file" in node && node.file === "behavior"),
|
actorTree
|
||||||
|
.getChildren(withTemporary)
|
||||||
|
.some((node) => "file" in node && node.file === "behavior.rn"),
|
||||||
);
|
);
|
||||||
const tempExecuted = documentChanged(state, (text) => text.trim() === "145");
|
const tempExecuted = documentChanged(state, (text) => text.trim() === "145");
|
||||||
const logged = documentChanged(log, (text) => text.includes("[INFO] temporary"));
|
const logged = documentChanged(log, (text) => text.includes("[INFO] temporary"));
|
||||||
|
|
@ -1349,7 +1397,7 @@ async function editActorFiles(address: string): Promise<void> {
|
||||||
const reloaded = actorTree.getChildren().find((node) => node.actor_ref === ref);
|
const reloaded = actorTree.getChildren().find((node) => node.actor_ref === ref);
|
||||||
assert.ok(reloaded);
|
assert.ok(reloaded);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!actorTree.getChildren(reloaded).some((node) => "file" in node && node.file === "behavior"),
|
actorTree.getChildren(reloaded).some((node) => "file" in node && node.file === "behavior.rn"),
|
||||||
);
|
);
|
||||||
await vscode.commands.executeCommand("runa.kill", selected);
|
await vscode.commands.executeCommand("runa.kill", selected);
|
||||||
await vscode.commands.executeCommand("runa.refresh");
|
await vscode.commands.executeCommand("runa.refresh");
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@
|
||||||
"lint": "tsc -p . --noEmit && prettier --check .",
|
"lint": "tsc -p . --noEmit && prettier --check .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"test": "npm run compile && node --test out/test/*.test.js",
|
"test": "npm run compile && node --test out/test/*.test.js",
|
||||||
"test:integration": "cargo build --manifest-path ../Cargo.toml -p runa -p runa-cli && npm run compile && node --test out/integration/*.test.js",
|
"test:integration": "cargo build --manifest-path ../Cargo.toml -p runa -p runa-cli -p runa-dap && npm run compile && node --test out/integration/*.test.js",
|
||||||
"test:host": "cargo build --manifest-path ../Cargo.toml -p runa && npm run compile && node scripts/test-host.cjs"
|
"test:host": "cargo build --manifest-path ../Cargo.toml -p runa -p runa-dap && npm run compile && node scripts/test-host.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lossless-json": "^4.3.0",
|
"lossless-json": "^4.3.0",
|
||||||
|
|
@ -630,6 +630,12 @@
|
||||||
"default": "127.0.0.1:3737",
|
"default": "127.0.0.1:3737",
|
||||||
"scope": "machine",
|
"scope": "machine",
|
||||||
"description": "Trusted local image endpoint: numeric IPv4 loopback or [::1]:port. No authentication is available."
|
"description": "Trusted local image endpoint: numeric IPv4 loopback or [::1]:port. No authentication is available."
|
||||||
|
},
|
||||||
|
"runa.debugAdapterPath": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "runa-dap",
|
||||||
|
"scope": "machine",
|
||||||
|
"description": "Path to the standalone Rust runa-dap executable. Build with cargo build -p runa-dap, or install it on PATH."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export const ACTOR_FILES = [
|
||||||
"state.json",
|
"state.json",
|
||||||
"mailbox.json",
|
"mailbox.json",
|
||||||
"actor.log",
|
"actor.log",
|
||||||
|
"behavior.rn",
|
||||||
] as const;
|
] as const;
|
||||||
export function actorFileUri(session: number, actor: string, file?: string): vscode.Uri {
|
export function actorFileUri(session: number, actor: string, file?: string): vscode.Uri {
|
||||||
return vscode.Uri.from({
|
return vscode.Uri.from({
|
||||||
|
|
@ -77,7 +78,7 @@ export class ActorFiles implements vscode.FileSystemProvider {
|
||||||
}
|
}
|
||||||
private fire(session: number, actor: string): void {
|
private fire(session: number, actor: string): void {
|
||||||
this.changes.fire(
|
this.changes.fire(
|
||||||
[...ACTOR_FILES, "behavior"].map((file) => ({
|
ACTOR_FILES.map((file) => ({
|
||||||
type: vscode.FileChangeType.Changed,
|
type: vscode.FileChangeType.Changed,
|
||||||
uri: actorFileUri(session, actor, file),
|
uri: actorFileUri(session, actor, file),
|
||||||
})),
|
})),
|
||||||
|
|
@ -87,7 +88,7 @@ export class ActorFiles implements vscode.FileSystemProvider {
|
||||||
const parts = uri.path.split("/").filter(Boolean);
|
const parts = uri.path.split("/").filter(Boolean);
|
||||||
if (parts.length < 1 || parts.length > 2) throw vscode.FileSystemError.FileNotFound(uri);
|
if (parts.length < 1 || parts.length > 2) throw vscode.FileSystemError.FileNotFound(uri);
|
||||||
const entry = this.actors.get(actorFileUri(Number(uri.authority), parts[0]!).toString());
|
const entry = this.actors.get(actorFileUri(Number(uri.authority), parts[0]!).toString());
|
||||||
if (!entry || (parts[1] !== undefined && ![...ACTOR_FILES, "behavior"].includes(parts[1])))
|
if (!entry || (parts[1] !== undefined && !ACTOR_FILES.some((file) => file === parts[1])))
|
||||||
throw vscode.FileSystemError.FileNotFound(uri);
|
throw vscode.FileSystemError.FileNotFound(uri);
|
||||||
return { entry, file: parts[1] };
|
return { entry, file: parts[1] };
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +97,7 @@ export class ActorFiles implements vscode.FileSystemProvider {
|
||||||
entry.connected &&
|
entry.connected &&
|
||||||
!entry.removed &&
|
!entry.removed &&
|
||||||
(file === "config.json" ||
|
(file === "config.json" ||
|
||||||
file === "behavior" ||
|
file === "behavior.rn" ||
|
||||||
((file === "state.json" || file === "mailbox.json") && canEditActor(entry.actor)))
|
((file === "state.json" || file === "mailbox.json") && canEditActor(entry.actor)))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -111,12 +112,9 @@ export class ActorFiles implements vscode.FileSystemProvider {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
readDirectory(uri: vscode.Uri): [string, vscode.FileType][] {
|
readDirectory(uri: vscode.Uri): [string, vscode.FileType][] {
|
||||||
const { entry, file } = this.entry(uri);
|
const { file } = this.entry(uri);
|
||||||
if (file) throw vscode.FileSystemError.FileNotADirectory(uri);
|
if (file) throw vscode.FileSystemError.FileNotADirectory(uri);
|
||||||
return [...ACTOR_FILES, ...(entry.actor.temporary_behavior ? ["behavior"] : [])].map((name) => [
|
return ACTOR_FILES.map((name) => [name, vscode.FileType.File]);
|
||||||
name,
|
|
||||||
vscode.FileType.File,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
async readFile(uri: vscode.Uri): Promise<Uint8Array> {
|
async readFile(uri: vscode.Uri): Promise<Uint8Array> {
|
||||||
const { entry, file } = this.entry(uri);
|
const { entry, file } = this.entry(uri);
|
||||||
|
|
@ -149,7 +147,7 @@ export class ActorFiles implements vscode.FileSystemProvider {
|
||||||
text = formatted;
|
text = formatted;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "behavior": {
|
case "behavior.rn": {
|
||||||
if (!entry.connected || entry.removed) return this.cached(uri);
|
if (!entry.connected || entry.removed) return this.cached(uri);
|
||||||
const connection = this.current(entry);
|
const connection = this.current(entry);
|
||||||
const behavior = await this.readLive(uri, () =>
|
const behavior = await this.readLive(uri, () =>
|
||||||
|
|
@ -219,7 +217,7 @@ export class ActorFiles implements vscode.FileSystemProvider {
|
||||||
const connection = this.current(entry);
|
const connection = this.current(entry);
|
||||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
||||||
const ref = entry.actor.actor_ref;
|
const ref = entry.actor.actor_ref;
|
||||||
if (file === "behavior") {
|
if (file === "behavior.rn") {
|
||||||
const expected = this.behaviorIds.get(uri.toString());
|
const expected = this.behaviorIds.get(uri.toString());
|
||||||
if (!expected)
|
if (!expected)
|
||||||
throw vscode.FileSystemError.Unavailable("Open the current actor behavior before saving");
|
throw vscode.FileSystemError.Unavailable("Open the current actor behavior before saving");
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export class SourceDiagnostics implements vscode.Disposable {
|
||||||
document.uri.path.endsWith(".rn")) ||
|
document.uri.path.endsWith(".rn")) ||
|
||||||
(document.uri.scheme === ACTOR_SCHEME &&
|
(document.uri.scheme === ACTOR_SCHEME &&
|
||||||
document.uri.authority === String(connection.session) &&
|
document.uri.authority === String(connection.session) &&
|
||||||
document.uri.path.endsWith("/behavior")))
|
document.uri.path.endsWith("/behavior.rn")))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ 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 { ExecutionDocuments, EXECUTION_SCHEME, frameSelection } from "./executionProvider";
|
||||||
import { ActorDebugAdapter } from "./debugger";
|
|
||||||
import { ActorFiles, ACTOR_SCHEME, ACTOR_FILES, actorFileUri } from "./actorFiles";
|
import { ActorFiles, ACTOR_SCHEME, ACTOR_FILES, actorFileUri } from "./actorFiles";
|
||||||
import {
|
import {
|
||||||
ImageSourceFileSystem,
|
ImageSourceFileSystem,
|
||||||
|
|
@ -49,7 +48,7 @@ class ActorTree implements vscode.TreeDataProvider<ActorNode> {
|
||||||
getChildren(parent?: ActorNode): ActorNode[] {
|
getChildren(parent?: ActorNode): ActorNode[] {
|
||||||
if (!parent) return this.actors;
|
if (!parent) return this.actors;
|
||||||
if ("file" in parent) return [];
|
if ("file" in parent) return [];
|
||||||
return [...ACTOR_FILES, ...(parent.temporary_behavior ? ["behavior"] : [])].map((file) => ({
|
return ACTOR_FILES.map((file) => ({
|
||||||
actor_ref: parent.actor_ref,
|
actor_ref: parent.actor_ref,
|
||||||
file,
|
file,
|
||||||
}));
|
}));
|
||||||
|
|
@ -127,9 +126,10 @@ export function activate(context: vscode.ExtensionContext): {
|
||||||
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 executions = new ExecutionDocuments();
|
||||||
const debuggers = new Map<string, ActorDebugAdapter>();
|
const debuggers = new Map<string, vscode.DebugSession>();
|
||||||
const endDebuggers = () => {
|
const endDebuggers = () => {
|
||||||
for (const adapter of debuggers.values()) adapter.terminate("Image connection ended.");
|
for (const debugSession of debuggers.values())
|
||||||
|
void vscode.debug.stopDebugging(debugSession).then(undefined, (error) => report(error));
|
||||||
debuggers.clear();
|
debuggers.clear();
|
||||||
};
|
};
|
||||||
const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree });
|
const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree });
|
||||||
|
|
@ -207,7 +207,6 @@ export function activate(context: vscode.ExtensionContext): {
|
||||||
if (current !== client || currentSession !== session) return;
|
if (current !== client || currentSession !== session) return;
|
||||||
tree.session = session;
|
tree.session = session;
|
||||||
tree.replace(actors);
|
tree.replace(actors);
|
||||||
for (const adapter of debuggers.values()) adapter.invalidate();
|
|
||||||
actorFiles.refreshActors(session, actors);
|
actorFiles.refreshActors(session, actors);
|
||||||
sourceTree.refresh();
|
sourceTree.refresh();
|
||||||
documents.refresh(session, endpoint, actors);
|
documents.refresh(session, endpoint, actors);
|
||||||
|
|
@ -913,7 +912,7 @@ export function activate(context: vscode.ExtensionContext): {
|
||||||
connection.check();
|
connection.check();
|
||||||
actorFiles.refreshActors(connection.session, actors);
|
actorFiles.refreshActors(connection.session, actors);
|
||||||
const document = await vscode.workspace.openTextDocument(
|
const document = await vscode.workspace.openTextDocument(
|
||||||
actorFileUri(connection.session, actor.actor_ref, "behavior"),
|
actorFileUri(connection.session, actor.actor_ref, "behavior.rn"),
|
||||||
);
|
);
|
||||||
await vscode.languages.setTextDocumentLanguage(document, "rune");
|
await vscode.languages.setTextDocumentLanguage(document, "rune");
|
||||||
await vscode.window.showTextDocument(document);
|
await vscode.window.showTextDocument(document);
|
||||||
|
|
@ -978,6 +977,7 @@ export function activate(context: vscode.ExtensionContext): {
|
||||||
throw new Error("Runa supports attach debugging only");
|
throw new Error("Runa supports attach debugging only");
|
||||||
const actorRef = configuration.actorRef ?? (await chooseActor());
|
const actorRef = configuration.actorRef ?? (await chooseActor());
|
||||||
if (!actorRef) return undefined;
|
if (!actorRef) return undefined;
|
||||||
|
const inspection = await connection.client.inspectExecution(actorRef);
|
||||||
connection.check();
|
connection.check();
|
||||||
return {
|
return {
|
||||||
...configuration,
|
...configuration,
|
||||||
|
|
@ -986,6 +986,10 @@ export function activate(context: vscode.ExtensionContext): {
|
||||||
name: configuration.name ?? `Runa @${actorRef}`,
|
name: configuration.name ?? `Runa @${actorRef}`,
|
||||||
actorRef,
|
actorRef,
|
||||||
runaConnection: connection.session,
|
runaConnection: connection.session,
|
||||||
|
behaviorSourceUri: actorFileUri(connection.session, actorRef, "behavior.rn").toString(),
|
||||||
|
workingSourceUri: inspection.behavior_path
|
||||||
|
? sourceFileUri(connection.endpoint, inspection.behavior_path).toString()
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
report(error);
|
report(error);
|
||||||
|
|
@ -1000,43 +1004,38 @@ export function activate(context: vscode.ExtensionContext): {
|
||||||
throw new Error("Image connection changed before debugger attachment");
|
throw new Error("Image connection changed before debugger attachment");
|
||||||
const actorRef: unknown = debugSession.configuration.actorRef;
|
const actorRef: unknown = debugSession.configuration.actorRef;
|
||||||
if (typeof actorRef !== "string") throw new Error("Choose a debug actor");
|
if (typeof actorRef !== "string") throw new Error("Choose a debug actor");
|
||||||
if ([...debuggers.values()].some((adapter) => adapter.actorRef === actorRef))
|
if ([...debuggers.values()].some((session) => session.configuration.actorRef === actorRef))
|
||||||
throw new Error("This actor already has a Runa debug session");
|
throw new Error("This actor already has a Runa debug session");
|
||||||
const messages = new vscode.EventEmitter<vscode.DebugProtocolMessage>();
|
const executable = vscode.workspace
|
||||||
const adapter = new ActorDebugAdapter(
|
.getConfiguration("runa")
|
||||||
connection.client,
|
.get<string>("debugAdapterPath", "runa-dap");
|
||||||
actorRef,
|
if (!executable.trim())
|
||||||
(inspection) => executions.set(connection.session, inspection).toString(),
|
throw new Error("Set runa.debugAdapterPath to the runa-dap executable");
|
||||||
connection.check,
|
debuggers.set(debugSession.id, debugSession);
|
||||||
(message) => messages.fire(message),
|
return new vscode.DebugAdapterExecutable(executable, ["--connect", connection.endpoint]);
|
||||||
(path) => sourceFileUri(connection.endpoint, path).toString(),
|
|
||||||
(path, source) => {
|
|
||||||
const uri = sourceFileUri(connection.endpoint, path).toString();
|
|
||||||
const document = vscode.workspace.textDocuments.find(
|
|
||||||
(document) => document.uri.toString() === uri,
|
|
||||||
);
|
|
||||||
return !document || document.getText() === source;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
debuggers.set(debugSession.id, adapter);
|
|
||||||
return new vscode.DebugAdapterInlineImplementation({
|
|
||||||
onDidSendMessage: messages.event,
|
|
||||||
handleMessage: (message) => adapter.handleMessage(message),
|
|
||||||
dispose: () => {
|
|
||||||
debuggers.delete(debugSession.id);
|
|
||||||
adapter.dispose();
|
|
||||||
messages.dispose();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
vscode.debug.onDidTerminateDebugSession((session) => debuggers.delete(session.id)),
|
||||||
|
vscode.debug.registerDebugAdapterTrackerFactory("runa", {
|
||||||
|
createDebugAdapterTracker: () => ({
|
||||||
|
onWillReceiveMessage: (message) => {
|
||||||
|
if (message.type !== "request" || message.command !== "setBreakpoints") return;
|
||||||
|
const uri = message.arguments?.source?.path;
|
||||||
|
if (typeof uri !== "string") return;
|
||||||
|
if (
|
||||||
|
vscode.workspace.textDocuments.some(
|
||||||
|
(document) => document.uri.toString() === uri && document.isDirty,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
message.arguments.sourceModified = true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
command("runa.debugActor", async (selected?: { actor_ref: string }) => {
|
command("runa.debugActor", async (selected?: { actor_ref: string }) => {
|
||||||
const connection = active();
|
const connection = active();
|
||||||
const actorRef = await chooseActor(selected);
|
const actorRef = await chooseActor(selected);
|
||||||
if (!actorRef) return;
|
if (!actorRef) return;
|
||||||
connection.check();
|
connection.check();
|
||||||
const inspection = await connection.client.inspectExecution(actorRef);
|
|
||||||
connection.check();
|
|
||||||
const started = await vscode.debug.startDebugging(
|
const started = await vscode.debug.startDebugging(
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
|
|
@ -1049,11 +1048,7 @@ export function activate(context: vscode.ExtensionContext): {
|
||||||
{ suppressSaveBeforeStart: true },
|
{ suppressSaveBeforeStart: true },
|
||||||
);
|
);
|
||||||
connection.check();
|
connection.check();
|
||||||
if (started) {
|
if (started) await editBehavior({ actor_ref: actorRef });
|
||||||
await vscode.window.showTextDocument(
|
|
||||||
await vscode.workspace.openTextDocument(executions.set(connection.session, inspection)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return started;
|
return started;
|
||||||
}),
|
}),
|
||||||
tree,
|
tree,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue