diff --git a/CHANGELOG.md b/CHANGELOG.md index e569648..4cc0c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file. ### Added +- Automatically attach an image-wide VS Code debugger when connecting, exposing + actors as dynamic threads without pausing them. Add `runa.autoAttachDebugger` + to opt out; Debug Actor focuses only the chosen actor in the shared session. - Add `mise run package-extension` to generate `vscode/runa.vsix` for manual installation without installing it automatically. - Add `runa-dap --listen` / `RUNA_DAP_LISTEN` for loopback TCP DAP sessions, diff --git a/README.md b/README.md index eda4c7d..b0af7aa 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,10 @@ callbacks are not preemptible. The debugger foundation exposes bounded active Ru stack frames and exact loaded source through read-only protocol inspection and VS Code's **Inspect Execution Stack...** actor action. Actor-local source breakpoints and bounded step-into/over/out are implemented through the protocol and VS -Code actor actions. **Debug Actor** attaches a native VS Code debug session with +Code actor actions. Connecting to an image automatically starts a native VS Code +debug session with all actors as threads, without pausing them. Set +`runa.autoAttachDebugger` to `false` to opt out. **Debug Actor** focuses and pauses +only the chosen actor. The debugger provides gutter breakpoints, the call-stack view, and bounded toolbar stepping. Continue resumes the actor; scheduler ticks remain explicit. The Variables view exposes a read-only committed actor snapshot with expandable state and mailbox. diff --git a/crates/runa-dap/src/lib.rs b/crates/runa-dap/src/lib.rs index 125eb4e..2145a90 100644 --- a/crates/runa-dap/src/lib.rs +++ b/crates/runa-dap/src/lib.rs @@ -1,7 +1,9 @@ //! One-actor DAP sessions over the loopback image protocol. Debug controls never tick the image. +use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; use std::net::SocketAddr; +use std::rc::Rc; use runa_client::WebSocketClient; use runa_protocol::{ @@ -75,7 +77,7 @@ struct BreakpointGroup { entries: Vec, } -pub struct Adapter { +struct ActorAdapter { connection: C, sequence: u64, messages: Vec, @@ -99,8 +101,8 @@ pub struct Adapter { breakpoint_states: BTreeMap, } -impl Adapter { - pub fn new(connection: C) -> Self { +impl ActorAdapter { + fn new(connection: C) -> Self { Self { connection, sequence: 0, @@ -134,7 +136,7 @@ impl Adapter { if self.closed { return Vec::new(); } - let request: Request = match serde_json::from_value(message) { + let request: Request = match serde_json::from_value::(message) { Ok(request) => request, Err(error) => return self.terminate(&format!("Invalid DAP request: {error}")), }; @@ -806,6 +808,750 @@ fn optional_uri(args: &Json, key: &str) -> Result, String> { { Ok(Some(value.clone())) } + + _ => Err(format!("Expected nonempty {key} string")), + } +} + +#[derive(Clone)] +struct SharedConnection(Rc>); + +impl Connection for SharedConnection { + fn request(&mut self, command: Command) -> Result { + self.0.borrow_mut().request(command) + } +} + +enum AdapterMode { + Actor(Box>>), + Image(Box>), +} + +/// A DAP session which can attach either to one actor or to every actor in an image. +pub struct Adapter { + connection: Rc>, + mode: AdapterMode, + sequence: u64, +} + +impl Adapter { + pub fn new(connection: C) -> Self { + let connection = Rc::new(RefCell::new(connection)); + Self { + mode: AdapterMode::Actor(Box::new(ActorAdapter::new(SharedConnection( + connection.clone(), + )))), + connection, + sequence: 0, + } + } + + pub fn is_closed(&self) -> bool { + match &self.mode { + AdapterMode::Actor(adapter) => adapter.is_closed(), + AdapterMode::Image(adapter) => adapter.closed, + } + } + + pub fn handle(&mut self, message: Json) -> Vec { + let image_attach = serde_json::from_value::(message.clone()) + .ok() + .is_some_and(|request| { + request.command == "attach" && request.arguments["image"] == true + }); + if image_attach { + let initialization = match &self.mode { + AdapterMode::Actor(adapter) + if adapter.initialized && adapter.actor_ref.is_none() => + { + Some((adapter.initialized, adapter.line_base, adapter.column_base)) + } + AdapterMode::Actor(_) | AdapterMode::Image(_) => None, + }; + if let Some((true, line_base, column_base)) = initialization { + let mut image = ImageAdapter::new(self.connection.clone()); + image.initialized = true; + image.line_base = line_base; + image.column_base = column_base; + self.mode = AdapterMode::Image(Box::new(image)); + } + } + let messages = match &mut self.mode { + AdapterMode::Actor(adapter) => adapter.handle(message), + AdapterMode::Image(adapter) => adapter.handle(message), + }; + self.number(messages) + } + + pub fn poll(&mut self) -> Vec { + let messages = match &mut self.mode { + AdapterMode::Actor(adapter) => adapter.poll(), + AdapterMode::Image(adapter) => adapter.poll(), + }; + self.number(messages) + } + + pub fn terminate(&mut self, message: &str) -> Vec { + let messages = match &mut self.mode { + AdapterMode::Actor(adapter) => adapter.terminate(message), + AdapterMode::Image(adapter) => adapter.terminate(message), + }; + self.number(messages) + } + + fn number(&mut self, messages: Vec) -> Vec { + messages + .into_iter() + .map(|mut message| { + self.sequence += 1; + message["seq"] = json!(self.sequence); + message + }) + .collect() + } +} + +struct ImageActor { + adapter: ActorAdapter>, + thread_id: u64, + source_reference: u64, +} + +#[derive(Clone)] +struct HandleOwner { + actor_ref: String, + local_id: u64, +} + +struct ImageAdapter { + connection: Rc>, + actors: BTreeMap>, + threads: BTreeMap, + sources: BTreeMap, + frames: BTreeMap, + variables: BTreeMap, + next_thread: u64, + next_reference: u64, + messages: Vec, + initialized: bool, + configured: bool, + closed: bool, + behavior_base: Option, + working_base: Option, + line_base: usize, + column_base: usize, +} + +impl ImageAdapter { + fn new(connection: Rc>) -> Self { + Self { + connection, + actors: BTreeMap::new(), + threads: BTreeMap::new(), + sources: BTreeMap::new(), + frames: BTreeMap::new(), + variables: BTreeMap::new(), + next_thread: 0, + next_reference: 0, + messages: Vec::new(), + initialized: false, + configured: false, + closed: false, + behavior_base: None, + working_base: None, + line_base: 1, + column_base: 1, + } + } + + fn handle(&mut self, message: Json) -> Vec { + if self.closed { + return Vec::new(); + } + let request: Request = match serde_json::from_value::(message) { + Ok(request) if request.kind == "request" && request.arguments.is_object() => request, + Ok(_) => return self.terminate("Expected a DAP request with object arguments"), + Err(error) => return self.terminate(&format!("Invalid DAP request: {error}")), + }; + let result = self.dispatch(&request.command, &request.arguments); + if let Err(message) = &result + && request.command == "attach" + && !self.closed + { + self.event( + "output", + json!({"category":"console","output":format!("Runa: {message}\n")}), + ); + self.event("terminated", json!({})); + self.closed = true; + } + let response = match result { + Ok(body) => { + json!({"type": "response", "request_seq": request.seq, "command": request.command, "success": true, "body": body}) + } + Err(message) => { + json!({"type": "response", "request_seq": request.seq, "command": request.command, "success": false, "message": message}) + } + }; + self.messages.insert(0, response); + self.drain() + } + + fn poll(&mut self) -> Vec { + if self.closed || !self.configured { + return Vec::new(); + } + if let Err(error) = self.discover() { + self.event( + "output", + json!({"category":"console","output":format!("Runa: {error}\n")}), + ); + } + let actor_refs: Vec<_> = self.actors.keys().cloned().collect(); + for actor_ref in actor_refs { + if let Some(actor) = self.actors.get_mut(&actor_ref) { + let messages = actor.adapter.poll(); + self.forward_events(&actor_ref, messages); + } + } + self.drain() + } + + fn terminate(&mut self, message: &str) -> Vec { + self.event( + "output", + json!({"category":"console","output":format!("Runa: {message}\n")}), + ); + self.event("terminated", json!({})); + self.closed = true; + self.drain() + } + + fn drain(&mut self) -> Vec { + std::mem::take(&mut self.messages) + } + + fn event(&mut self, event: &str, body: Json) { + self.messages + .push(json!({"type":"event","event":event,"body":body})); + } + + fn dispatch(&mut self, command: &str, args: &Json) -> Result { + 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 + })); + } + "attach" => { + if !self.initialized { + return Err("Initialize the debugger first".to_owned()); + } + if self.behavior_base.is_some() { + return Err("Debugger is already attached".to_owned()); + } + self.behavior_base = Some(uri(args, "actorSourceBaseUri")?); + self.working_base = Some(uri(args, "workingSourceBaseUri")?); + self.discover()?; + self.event("output", json!({"category":"console","output":"Runa: Attached to the image without changing actor state. Continue and pause affect only the requested actor thread; discovery never pauses actors.\n"})); + self.event("initialized", json!({})); + return Ok(json!({})); + } + "disconnect" => { + if args["terminateDebuggee"] == true || args["suspendDebuggee"] == true { + return Err("Disconnect cannot terminate or suspend actors".to_owned()); + } + self.closed = true; + return Ok(json!({})); + } + _ => {} + } + if self.behavior_base.is_none() { + return Err("Attach to an image first".to_owned()); + } + if self.configured { + self.discover()?; + } + match command { + "configurationDone" => { + self.configured = true; + self.discover()?; + Ok(json!({})) + } + "threads" => Ok( + json!({"threads": self.actors.iter().map(|(actor_ref, actor)| json!({"id": actor.thread_id, "name": format!("@{actor_ref}")})).collect::>()}), + ), + "loadedSources" => Ok( + json!({"sources": self.actors.iter().map(|(actor_ref, actor)| { + self.source_json(actor_ref, actor.source_reference) + }).collect::>()}), + ), + "source" => { + let reference = integer(args, "sourceReference", None)?; + let actor_ref = self + .sources + .get(&reference) + .cloned() + .ok_or("Unknown or stale loaded source")?; + let source = self.forward(&actor_ref, "source", json!({"sourceReference":1}))?; + Ok(source) + } + "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": []})) + } + "focusActor" => self.focus_actor(args), + "stackTrace" | "scopes" | "variables" | "evaluate" | "exceptionInfo" | "pause" + | "continue" | "next" | "stepIn" | "stepOut" => self.route_actor(command, args), + _ => Err(format!("Unsupported debug request: {command}")), + } + } + + fn discover(&mut self) -> Result<(), String> { + let Reply::Actors(listed) = self.connection.borrow_mut().request(Command::ListActors)? + else { + return Err("Image returned an unexpected actor list reply".to_owned()); + }; + let mut current = BTreeMap::new(); + for snapshot in listed { + if snapshot.status != ActorStatusDto::Stopped { + current.insert(snapshot.actor_ref.clone(), snapshot); + } + } + let removed: Vec<_> = self + .actors + .keys() + .filter(|actor_ref| !current.contains_key(*actor_ref)) + .cloned() + .collect(); + for actor_ref in removed { + self.retire(&actor_ref); + } + for (actor_ref, snapshot) in current { + let mut replace = false; + if let Some(existing) = self.actors.get(&actor_ref) { + let previous = existing.adapter.inspection.as_ref().map(|value| { + ( + value.incarnation, + value.implementation_id.clone(), + value.source_id.clone(), + ) + }); + let inspection = self.inspect_execution(&actor_ref)?; + replace = previous.is_some_and(|(incarnation, implementation, source_id)| { + snapshot.incarnation != incarnation + || snapshot.implementation_id != implementation + || inspection.source_id != source_id + }); + } + if replace { + self.retire(&actor_ref); + } + if !self.actors.contains_key(&actor_ref) { + self.add_actor(actor_ref, snapshot)?; + } + } + Ok(()) + } + + fn inspect_execution(&mut self, actor_ref: &str) -> Result { + match self + .connection + .borrow_mut() + .request(Command::InspectExecution { + actor_ref: actor_ref.to_owned(), + })? { + Reply::ExecutionInspected(inspection) if inspection.actor_ref == actor_ref => { + Ok(inspection) + } + _ => Err("Image returned an unexpected execution reply".to_owned()), + } + } + + fn add_actor( + &mut self, + actor_ref: String, + snapshot: runa_protocol::ActorSnapshotDto, + ) -> Result<(), String> { + let mut adapter = ActorAdapter::new(SharedConnection(self.connection.clone())); + adapter.initialized = true; + adapter.configured = true; + adapter.line_base = self.line_base; + adapter.column_base = self.column_base; + adapter.actor_ref = Some(actor_ref.clone()); + adapter.behavior_uri = Some(self.behavior_uri(&actor_ref)); + adapter.working_uri = snapshot + .behavior_path + .as_ref() + .map(|path| self.working_uri(path)); + let inspection = adapter.read()?; + if snapshot.incarnation != inspection.incarnation + || snapshot.implementation_id != inspection.implementation_id + { + return Err("Actor changed while being discovered".to_owned()); + } + self.next_thread += 1; + self.next_reference += 1; + let thread_id = self.next_thread; + let source_reference = self.next_reference; + self.threads.insert(thread_id, actor_ref.clone()); + self.sources.insert(source_reference, actor_ref.clone()); + self.actors.insert( + actor_ref.clone(), + ImageActor { + adapter, + thread_id, + source_reference, + }, + ); + self.event("thread", json!({"reason":"started","threadId":thread_id})); + Ok(()) + } + + fn retire(&mut self, actor_ref: &str) { + if let Some(actor) = self.actors.remove(actor_ref) { + self.threads.remove(&actor.thread_id); + self.sources.remove(&actor.source_reference); + self.frames.retain(|_, owner| owner.actor_ref != actor_ref); + self.variables + .retain(|_, owner| owner.actor_ref != actor_ref); + self.event( + "thread", + json!({"reason":"exited","threadId":actor.thread_id}), + ); + } + } + + fn route_actor(&mut self, command: &str, args: &Json) -> Result { + let actor_ref = if args.get("frameId").is_some() && command != "stackTrace" { + self.frames + .get(&integer(args, "frameId", None)?) + .map(|owner| owner.actor_ref.clone()) + .ok_or("Stale or unknown frame")? + } else if command == "variables" { + self.variables + .get(&integer(args, "variablesReference", None)?) + .map(|owner| owner.actor_ref.clone()) + .ok_or("Stale or unknown variables reference")? + } else { + self.actor_for_thread(args)? + }; + let mut rewritten = args.clone(); + if command == "stackTrace" + || command == "pause" + || command == "continue" + || command == "next" + || command == "stepIn" + || command == "stepOut" + || command == "exceptionInfo" + || command == "scopes" + { + rewritten["threadId"] = json!(1); + } + if let Some(frame_id) = args.get("frameId").and_then(Json::as_u64) { + let owner = self.frames.get(&frame_id).ok_or("Stale or unknown frame")?; + rewritten["frameId"] = json!(owner.local_id); + } + if command == "variables" { + let owner = self + .variables + .get(&integer(args, "variablesReference", None)?) + .ok_or("Stale or unknown variables reference")?; + rewritten["variablesReference"] = json!(owner.local_id); + } + if command == "evaluate" && args.get("threadId").is_some() { + rewritten["threadId"] = json!(1); + } + let mut result = self.forward(&actor_ref, command, rewritten)?; + if matches!(command, "continue" | "next" | "stepIn" | "stepOut") { + result["allThreadsContinued"] = json!(false); + } + if command == "stackTrace" { + self.remap_frames(&actor_ref, result) + } else if command == "scopes" || command == "variables" || command == "evaluate" { + self.remap_variables(&actor_ref, result) + } else { + Ok(result) + } + } + + fn actor_for_thread(&self, args: &Json) -> Result { + let thread_id = integer(args, "threadId", None)?; + self.threads + .get(&thread_id) + .cloned() + .ok_or_else(|| "Unknown actor thread".to_owned()) + } + + fn forward(&mut self, actor_ref: &str, command: &str, args: Json) -> Result { + let actor = self + .actors + .get_mut(actor_ref) + .ok_or("Actor thread has exited")?; + let messages = actor + .adapter + .handle(json!({"seq":1,"type":"request","command":command,"arguments":args})); + let mut result = None; + for message in messages { + if message["type"] == "response" { + if message["success"] != true { + return Err(message["message"] + .as_str() + .unwrap_or("Actor request failed") + .to_owned()); + } + result = Some(message["body"].clone()); + } else if message["type"] == "event" { + self.forward_events(actor_ref, vec![message]); + } + } + result.ok_or_else(|| format!("No response for {command}")) + } + + fn forward_events(&mut self, actor_ref: &str, messages: Vec) { + let Some(actor) = self.actors.get(actor_ref) else { + return; + }; + let thread_id = actor.thread_id; + for mut message in messages { + if message["type"] != "event" { + continue; + } + if message["event"] == "continued" { + self.frames.retain(|_, owner| owner.actor_ref != actor_ref); + self.variables + .retain(|_, owner| owner.actor_ref != actor_ref); + } + if message["event"] == "stopped" { + message["body"]["threadId"] = json!(thread_id); + message["body"]["allThreadsStopped"] = json!(false); + } else if message["event"] == "continued" { + message["body"]["threadId"] = json!(thread_id); + message["body"]["allThreadsContinued"] = json!(false); + } + self.remap_source_fields(actor_ref, &mut message); + self.event( + message["event"].as_str().unwrap_or("output"), + message["body"].clone(), + ); + } + } + + fn remap_frames(&mut self, actor_ref: &str, mut result: Json) -> Result { + let frames = result["stackFrames"] + .as_array_mut() + .ok_or("Expected stack frame list")?; + for frame in frames { + if let Some(local) = frame["id"].as_u64() { + self.next_reference += 1; + let global = self.next_reference; + self.frames.insert( + global, + HandleOwner { + actor_ref: actor_ref.to_owned(), + local_id: local, + }, + ); + frame["id"] = json!(global); + } + self.remap_source_fields(actor_ref, frame); + } + Ok(result) + } + + fn remap_variables(&mut self, actor_ref: &str, mut result: Json) -> Result { + fn walk(this: &mut ImageAdapter, actor_ref: &str, value: &mut Json) { + match value { + Json::Object(object) => { + if let Some(local) = object + .get("variablesReference") + .and_then(Json::as_u64) + .filter(|id| *id != 0) + { + this.next_reference += 1; + let global = this.next_reference; + this.variables.insert( + global, + HandleOwner { + actor_ref: actor_ref.to_owned(), + local_id: local, + }, + ); + object.insert("variablesReference".to_owned(), json!(global)); + } + for child in object.values_mut() { + walk(this, actor_ref, child); + } + } + Json::Array(values) => { + for child in values { + walk(this, actor_ref, child); + } + } + _ => {} + } + } + walk(self, actor_ref, &mut result); + Ok(result) + } + + fn remap_source_fields(&self, actor_ref: &str, value: &mut Json) { + match value { + Json::Object(object) => { + if object.contains_key("sourceReference") + && object["sourceReference"] == 1 + && let Some(actor) = self.actors.get(actor_ref) + { + object.insert("sourceReference".to_owned(), json!(actor.source_reference)); + object.insert("path".to_owned(), json!(self.behavior_uri(actor_ref))); + } + for child in object.values_mut() { + self.remap_source_fields(actor_ref, child); + } + } + Json::Array(values) => { + for child in values { + self.remap_source_fields(actor_ref, child); + } + } + _ => {} + } + } + + fn set_breakpoints(&mut self, args: &Json) -> Result { + let source = args + .get("source") + .filter(|source| source.is_object()) + .ok_or("Expected breakpoint source")?; + let path = source["path"].as_str(); + let source_reference = source.get("sourceReference").and_then(Json::as_u64); + let actor_refs = if let Some(reference) = source_reference { + self.sources + .get(&reference) + .cloned() + .into_iter() + .collect::>() + } else { + let behavior_actor = self + .actors + .keys() + .find(|actor_ref| path == Some(self.behavior_uri(actor_ref).as_str())) + .cloned(); + if let Some(actor_ref) = behavior_actor { + vec![actor_ref] + } else { + self.actors + .iter() + .filter(|(_, actor)| actor.adapter.working_uri.as_deref() == path) + .map(|(actor_ref, _)| actor_ref.clone()) + .collect() + } + }; + if actor_refs.is_empty() { + let requested = args["breakpoints"].as_array().cloned().unwrap_or_default(); + return Ok( + json!({"breakpoints": requested.into_iter().map(|breakpoint| { + json!({"verified":false,"line":breakpoint["line"],"message":"Source is not an actor's exact loaded behavior"}) + }).collect::>()}), + ); + } + let mut breakpoints = Vec::new(); + for actor_ref in actor_refs { + let mut rewritten = args.clone(); + if args["sourceModified"] == true { + rewritten["source"] + .as_object_mut() + .unwrap() + .remove("sourceReference"); + if source_reference.is_some() { + rewritten["source"]["path"] = json!(self.behavior_uri(&actor_ref)); + } + } else if source_reference.is_some() { + rewritten["source"]["sourceReference"] = json!(1); + rewritten["source"]["path"] = json!(self.behavior_uri(&actor_ref)); + } + let mut result = self.forward(&actor_ref, "setBreakpoints", rewritten)?; + self.remap_source_fields(&actor_ref, &mut result); + breakpoints.extend( + result["breakpoints"] + .as_array() + .cloned() + .unwrap_or_default(), + ); + } + Ok(json!({"breakpoints":breakpoints})) + } + + fn focus_actor(&mut self, args: &Json) -> Result { + let actor_ref = args["actorRef"] + .as_str() + .ok_or("Expected actorRef")? + .to_owned(); + let actor = self + .actors + .get_mut(&actor_ref) + .ok_or("Unknown actor reference")?; + let snapshot = actor.adapter.inspect_actor()?; + if !matches!(snapshot.status, ActorStatusDto::Failed(_)) { + actor.adapter.system(SystemMessageDto::Pause)?; + } + actor.adapter.stop_key = None; + actor.adapter.synchronize()?; + let events = std::mem::take(&mut actor.adapter.messages); + self.forward_events(&actor_ref, events); + self.event("output", json!({"category":"console","output":format!("Runa: Focused actor @{actor_ref}; no other actor was paused.\n")})); + Ok(json!({})) + } + + fn behavior_uri(&self, actor_ref: &str) -> String { + format!( + "{}/{actor_ref}/behavior.rn", + self.behavior_base + .as_deref() + .unwrap_or("") + .trim_end_matches('/') + ) + } + + fn working_uri(&self, path: &str) -> String { + format!( + "{}/{}", + self.working_base + .as_deref() + .unwrap_or("") + .trim_end_matches('/'), + path.trim_start_matches('/') + ) + } + + fn source_json(&self, actor_ref: &str, reference: u64) -> Json { + json!({"name":"behavior.rn","path":self.behavior_uri(actor_ref),"sourceReference":reference}) + } +} + +fn uri(args: &Json, key: &str) -> Result { + match args.get(key).and_then(Json::as_str) { + Some(value) if !value.is_empty() && value.len() <= 8192 && !value.contains('\0') => { + Ok(value.to_owned()) + } _ => Err(format!("Expected nonempty {key} string")), } } diff --git a/crates/runa-dap/tests/adapter.rs b/crates/runa-dap/tests/adapter.rs index 240b10a..41e8d79 100644 --- a/crates/runa-dap/tests/adapter.rs +++ b/crates/runa-dap/tests/adapter.rs @@ -114,6 +114,28 @@ fn fixture() -> (Image, String) { (image, actor) } +fn attach_image(image: &Image) -> Adapter { + let mut adapter = Adapter::new(image.clone()); + ok(&mut adapter, "initialize", json!({})); + let messages = request( + &mut adapter, + "attach", + json!({ + "image": true, + "actorSourceBaseUri": "runa-actor://session", + "workingSourceBaseUri": "runa-image://localhost/source" + }), + ); + assert_eq!(messages[0]["success"], true, "{messages:?}"); + assert!( + messages + .iter() + .any(|message| message["event"] == "initialized") + ); + ok(&mut adapter, "configurationDone", json!({})); + adapter +} + #[test] fn attach_pause_source_and_resume_never_tick_or_clear_breakpoints() { let (mut image, actor) = fixture(); @@ -891,3 +913,448 @@ fn zero_based_locations_and_unsupported_breakpoint_features() { assert_eq!(response["breakpoints"][1]["verified"], false); assert_eq!(response["breakpoints"][2]["verified"], false); } + +#[test] +fn image_attach_discovers_threads_without_mutation_and_routes_actor_controls() { + let (mut image, first) = fixture(); + let second = image.actor("tests/other", SOURCE); + let before_first = image.inspect(&first); + let before_second = image.inspect(&second); + let mut adapter = attach_image(&image); + + assert_eq!(image.inspect(&first), before_first); + assert_eq!(image.inspect(&second), before_second); + assert!(!image.commands.borrow().iter().any(|command| { + matches!( + command, + Command::System { .. } | Command::SetBreakpoints { .. } + ) + })); + + let threads = ok(&mut adapter, "threads", json!({}))["threads"] + .as_array() + .unwrap() + .clone(); + assert_eq!(threads.len(), 2); + let first_thread = threads + .iter() + .find(|thread| thread["name"] == format!("@{first}")) + .unwrap()["id"] + .as_u64() + .unwrap(); + let second_thread = threads + .iter() + .find(|thread| thread["name"] == format!("@{second}")) + .unwrap()["id"] + .as_u64() + .unwrap(); + assert_ne!(first_thread, second_thread); + let sources = ok(&mut adapter, "loadedSources", json!({}))["sources"] + .as_array() + .unwrap() + .clone(); + assert_eq!(sources.len(), 2); + assert_ne!(sources[0]["sourceReference"], sources[1]["sourceReference"]); + assert!( + sources.iter().any(|source| { + source["path"] == format!("runa-actor://session/{first}/behavior.rn") + }) + ); + let first_source_ref = sources + .iter() + .find(|source| source["path"] == format!("runa-actor://session/{first}/behavior.rn")) + .unwrap()["sourceReference"] + .clone(); + assert_eq!( + ok( + &mut adapter, + "source", + json!({"sourceReference":first_source_ref}) + )["content"], + SOURCE + ); + let first_source = json!({"path": format!("runa-actor://session/{first}/behavior.rn")}); + let breakpoints = ok( + &mut adapter, + "setBreakpoints", + json!({"source": first_source, "breakpoints": [{"line": 5}]}), + ); + assert_eq!(breakpoints["breakpoints"][0]["verified"], true); + let dirty = ok( + &mut adapter, + "setBreakpoints", + json!({ + "source": {"path": format!("runa-actor://session/{first}/behavior.rn")}, + "sourceModified": true, + "breakpoints": [{"line": 5}] + }), + ); + assert_eq!(dirty["breakpoints"][0]["verified"], false); + let first_execution = image + .request(Command::InspectExecution { + actor_ref: first.clone(), + }) + .unwrap(); + let Reply::ExecutionInspected(first_execution) = first_execution else { + panic!() + }; + assert!(first_execution.debugger.breakpoints.is_empty()); + let Reply::ExecutionInspected(other_execution) = image + .request(Command::InspectExecution { + actor_ref: second.clone(), + }) + .unwrap() + else { + panic!() + }; + assert!(other_execution.debugger.breakpoints.is_empty()); + + ok(&mut adapter, "pause", json!({"threadId": first_thread})); + let stopped = adapter.poll(); + assert!(stopped.iter().any(|message| { + message["event"] == "stopped" + && message["body"]["threadId"] == first_thread + && message["body"]["allThreadsStopped"] == false + })); + assert_eq!( + image.inspect(&first).status, + runa_protocol::ActorStatusDto::Paused + ); + assert_eq!(image.inspect(&second).status, before_second.status); + + let focused = request(&mut adapter, "focusActor", json!({"actorRef":second})); + assert_eq!(focused[0]["success"], true, "{focused:?}"); + assert!(focused.iter().any(|message| { + message["event"] == "stopped" + && message["body"]["threadId"] == second_thread + && message["body"]["allThreadsStopped"] == false + })); + let first_root = ok( + &mut adapter, + "evaluate", + json!({"expression":"actor","threadId":first_thread}), + )["variablesReference"] + .clone(); + let second_root = ok( + &mut adapter, + "evaluate", + json!({"expression":"actor","threadId":second_thread}), + )["variablesReference"] + .clone(); + assert_ne!(first_root, second_root); + assert_ne!(first_root, first_source_ref); + assert_ne!(second_root, first_source_ref); + assert_eq!( + request( + &mut adapter, + "variables", + json!({"variablesReference":first_root}) + )[0]["success"], + true + ); + assert_eq!( + request( + &mut adapter, + "variables", + json!({"variablesReference":second_root}) + )[0]["success"], + true + ); + + let continued = request(&mut adapter, "continue", json!({"threadId": first_thread})); + assert_eq!(continued[0]["success"], true); + assert_eq!(continued[0]["body"]["allThreadsContinued"], false); + assert!(continued.iter().any(|message| { + message["event"] == "continued" + && message["body"]["threadId"] == first_thread + && message["body"]["allThreadsContinued"] == false + })); + assert_eq!( + image.inspect(&second).status, + runa_protocol::ActorStatusDto::Paused + ); + + let focused = request(&mut adapter, "focusActor", json!({"actorRef":first})); + assert_eq!(focused[0]["success"], true, "{focused:?}"); + assert!(focused.iter().any(|message| { + message["event"] == "stopped" + && message["body"]["threadId"] == first_thread + && message["body"]["allThreadsStopped"] == false + })); + assert_eq!( + image.inspect(&first).status, + runa_protocol::ActorStatusDto::Paused + ); + assert_eq!( + image.inspect(&second).status, + runa_protocol::ActorStatusDto::Paused + ); + assert!( + !image + .commands + .borrow() + .iter() + .any(|command| matches!(command, Command::Tick { .. })) + ); +} + +#[test] +fn image_threads_track_spawn_stop_and_restart_incarnations() { + let (mut image, actor) = fixture(); + let mut adapter = attach_image(&image); + let original = ok(&mut adapter, "threads", json!({}))["threads"][0]["id"] + .as_u64() + .unwrap(); + + let spawned = image.actor("tests/new", SOURCE); + let updated = request(&mut adapter, "threads", json!({})); + let spawned_thread = updated + .iter() + .find(|message| { + message["event"] == "thread" + && message["body"]["reason"] == "started" + && message["body"]["threadId"] != original + }) + .unwrap()["body"]["threadId"] + .as_u64() + .unwrap(); + assert!( + updated[0]["body"]["threads"] + .as_array() + .unwrap() + .iter() + .any(|thread| thread["id"] == spawned_thread) + ); + + image + .request(Command::RestartActor { + actor_ref: actor.clone(), + }) + .unwrap(); + let restarted = adapter.poll(); + assert!(restarted.iter().any(|message| { + message["event"] == "thread" + && message["body"]["reason"] == "exited" + && message["body"]["threadId"] == original + })); + let replacement_thread = ok(&mut adapter, "threads", json!({}))["threads"] + .as_array() + .unwrap() + .iter() + .find(|thread| thread["name"] == format!("@{actor}")) + .unwrap()["id"] + .as_u64() + .unwrap(); + assert_ne!(replacement_thread, original); + + image + .request(Command::StopActor { + actor_ref: spawned.clone(), + }) + .unwrap(); + image + .request(Command::Tick { + ticks: 1, + instruction_budget: 1, + }) + .unwrap(); + let removed = adapter.poll(); + assert!(removed.iter().any(|message| { + message["event"] == "thread" + && message["body"]["reason"] == "exited" + && message["body"]["threadId"] == spawned_thread + })); + assert!( + !ok(&mut adapter, "threads", json!({}))["threads"] + .as_array() + .unwrap() + .iter() + .any(|thread| thread["id"] == spawned_thread) + ); + assert!( + ok(&mut adapter, "threads", json!({}))["threads"] + .as_array() + .unwrap() + .iter() + .any(|thread| thread["id"] == replacement_thread) + ); +} + +#[test] +fn image_behavior_rebind_retires_only_that_thread_and_its_source_reference() { + let (mut image, actor) = fixture(); + let other = image.actor("tests/other", SOURCE); + let mut adapter = attach_image(&image); + let threads = ok(&mut adapter, "threads", json!({}))["threads"] + .as_array() + .unwrap() + .clone(); + let actor_thread = threads + .iter() + .find(|thread| thread["name"] == format!("@{actor}")) + .unwrap()["id"] + .as_u64() + .unwrap(); + let other_thread = threads + .iter() + .find(|thread| thread["name"] == format!("@{other}")) + .unwrap()["id"] + .as_u64() + .unwrap(); + let old_source = ok(&mut adapter, "loadedSources", json!({}))["sources"] + .as_array() + .unwrap() + .iter() + .find(|source| source["path"] == format!("runa-actor://session/{actor}/behavior.rn")) + .unwrap()["sourceReference"] + .as_u64() + .unwrap(); + + image + .request(Command::PublishBehavior { + name: "tests/rebound".to_owned(), + expected_revision: None, + source: SOURCE.replace("value + 1", "value + 2"), + }) + .unwrap(); + image + .request(Command::InstallBehavior { + actor_ref: actor.clone(), + behavior_name: "tests/rebound".to_owned(), + }) + .unwrap(); + let status_before_poll = image.inspect(&actor).status; + let events = adapter.poll(); + assert_eq!(image.inspect(&actor).status, status_before_poll); + assert!(events.iter().any(|message| { + message["event"] == "thread" + && message["body"]["reason"] == "exited" + && message["body"]["threadId"] == actor_thread + })); + let replacement = ok(&mut adapter, "threads", json!({}))["threads"] + .as_array() + .unwrap() + .iter() + .find(|thread| thread["name"] == format!("@{actor}")) + .unwrap()["id"] + .as_u64() + .unwrap(); + assert_ne!(replacement, actor_thread); + assert!( + ok(&mut adapter, "threads", json!({}))["threads"] + .as_array() + .unwrap() + .iter() + .any(|thread| thread["id"] == other_thread) + ); + assert_eq!( + request( + &mut adapter, + "source", + json!({"sourceReference":old_source}) + )[0]["success"], + false + ); + let new_source = ok(&mut adapter, "loadedSources", json!({}))["sources"] + .as_array() + .unwrap() + .iter() + .find(|source| source["path"] == format!("runa-actor://session/{actor}/behavior.rn")) + .unwrap()["sourceReference"] + .clone(); + assert_ne!(old_source, new_source.as_u64().unwrap()); +} + +#[test] +fn image_frame_ids_are_global_and_route_to_their_own_actor() { + let (mut image, first) = fixture(); + let second = image.actor("tests/second", SOURCE); + for actor_ref in [&first, &second] { + image + .request(Command::Send { + actor_ref: actor_ref.clone(), + message: Value::Integer(3), + }) + .unwrap(); + } + image + .request(Command::Tick { + ticks: 1, + instruction_budget: 1, + }) + .unwrap(); + let mut adapter = attach_image(&image); + let source_references: Vec<_> = ok(&mut adapter, "loadedSources", json!({}))["sources"] + .as_array() + .unwrap() + .iter() + .map(|source| source["sourceReference"].as_u64().unwrap()) + .collect(); + + let mut frame_ids = Vec::new(); + for actor_ref in [&first, &second] { + let focused = request(&mut adapter, "focusActor", json!({"actorRef":actor_ref})); + assert_eq!(focused[0]["success"], true, "{focused:?}"); + let threads = ok(&mut adapter, "threads", json!({}))["threads"] + .as_array() + .unwrap() + .clone(); + let thread_id = threads + .iter() + .find(|thread| thread["name"] == format!("@{actor_ref}")) + .unwrap()["id"] + .clone(); + let trace = ok(&mut adapter, "stackTrace", json!({"threadId":thread_id})); + assert!(trace["totalFrames"].as_u64().unwrap() > 0, "{trace:?}"); + let frame_id = trace["stackFrames"][0]["id"].as_u64().unwrap(); + assert!(!source_references.contains(&frame_id)); + assert_eq!( + ok(&mut adapter, "scopes", json!({"frameId":frame_id}))["scopes"] + .as_array() + .unwrap() + .len(), + 2 + ); + frame_ids.push(frame_id); + } + assert_ne!(frame_ids[0], frame_ids[1]); +} + +#[test] +fn image_focus_leaves_failed_actor_unchanged() { + 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 before = image.inspect(&actor); + assert!(matches!( + before.status, + runa_protocol::ActorStatusDto::Failed(_) + )); + image.commands.borrow_mut().clear(); + let mut adapter = attach_image(&image); + let messages = request(&mut adapter, "focusActor", json!({"actorRef":actor})); + assert_eq!(messages[0]["success"], true, "{messages:?}"); + assert!(messages.iter().any(|message| message["event"] == "stopped")); + assert_eq!(image.inspect(&actor), before); + assert!( + !image + .commands + .borrow() + .iter() + .any(|command| matches!(command, Command::System { .. })) + ); +} diff --git a/docs/architecture.md b/docs/architecture.md index d64d2d2..a4b3412 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -584,9 +584,12 @@ 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 -stepping. Continue does not implicitly tick the shared scheduler. Sessions bind -to the connection, implementation and incarnation; a change terminates the -session rather than remapping live frames. Detach leaves actor status and +stepping. Connecting the extension starts an image-wide session without pausing +actors, discovering existing/new actors as threads. Restart, removal or loaded-code +replacement retires that actor's old thread and handles without ending the image +session. Explicit single-actor sessions remain available and terminate on +implementation/incarnation changes. Continue does not implicitly tick the shared +scheduler. Detach leaves actor status and breakpoints unchanged. Read-only DAP scopes expose owned committed actor snapshots (state, mailbox, configuration and identity), shared across frames, not live invocation drafts. Handles expire on stepping/resume or snapshot @@ -599,7 +602,7 @@ Failed actors can be attached without lifecycle mutation. DAP exception details report the sampled actor failure and snapshot watches expose retained committed state and the reserved message, not a post-mortem stack. Debug run controls reject failed actors; explicit repair/restart remains separate, and restart ends the -old-incarnation debug session. +old-incarnation debug thread (or explicit single-actor session). Non-destructive image-change notifications and automatic snapshot refresh are implemented over the shared `runa-debug` WebSocket transport. diff --git a/docs/protocol.md b/docs/protocol.md index ca103fd..e778332 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -250,6 +250,18 @@ 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. +Image-wide attach instead supplies `image: true`, `actorSourceBaseUri` +(for example `runa-actor://1`), and optionally `workingSourceBaseUri` +(`runa-image://127.0.0.1:3737/source`). It discovers actors as distinct threads +without pausing them. Actor-local `behavior.rn` paths route gutter breakpoints; +control requests use the actor's `threadId`. Frame, variable and source references +are session-wide and actor-specific. Actor removal or identity changes retire +only the affected thread and its handles. Newly spawned actors become new threads. +The custom `focusActor` request with `{actorRef}` focuses/pauses one actor, +preserving failure without retrying failed actors. The VS Code extension uses +this mode automatically when connecting; `runa.autoAttachDebugger: false` +retains opt-in single-actor debugging. + 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 are wire DTOs, not serialized runtime objects; implementation identities are diff --git a/vscode/README.md b/vscode/README.md index f20dbad..5118101 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -257,7 +257,18 @@ Use a server built with mode-aware stepping for these new commands. ### Native actor debugger -Connect to an image, then right-click an actor and choose **Debug Actor**. +Connecting to an image automatically starts an image-wide debugger. Existing and +newly spawned actors appear as threads in Call Stack without being paused, +resumed, or otherwise executed by attachment. Set breakpoints directly in an +actor's `behavior.rn`, or select a thread and use Pause and the debug toolbar. +Right-clicking **Debug Actor** focuses and pauses only that actor in the existing +image session; failed actors are inspected without retrying them. +Disconnecting from the image ends its debug session without changing actors. +Set `runa.autoAttachDebugger` to `false` to disable automatic attachment on the +next connection. Debugger startup errors are reported without dropping the image +connection; check `runa.debugAdapterPath` if the executable cannot be launched. + +With automatic attachment disabled, right-click an actor and choose **Debug Actor**. Alternatively choose **Runa Actor** in Run and Debug and use an attach configuration (omit `actorRef` to choose an actor): @@ -278,9 +289,12 @@ and set `runa.debugAdapterPath` to the executable's absolute path (for example `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 -actor is allowed. Attach pauses a live nonfailed actor, retaining its active +An image-wide session discovers one thread per actor incarnation. Removal, +restart, or a new loaded implementation retires only that actor's old thread +and its inspection handles; the image session and other actors remain usable. +Explicit single-actor sessions are still bound to their installed implementation +and incarnation, and end when either changes. Overlapping image/single-actor +sessions are rejected. Single-actor attach pauses a live nonfailed actor, retaining its active 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 its status, incarnation, state or mailbox. @@ -346,7 +360,8 @@ The extension continues to launch its own stdio adapter by default. reject failed actors explicitly; they never retry, resume, or restart them. State/mailbox repair, temporary-behavior edits and **Restart Actor** remain separate explicit actions. Repair-only changes refresh the sampled data while - preserving failure; a new implementation or incarnation ends the session. + preserving failure; a new implementation or incarnation retires the actor's + thread in image mode, or ends an explicit single-actor session. Detaching leaves the failed actor untouched. Exception filters and first-chance/pre-unwind pauses are not supported. - Watch and Debug Console support **snapshot paths**, not Rune expressions: @@ -376,10 +391,10 @@ The extension continues to launch its own stdio adapter by default. native calls remain nonpreemptible. Unsupported requests fail explicitly. - Disconnecting/stopping the debug session leaves the actor's current status and breakpoint list unchanged: it does not kill, resume, or restart the actor. - Actor removal, implementation installation, restart/incarnation change, or - image disconnect terminates the session with a console explanation. Attach - again to use the new version. Pending code edits alone do not change the - loaded version. + Actor removal, implementation installation, or restart/incarnation change + retires only that actor's old thread in image mode; explicit single-actor + sessions terminate instead. Image disconnect ends the whole session. + Pending code edits alone do not change the loaded version. Breakpoint lists are actor/image state shared with other clients and the standalone **Set Actor Breakpoints...** command; a later writer replaces the diff --git a/vscode/integration/dap.test.ts b/vscode/integration/dap.test.ts index 35c9760..9b9ee3f 100644 --- a/vscode/integration/dap.test.ts +++ b/vscode/integration/dap.test.ts @@ -113,6 +113,82 @@ function startAdapter(address: string) { }; } +test( + "image-wide Rust DAP discovers actors without pausing and isolates behavior breakpoints", + { timeout: 20000 }, + async (t) => { + const image = await startImage(); + const client = await ImageClient.connect(image.address, (error) => + assert.equal(error, undefined), + ); + const adapter = startAdapter(image.address); + t.after(async () => { + try { + await adapter.stop(); + } finally { + try { + await client.close(); + } finally { + await image.stop(); + } + } + }); + const source = "pub fn handle(ctx, state, sender, message) {\n state + 1\n}"; + const file = await client.writeSource("tests/image_dap", null, source); + const actor = await client.spawn(file.path, parseValue("10")); + const other = await client.spawn(file.path, parseValue("20")); + const before = await client.inspectActor(actor); + const otherBefore = await client.inspectActor(other); + await adapter.request("initialize"); + await adapter.request("attach", { + image: true, + actorSourceBaseUri: "runa-actor://1", + workingSourceBaseUri: `runa-image://${image.address}/source`, + }); + await adapter.request("configurationDone"); + const threads = await adapter.request<{ threads: { id: number; name: string }[] }>("threads"); + const thread = threads.threads.find((thread) => thread.name.includes(actor))!; + const otherThread = threads.threads.find((thread) => thread.name.includes(other))!; + assert.ok(thread); + assert.ok(otherThread); + assert.notEqual(thread.id, otherThread.id); + assert.deepEqual(await client.inspectActor(actor), before); + assert.deepEqual(await client.inspectActor(other), otherBefore); + const result = await adapter.request<{ breakpoints: { verified: boolean }[] }>( + "setBreakpoints", + { + source: { path: `runa-actor://1/${actor}/behavior.rn` }, + breakpoints: [{ line: 2 }], + }, + ); + assert.equal(result.breakpoints[0]?.verified, true); + assert.equal((await client.inspectExecution(other)).debugger?.breakpoints.length ?? 0, 0); + const since = adapter.messages.length; + await client.send(actor, parseValue("null")); + await client.tick(1, 1000); + const stopped = await adapter.wait((message) => message.event === "stopped", since); + assert.equal((stopped.body as { threadId: number }).threadId, thread.id); + assert.equal((stopped.body as { allThreadsStopped: boolean }).allThreadsStopped, false); + const stack = await adapter.request<{ + stackFrames: { id: number; source: { path: string; sourceReference: number } }[]; + }>("stackTrace", { threadId: thread.id }); + assert.equal(stack.stackFrames[0]?.source.path, `runa-actor://1/${actor}/behavior.rn`); + assert.deepEqual(await client.inspectActor(other), otherBefore); + const added = await client.spawn(file.path, parseValue("30")); + const discovered = await adapter.request<{ threads: { id: number; name: string }[] }>( + "threads", + ); + assert.ok(discovered.threads.some((thread) => thread.name.includes(added))); + assert.equal((await client.inspectActor(added)).status.status, "idle"); + await client.system(actor, { kind: "kill" }); + const remaining = await adapter.request<{ threads: { id: number; name: string }[] }>("threads"); + assert.ok(!remaining.threads.some((entry) => entry.id === thread.id)); + assert.ok(remaining.threads.some((entry) => entry.id === otherThread.id)); + await adapter.request("disconnect"); + assert.deepEqual(await client.inspectActor(other), otherBefore); + }, +); + test( "standalone Rust DAP binds behavior.rn breakpoints to one actor and serves exact loaded code", { timeout: 20000 }, diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index ae616d9..7eee11a 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -54,6 +54,10 @@ export async function run(): Promise { ), ); await vscode.commands.executeCommand("workbench.view.extension.runa"); + await automaticImageDebugging(image.address); + await vscode.workspace + .getConfiguration("runa") + .update("autoAttachDebugger", false, vscode.ConfigurationTarget.Global); await vscode.commands.executeCommand("runa.connect", image.address); assert.ok(!(await vscode.commands.getCommands(true)).includes("runa.addSourceFolder")); assert.deepEqual( @@ -267,6 +271,76 @@ export async function run(): Promise { } } +async function automaticImageDebugging(address: string): Promise { + const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); + const configuration = vscode.workspace.getConfiguration("runa"); + await configuration.update("autoAttachDebugger", true, vscode.ConfigurationTarget.Global); + const file = await client.writeSource( + "tests/automatic_debugger", + null, + "pub fn handle(ctx, state, sender, message) {\n state + 1\n}", + ); + const actor = await client.spawn(file.path, parseValue("10")); + const other = await client.spawn(file.path, parseValue("20")); + const before = await client.inspectActor(actor); + const otherBefore = await client.inspectActor(other); + const awaitSession = async () => { + for (let count = 0; count < 100; count++) { + const session = vscode.debug.activeDebugSession; + if (session?.type === "runa" && session.configuration.image === true) return session; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("Connecting did not start an image-wide debugger"); + }; + const threadsFor = async (session: vscode.DebugSession, actor: string) => { + for (let count = 0; count < 100; count++) { + const result: { threads: { id: number; name: string }[] } = + await session.customRequest("threads"); + const thread = result.threads.find((thread) => thread.name.includes(actor)); + if (thread) return thread; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("Actor was not discovered as a debugger thread"); + }; + try { + await vscode.commands.executeCommand("runa.connect", address); + const session = await awaitSession(); + const firstThread = await threadsFor(session, actor); + const otherThread = await threadsFor(session, other); + assert.notEqual(firstThread.id, otherThread.id); + assert.deepEqual(await client.inspectActor(actor), before); + assert.deepEqual(await client.inspectActor(other), otherBefore); + const newActor = await client.spawn(file.path, parseValue("30")); + await threadsFor(session, newActor); + assert.equal((await client.inspectActor(newActor)).status.status, "idle"); + assert.equal( + await vscode.commands.executeCommand("runa.debugActor", { actor_ref: actor }), + true, + ); + assert.equal(vscode.debug.activeDebugSession?.id, session.id); + assert.equal((await client.inspectActor(actor)).status.status, "paused"); + assert.deepEqual(await client.inspectActor(other), otherBefore); + await vscode.commands.executeCommand("runa.disconnect"); + await vscode.commands.executeCommand("runa.connect", address); + const reconnected = await awaitSession(); + assert.notEqual(reconnected.id, session.id); + await threadsFor(reconnected, other); + assert.deepEqual(await client.inspectActor(other), otherBefore); + await vscode.commands.executeCommand("runa.disconnect"); + await client.system(newActor, { kind: "kill" }); + console.log( + "Runa automatic debugger: non-pausing attach, dynamic threads, targeted focus and reconnect passed", + ); + } finally { + await vscode.commands.executeCommand("runa.disconnect"); + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + await client.system(actor, { kind: "kill" }); + await client.system(other, { kind: "kill" }); + await client.deleteSource(file.path, file.source_id); + await client.close(); + } +} + async function snapshotImage(address: string): Promise { const directory = await mkdtemp(join(tmpdir(), "runa-host-snapshot-")); const file = join(directory, "image.json"); diff --git a/vscode/package.json b/vscode/package.json index 7ffedd2..2fb6908 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -54,6 +54,10 @@ "configurationAttributes": { "attach": { "properties": { + "image": { + "type": "boolean", + "description": "Discover all actors as threads without pausing them on attach." + }, "actorRef": { "type": "string", "description": "Actor reference in the connected image. Omit to choose an actor." @@ -640,6 +644,11 @@ "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." + }, + "runa.autoAttachDebugger": { + "type": "boolean", + "default": true, + "description": "Automatically start an image-wide debugger when connecting, without pausing actors. Applies on the next connection." } } } diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 8a14f24..588af47 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -288,6 +288,25 @@ export function activate(context: vscode.ExtensionContext): { ? undefined : "No actors. Spawn a .rn file from the source folder."; output.appendLine(`Connected to ${address} (protocol v5)`); + if (vscode.workspace.getConfiguration("runa").get("autoAttachDebugger", true)) { + try { + const started = await vscode.debug.startDebugging( + undefined, + { + type: "runa", + request: "attach", + name: `Runa Image ${address}`, + image: true, + runaConnection: currentSession, + }, + { suppressSaveBeforeStart: true }, + ); + if (!started) + throw new Error("Could not start the image debugger; check runa.debugAdapterPath"); + } catch (error) { + report(error); + } + } }; const inspect = async (selected?: ActorSnapshot) => { @@ -975,6 +994,26 @@ export function activate(context: vscode.ExtensionContext): { throw new Error("Image connection changed before debugger attachment"); if (configuration.request && configuration.request !== "attach") throw new Error("Runa supports attach debugging only"); + if (configuration.image === true) { + return { + ...configuration, + type: "runa", + request: "attach", + name: configuration.name ?? `Runa Image ${connection.endpoint}`, + runaConnection: connection.session, + actorSourceBaseUri: vscode.Uri.from({ + scheme: ACTOR_SCHEME, + authority: String(connection.session), + }) + .toString() + .replace(/\/$/, ""), + workingSourceBaseUri: vscode.Uri.from({ + scheme: SOURCE_SCHEME, + authority: connection.endpoint, + path: "/source", + }).toString(), + }; + } const actorRef = configuration.actorRef ?? (await chooseActor()); if (!actorRef) return undefined; const inspection = await connection.client.inspectExecution(actorRef); @@ -1003,9 +1042,17 @@ export function activate(context: vscode.ExtensionContext): { if (debugSession.configuration.runaConnection !== connection.session) throw new Error("Image connection changed before debugger attachment"); const actorRef: unknown = debugSession.configuration.actorRef; - if (typeof actorRef !== "string") throw new Error("Choose a debug actor"); - if ([...debuggers.values()].some((session) => session.configuration.actorRef === actorRef)) - throw new Error("This actor already has a Runa debug session"); + if (debugSession.configuration.image !== true && typeof actorRef !== "string") + throw new Error("Choose a debug actor"); + if ( + [...debuggers.values()].some( + (session) => + session.configuration.image === true || + debugSession.configuration.image === true || + session.configuration.actorRef === actorRef, + ) + ) + throw new Error("An overlapping Runa debug session is already active"); const executable = vscode.workspace .getConfiguration("runa") .get("debugAdapterPath", "runa-dap"); @@ -1017,7 +1064,11 @@ export function activate(context: vscode.ExtensionContext): { }), vscode.debug.onDidTerminateDebugSession((session) => debuggers.delete(session.id)), vscode.debug.registerDebugAdapterTrackerFactory("runa", { - createDebugAdapterTracker: () => ({ + createDebugAdapterTracker: (debugSession) => ({ + onError: (error) => { + debuggers.delete(debugSession.id); + report(error); + }, onWillReceiveMessage: (message) => { if (message.type !== "request" || message.command !== "setBreakpoints") return; const uri = message.arguments?.source?.path; @@ -1036,6 +1087,17 @@ export function activate(context: vscode.ExtensionContext): { const actorRef = await chooseActor(selected); if (!actorRef) return; connection.check(); + const imageDebugger = [...debuggers.values()].find( + (debugSession) => + debugSession.configuration.image === true && + debugSession.configuration.runaConnection === connection.session, + ); + if (imageDebugger) { + await imageDebugger.customRequest("focusActor", { actorRef }); + connection.check(); + await editBehavior({ actor_ref: actorRef }); + return true; + } const started = await vscode.debug.startDebugging( undefined, {