diff --git a/CHANGELOG.md b/CHANGELOG.md index fcf5b5c..68e93d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index ad704e3..4835a0f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/runa-dap/src/main.rs b/crates/runa-dap/src/main.rs index 7d02d98..52051ed 100644 --- a/crates/runa-dap/src/main.rs +++ b/crates/runa-dap/src/main.rs @@ -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, } 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), diff --git a/crates/runa-dap/tests/stdio.rs b/crates/runa-dap/tests/stdio.rs index 88100a7..68dc4c4 100644 --- a/crates/runa-dap/tests/stdio.rs +++ b/crates/runa-dap/tests/stdio.rs @@ -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(); } diff --git a/docs/protocol.md b/docs/protocol.md index 0cc7b39..ca103fd 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 diff --git a/vscode/README.md b/vscode/README.md index 4310d4e..d07ed0a 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -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.