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:
Steve Biedermann 2026-11-05 09:26:51 +01:00
parent 35902b6b4d
commit 5be7bb6a01
21 changed files with 2659 additions and 73 deletions

View File

@ -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.

13
Cargo.lock generated
View File

@ -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"

View File

@ -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",

View File

@ -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

View File

@ -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" }

View File

@ -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());
}
}

811
crates/runa-dap/src/lib.rs Normal file
View File

@ -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")),
}
}

View File

@ -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(())
}

View File

@ -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(),
),
}
}

View File

@ -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);
}

View File

@ -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());
}
}

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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);
},
);

View File

@ -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");

View File

@ -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."
} }
} }
} }

View File

@ -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");

View File

@ -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")))
); );
} }

View File

@ -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,