feat: add configurable TCP listener to debug adapter

Support --listen and RUNA_DAP_LISTEN for loopback TCP sessions while preserving stdio by default. Test framed exchanges, sequential connections and recovery from invalid framing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-05 09:50:45 +01:00
parent 513415f21c
commit 21fe58ba09
6 changed files with 132 additions and 8 deletions

View File

@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file.
### Added
- Add `runa-dap --listen` / `RUNA_DAP_LISTEN` for loopback TCP DAP sessions,
retaining stdio as the default transport.
- Add `mise run install-extension` to build the debug adapter, package the VS Code
extension, and install it locally using `code` (or `VSCODE_CLI`).
- Add a standalone Rust `runa-dap` stdio debug adapter for VS Code actor debugging.

View File

@ -300,6 +300,9 @@ Set breakpoints there and use **Debug Actor** to debug that actor with VS Code's
Run and Debug controls. Build the standalone Rust DAP server with
`cargo build -p runa-dap` and set `runa.debugAdapterPath` to its absolute path,
or install it on PATH with `cargo install --path crates/runa-dap`.
For TCP DAP clients, use `runa-dap --listen 127.0.0.1:4711 --connect 127.0.0.1:3737`
(or set `RUNA_DAP_LISTEN`). The DAP listener is loopback-only; omitting the
listen option keeps stdio transport.
**Reload Actor Source**
clears the override, **Go to Behavior** opens the shared source, and **Update Behavior**
asks before copying the temporary text into shared working source without committing.

View File

@ -1,5 +1,5 @@
use std::io::{self, BufReader};
use std::net::SocketAddr;
use std::io::{self, BufReader, Read, Write};
use std::net::{Shutdown, SocketAddr, TcpListener};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::time::Duration;
@ -7,10 +7,13 @@ use clap::Parser;
use runa_dap::{Adapter, WebSocketConnection, framing};
#[derive(Parser)]
#[command(about = "Stdio Debug Adapter Protocol server for one Runa actor")]
#[command(about = "Stdio or loopback TCP Debug Adapter Protocol server for Runa actors")]
struct Args {
#[arg(long, env = "RUNA_CONNECT", default_value = "127.0.0.1:3737")]
connect: SocketAddr,
/// Listen for TCP DAP sessions instead of using stdin/stdout.
#[arg(long, env = "RUNA_DAP_LISTEN")]
listen: Option<SocketAddr>,
}
fn main() -> io::Result<()> {
@ -21,9 +24,39 @@ fn main() -> io::Result<()> {
"--connect must be a loopback address",
));
}
if let Some(address) = args.listen {
if !address.ip().is_loopback() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--listen must be a loopback address",
));
}
let listener = TcpListener::bind(address)?;
eprintln!("runa-dap: listening on {}", listener.local_addr()?);
for stream in listener.incoming() {
let stream = stream?;
let result = run_session(stream.try_clone()?, &stream, args.connect);
if let Err(error) = stream.shutdown(Shutdown::Both) {
eprintln!("runa-dap: could not close session socket: {error}");
}
if let Err(error) = result {
eprintln!("runa-dap: session failed: {error}");
}
}
Ok(())
} else {
run_session(io::stdin(), io::stdout().lock(), args.connect)
}
}
fn run_session(
input: impl Read + Send + 'static,
mut output: impl Write,
connect: SocketAddr,
) -> io::Result<()> {
let (sender, receiver) = mpsc::sync_channel(16);
std::thread::spawn(move || {
let mut input = BufReader::new(io::stdin().lock());
let mut input = BufReader::new(input);
loop {
let result = framing::read_frame(&mut input);
let finished = !matches!(&result, Ok(Some(_)));
@ -32,8 +65,7 @@ fn main() -> io::Result<()> {
}
}
});
let mut adapter = Adapter::new(WebSocketConnection::new(args.connect));
let mut output = io::stdout().lock();
let mut adapter = Adapter::new(WebSocketConnection::new(connect));
while !adapter.is_closed() {
let messages = match receiver.recv_timeout(Duration::from_millis(150)) {
Ok(Ok(Some(message))) => adapter.handle(message),

View File

@ -1,5 +1,7 @@
use std::io::{Cursor, Write};
use std::io::{BufRead, BufReader, Cursor, Write};
use std::net::TcpStream;
use std::process::{Command, Stdio};
use std::time::Duration;
use runa_dap::framing::{read_frame, write_frame};
use serde_json::{Value, json};
@ -8,6 +10,7 @@ fn run(input: &[u8], args: &[&str]) -> std::process::Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_runa-dap"))
.args(args)
.env_remove("RUNA_CONNECT")
.env_remove("RUNA_DAP_LISTEN")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@ -82,9 +85,77 @@ fn cli_documents_connect_and_rejects_nonloopback_addresses() {
let help = String::from_utf8(help.stdout).unwrap();
assert!(help.contains("RUNA_CONNECT"));
assert!(help.contains("127.0.0.1:3737"));
assert!(help.contains("--listen"));
assert!(help.contains("RUNA_DAP_LISTEN"));
for address in ["0.0.0.0:3737", "192.0.2.1:3737", "invalid"] {
let output = run(&[], &["--connect", address]);
assert!(!output.status.success());
assert!(!output.stderr.is_empty());
}
for address in ["0.0.0.0:0", "[::]:0", "192.0.2.1:3737", "invalid"] {
let output = run(&[], &["--listen", address]);
assert!(!output.status.success());
assert!(!output.stderr.is_empty());
}
}
#[test]
fn tcp_listener_accepts_framed_sessions_and_survives_invalid_framing() {
let mut child = Command::new(env!("CARGO_BIN_EXE_runa-dap"))
.args(["--listen", "127.0.0.1:0"])
.env_remove("RUNA_CONNECT")
.env_remove("RUNA_DAP_LISTEN")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut line = String::new();
let mut diagnostics = BufReader::new(child.stderr.take().unwrap());
diagnostics.read_line(&mut line).unwrap();
let address = line.trim().strip_prefix("runa-dap: listening on ").unwrap();
for malformed in [true, false, false] {
let mut socket = TcpStream::connect(address).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
if malformed {
socket
.write_all(b"Content-Length: 1048577\r\n\r\n")
.unwrap();
let mut reader = BufReader::new(socket);
assert_eq!(read_frame(&mut reader).unwrap().unwrap()["event"], "output");
assert_eq!(
read_frame(&mut reader).unwrap().unwrap()["event"],
"terminated"
);
} else {
write_frame(
&mut socket,
&json!({
"seq": 1, "type": "request", "command": "initialize"
}),
)
.unwrap();
let mut reader = BufReader::new(socket.try_clone().unwrap());
assert_eq!(read_frame(&mut reader).unwrap().unwrap()["success"], true);
write_frame(
&mut socket,
&json!({
"seq": 2, "type": "request", "command": "disconnect"
}),
)
.unwrap();
assert_eq!(read_frame(&mut reader).unwrap().unwrap()["success"], true);
assert_eq!(
read_frame(&mut reader).unwrap().unwrap()["event"],
"terminated"
);
}
}
}));
child.kill().unwrap();
child.wait().unwrap();
result.unwrap();
}

View File

@ -229,7 +229,16 @@ explicit debugger step operation advances only its selected paused actor.
`runa-dap --connect 127.0.0.1:3737` speaks standard Content-Length-framed DAP
on stdin/stdout and connects to an existing trusted loopback image using the
WebSocket operations above. Diagnostics go to stderr. It does not start an
image, tick the scheduler implicitly, or expose another network listener.
image or tick the scheduler implicitly. Stdio mode opens no DAP network listener.
To accept DAP over TCP instead of stdio, use
`runa-dap --listen 127.0.0.1:4711 --connect 127.0.0.1:3737`.
`RUNA_DAP_LISTEN` also selects the DAP bind address; `--listen` overrides it.
Only loopback addresses are accepted because the API is unauthenticated.
Port `0` chooses an available port, reported on stderr. TCP uses the same
Content-Length framing, handles one session at a time, and accepts a new
session after disconnect or a malformed session. Without `--listen` or
`RUNA_DAP_LISTEN`, the existing stdio behavior is unchanged.
An attach request supplies `actorRef`, optionally `behaviorSourceUri` (the
actor's `behavior.rn` editor URI) and `workingSourceUri` (its shared image source

View File

@ -274,6 +274,13 @@ 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.
For a DAP client connecting over TCP, start
`runa-dap --listen 127.0.0.1:4711 --connect 127.0.0.1:3737`.
The optional `RUNA_DAP_LISTEN` environment variable sets the same bind address.
The listener is unauthenticated and restricted to loopback; it serves one
session at a time. Port `0` selects an available port and prints it on stderr.
The extension continues to launch its own stdio adapter by default.
- Set gutter breakpoints in each actor's `behavior.rn`, or its immutable loaded source, opened by the
command or Call Stack view. The debugger serves the exact compiled text,
including temporary/older implementations, using source references.