feat: automatically attach image-wide debugger on connect

Discover actors as independent debug threads without pausing them. Route breakpoints and controls per actor, preserve unrelated threads across lifecycle changes, and allow opting out of automatic attachment.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-05 10:11:16 +01:00
parent e5887ff79c
commit 8211ef6da4
11 changed files with 1492 additions and 22 deletions

View File

@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file.
### Added ### 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 - Add `mise run package-extension` to generate `vscode/runa.vsix` for manual
installation without installing it automatically. installation without installing it automatically.
- Add `runa-dap --listen` / `RUNA_DAP_LISTEN` for loopback TCP DAP sessions, - Add `runa-dap --listen` / `RUNA_DAP_LISTEN` for loopback TCP DAP sessions,

View File

@ -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 stack frames and exact loaded source through read-only protocol inspection and
VS Code's **Inspect Execution Stack...** actor action. Actor-local source 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 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 gutter breakpoints, the call-stack view, and bounded toolbar stepping. Continue
resumes the actor; scheduler ticks remain explicit. The Variables view exposes resumes the actor; scheduler ticks remain explicit. The Variables view exposes
a read-only committed actor snapshot with expandable state and mailbox. a read-only committed actor snapshot with expandable state and mailbox.

View File

@ -1,7 +1,9 @@
//! One-actor DAP sessions over the loopback image protocol. Debug controls never tick the image. //! 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::collections::{BTreeMap, BTreeSet};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::rc::Rc;
use runa_client::WebSocketClient; use runa_client::WebSocketClient;
use runa_protocol::{ use runa_protocol::{
@ -75,7 +77,7 @@ struct BreakpointGroup {
entries: Vec<Breakpoint>, entries: Vec<Breakpoint>,
} }
pub struct Adapter<C> { struct ActorAdapter<C> {
connection: C, connection: C,
sequence: u64, sequence: u64,
messages: Vec<Json>, messages: Vec<Json>,
@ -99,8 +101,8 @@ pub struct Adapter<C> {
breakpoint_states: BTreeMap<u64, bool>, breakpoint_states: BTreeMap<u64, bool>,
} }
impl<C: Connection> Adapter<C> { impl<C: Connection> ActorAdapter<C> {
pub fn new(connection: C) -> Self { fn new(connection: C) -> Self {
Self { Self {
connection, connection,
sequence: 0, sequence: 0,
@ -134,7 +136,7 @@ impl<C: Connection> Adapter<C> {
if self.closed { if self.closed {
return Vec::new(); return Vec::new();
} }
let request: Request = match serde_json::from_value(message) { let request: Request = match serde_json::from_value::<Request>(message) {
Ok(request) => request, Ok(request) => request,
Err(error) => return self.terminate(&format!("Invalid DAP request: {error}")), Err(error) => return self.terminate(&format!("Invalid DAP request: {error}")),
}; };
@ -806,6 +808,750 @@ fn optional_uri(args: &Json, key: &str) -> Result<Option<String>, String> {
{ {
Ok(Some(value.clone())) Ok(Some(value.clone()))
} }
_ => Err(format!("Expected nonempty {key} string")),
}
}
#[derive(Clone)]
struct SharedConnection<C>(Rc<RefCell<C>>);
impl<C: Connection> Connection for SharedConnection<C> {
fn request(&mut self, command: Command) -> Result<Reply, String> {
self.0.borrow_mut().request(command)
}
}
enum AdapterMode<C> {
Actor(Box<ActorAdapter<SharedConnection<C>>>),
Image(Box<ImageAdapter<C>>),
}
/// A DAP session which can attach either to one actor or to every actor in an image.
pub struct Adapter<C> {
connection: Rc<RefCell<C>>,
mode: AdapterMode<C>,
sequence: u64,
}
impl<C: Connection> Adapter<C> {
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<Json> {
let image_attach = serde_json::from_value::<Request>(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<Json> {
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<Json> {
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<Json>) -> Vec<Json> {
messages
.into_iter()
.map(|mut message| {
self.sequence += 1;
message["seq"] = json!(self.sequence);
message
})
.collect()
}
}
struct ImageActor<C> {
adapter: ActorAdapter<SharedConnection<C>>,
thread_id: u64,
source_reference: u64,
}
#[derive(Clone)]
struct HandleOwner {
actor_ref: String,
local_id: u64,
}
struct ImageAdapter<C> {
connection: Rc<RefCell<C>>,
actors: BTreeMap<String, ImageActor<C>>,
threads: BTreeMap<u64, String>,
sources: BTreeMap<u64, String>,
frames: BTreeMap<u64, HandleOwner>,
variables: BTreeMap<u64, HandleOwner>,
next_thread: u64,
next_reference: u64,
messages: Vec<Json>,
initialized: bool,
configured: bool,
closed: bool,
behavior_base: Option<String>,
working_base: Option<String>,
line_base: usize,
column_base: usize,
}
impl<C: Connection> ImageAdapter<C> {
fn new(connection: Rc<RefCell<C>>) -> 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<Json> {
if self.closed {
return Vec::new();
}
let request: Request = match serde_json::from_value::<Request>(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<Json> {
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<Json> {
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<Json> {
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<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
}));
}
"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::<Vec<_>>()}),
),
"loadedSources" => Ok(
json!({"sources": self.actors.iter().map(|(actor_ref, actor)| {
self.source_json(actor_ref, actor.source_reference)
}).collect::<Vec<_>>()}),
),
"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<ExecutionInspectionDto, String> {
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<Json, String> {
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<String, String> {
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<Json, String> {
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<Json>) {
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<Json, String> {
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<Json, String> {
fn walk<C>(this: &mut ImageAdapter<C>, 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<Json, String> {
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::<Vec<_>>()
} 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::<Vec<_>>()}),
);
}
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<Json, String> {
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<String, String> {
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")), _ => Err(format!("Expected nonempty {key} string")),
} }
} }

View File

@ -114,6 +114,28 @@ fn fixture() -> (Image, String) {
(image, actor) (image, actor)
} }
fn attach_image(image: &Image) -> Adapter<Image> {
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] #[test]
fn attach_pause_source_and_resume_never_tick_or_clear_breakpoints() { fn attach_pause_source_and_resume_never_tick_or_clear_breakpoints() {
let (mut image, actor) = fixture(); 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"][1]["verified"], false);
assert_eq!(response["breakpoints"][2]["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 { .. }))
);
}

View File

@ -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. `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 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. Connecting the extension starts an image-wide session without pausing
to the connection, implementation and incarnation; a change terminates the actors, discovering existing/new actors as threads. Restart, removal or loaded-code
session rather than remapping live frames. Detach leaves actor status and 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 breakpoints unchanged. Read-only DAP scopes expose owned committed actor
snapshots (state, mailbox, configuration and identity), shared across frames, snapshots (state, mailbox, configuration and identity), shared across frames,
not live invocation drafts. Handles expire on stepping/resume or snapshot 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 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 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 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 Non-destructive image-change notifications and automatic snapshot refresh are
implemented over the shared `runa-debug` WebSocket transport. implemented over the shared `runa-debug` WebSocket transport.

View File

@ -250,6 +250,18 @@ source stepping, and failure inspection. Mutable-file breakpoints must match
the exact loaded text; modified documents remain unverified. Disconnect leaves the exact loaded text; modified documents remain unverified. Disconnect leaves
actor status and breakpoints unchanged. 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 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

@ -257,7 +257,18 @@ Use a server built with mode-aware stepping for these new commands.
### Native actor debugger ### 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 Alternatively choose **Runa Actor** in Run and Debug and use an attach
configuration (omit `actorRef` to choose an actor): 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. `cargo install --path crates/runa-dap` from the repository root.
The adapter connects to the selected image over loopback WebSocket; it does not 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. start an image, spawn an actor, or open a separate debug port.
Each session represents one actor/thread and is bound to the current An image-wide session discovers one thread per actor incarnation. Removal,
connection, installed implementation, and incarnation. Only one session per restart, or a new loaded implementation retires only that actor's old thread
actor is allowed. Attach pauses a live nonfailed actor, retaining its active 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 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.
@ -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. reject failed actors explicitly; they never retry, resume, or restart them.
State/mailbox repair, temporary-behavior edits and **Restart Actor** remain State/mailbox repair, temporary-behavior edits and **Restart Actor** remain
separate explicit actions. Repair-only changes refresh the sampled data while 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 Detaching leaves the failed actor untouched. Exception filters and
first-chance/pre-unwind pauses are not supported. first-chance/pre-unwind pauses are not supported.
- Watch and Debug Console support **snapshot paths**, not Rune expressions: - 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. native calls remain nonpreemptible. Unsupported requests fail explicitly.
- Disconnecting/stopping the debug session leaves the actor's current status - Disconnecting/stopping the debug session leaves the actor's current status
and breakpoint list unchanged: it does not kill, resume, or restart the actor. and breakpoint list unchanged: it does not kill, resume, or restart the actor.
Actor removal, implementation installation, restart/incarnation change, or Actor removal, implementation installation, or restart/incarnation change
image disconnect terminates the session with a console explanation. Attach retires only that actor's old thread in image mode; explicit single-actor
again to use the new version. Pending code edits alone do not change the sessions terminate instead. Image disconnect ends the whole session.
loaded version. Pending code edits alone do not change the loaded version.
Breakpoint lists are actor/image state shared with other clients and the Breakpoint lists are actor/image state shared with other clients and the
standalone **Set Actor Breakpoints...** command; a later writer replaces the standalone **Set Actor Breakpoints...** command; a later writer replaces the

View File

@ -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( test(
"standalone Rust DAP binds behavior.rn breakpoints to one actor and serves exact loaded code", "standalone Rust DAP binds behavior.rn breakpoints to one actor and serves exact loaded code",
{ timeout: 20000 }, { timeout: 20000 },

View File

@ -54,6 +54,10 @@ export async function run(): Promise<void> {
), ),
); );
await vscode.commands.executeCommand("workbench.view.extension.runa"); 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); await vscode.commands.executeCommand("runa.connect", image.address);
assert.ok(!(await vscode.commands.getCommands(true)).includes("runa.addSourceFolder")); assert.ok(!(await vscode.commands.getCommands(true)).includes("runa.addSourceFolder"));
assert.deepEqual( assert.deepEqual(
@ -267,6 +271,76 @@ export async function run(): Promise<void> {
} }
} }
async function automaticImageDebugging(address: string): Promise<void> {
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<void> { async function snapshotImage(address: string): Promise<void> {
const directory = await mkdtemp(join(tmpdir(), "runa-host-snapshot-")); const directory = await mkdtemp(join(tmpdir(), "runa-host-snapshot-"));
const file = join(directory, "image.json"); const file = join(directory, "image.json");

View File

@ -54,6 +54,10 @@
"configurationAttributes": { "configurationAttributes": {
"attach": { "attach": {
"properties": { "properties": {
"image": {
"type": "boolean",
"description": "Discover all actors as threads without pausing them on attach."
},
"actorRef": { "actorRef": {
"type": "string", "type": "string",
"description": "Actor reference in the connected image. Omit to choose an actor." "description": "Actor reference in the connected image. Omit to choose an actor."
@ -640,6 +644,11 @@
"default": "runa-dap", "default": "runa-dap",
"scope": "machine", "scope": "machine",
"description": "Path to the standalone Rust runa-dap executable. Build with cargo build -p runa-dap, or install it on PATH." "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."
} }
} }
} }

View File

@ -288,6 +288,25 @@ export function activate(context: vscode.ExtensionContext): {
? undefined ? undefined
: "No actors. Spawn a .rn file from the source folder."; : "No actors. Spawn a .rn file from the source folder.";
output.appendLine(`Connected to ${address} (protocol v5)`); output.appendLine(`Connected to ${address} (protocol v5)`);
if (vscode.workspace.getConfiguration("runa").get<boolean>("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) => { const inspect = async (selected?: ActorSnapshot) => {
@ -975,6 +994,26 @@ export function activate(context: vscode.ExtensionContext): {
throw new Error("Image connection changed before debugger attachment"); throw new Error("Image connection changed before debugger attachment");
if (configuration.request && configuration.request !== "attach") if (configuration.request && configuration.request !== "attach")
throw new Error("Runa supports attach debugging only"); 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()); const actorRef = configuration.actorRef ?? (await chooseActor());
if (!actorRef) return undefined; if (!actorRef) return undefined;
const inspection = await connection.client.inspectExecution(actorRef); const inspection = await connection.client.inspectExecution(actorRef);
@ -1003,9 +1042,17 @@ export function activate(context: vscode.ExtensionContext): {
if (debugSession.configuration.runaConnection !== connection.session) if (debugSession.configuration.runaConnection !== connection.session)
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 (debugSession.configuration.image !== true && typeof actorRef !== "string")
if ([...debuggers.values()].some((session) => session.configuration.actorRef === actorRef)) throw new Error("Choose a debug actor");
throw new Error("This actor already has a Runa debug session"); 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 const executable = vscode.workspace
.getConfiguration("runa") .getConfiguration("runa")
.get<string>("debugAdapterPath", "runa-dap"); .get<string>("debugAdapterPath", "runa-dap");
@ -1017,7 +1064,11 @@ export function activate(context: vscode.ExtensionContext): {
}), }),
vscode.debug.onDidTerminateDebugSession((session) => debuggers.delete(session.id)), vscode.debug.onDidTerminateDebugSession((session) => debuggers.delete(session.id)),
vscode.debug.registerDebugAdapterTrackerFactory("runa", { vscode.debug.registerDebugAdapterTrackerFactory("runa", {
createDebugAdapterTracker: () => ({ createDebugAdapterTracker: (debugSession) => ({
onError: (error) => {
debuggers.delete(debugSession.id);
report(error);
},
onWillReceiveMessage: (message) => { onWillReceiveMessage: (message) => {
if (message.type !== "request" || message.command !== "setBreakpoints") return; if (message.type !== "request" || message.command !== "setBreakpoints") return;
const uri = message.arguments?.source?.path; const uri = message.arguments?.source?.path;
@ -1036,6 +1087,17 @@ export function activate(context: vscode.ExtensionContext): {
const actorRef = await chooseActor(selected); const actorRef = await chooseActor(selected);
if (!actorRef) return; if (!actorRef) return;
connection.check(); 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( const started = await vscode.debug.startDebugging(
undefined, undefined,
{ {