feat!: connect the REPL to the localhost image

Add a reusable loopback TCP client, server-owned counter bootstrap, and shared-image reconnect coverage. Preserve actor state and behavior revisions across client disconnects without retrying uncertain requests.

BREAKING CHANGE: the REPL no longer starts an embedded image. Start runa-server with --listen before connecting; use --connect to select a loopback endpoint.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-02 15:57:26 +01:00
parent 4f6e7ccfa2
commit 350ddba0ce
11 changed files with 371 additions and 60 deletions

View File

@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- Reusable loopback TCP protocol client with bounded messages, correlation
checks, explicit connection failures, and no automatic request retries.
- Multi-client and reconnect tests proving that actors remain in the
server-owned image after development clients disconnect.
- Typed, instruction-sliced Rune invocations with preserved async execution, - Typed, instruction-sliced Rune invocations with preserved async execution,
wake-driven resumption, and cancellation tests. wake-driven resumption, and cancellation tests.
- Minimal single-worker actor runtime with bounded FIFO mailboxes, private - Minimal single-worker actor runtime with bounded FIFO mailboxes, private
@ -70,6 +74,10 @@ All notable changes to this project will be documented in this file.
### Changed ### Changed
- The REPL now connects to the localhost server rather than embedding its own
image. Start the server first; `--connect` selects a loopback endpoint.
- TCP server startup publishes the bundled counter behavior once; connecting
or disconnecting clients does not reset behavior revisions or actor state.
- Interactive CLI commands now use the versioned protocol dispatcher instead - Interactive CLI commands now use the versioned protocol dispatcher instead
of calling the image API directly. of calling the image API directly.
- Protocol version 2 replaces numeric actor IDs with NanoID actor references - Protocol version 2 replaces numeric actor IDs with NanoID actor references

1
Cargo.lock generated
View File

@ -551,6 +551,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
name = "runa" name = "runa"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"runa-client",
"runa-protocol", "runa-protocol",
"runa-server", "runa-server",
"rustyline", "rustyline",

View File

@ -21,8 +21,8 @@ installation, pause/resume, and cancellation through a trusted in-process image
API. A versioned transport-neutral protocol contract is now defined, but no API. A versioned transport-neutral protocol contract is now defined, but no
authentication is implemented. The dispatcher is available through local authentication is implemented. The dispatcher is available through local
JSON-lines stdio and an unauthenticated TCP listener restricted to localhost; JSON-lines stdio and an unauthenticated TCP listener restricted to localhost;
both use a bounded request size. The interactive CLI now uses the versioned both use a bounded request size. The interactive CLI connects to the localhost
protocol and dispatcher rather than calling the image API directly. server over TCP, so multiple clients operate on the same server-owned image.
The prototype uses worker-local invocation stacks and cooperative instruction The prototype uses worker-local invocation stacks and cooperative instruction
slices, and actor snapshots report basic active-execution progress. Native slices, and actor snapshots report basic active-execution progress. Native
@ -45,12 +45,25 @@ architecture, roadmap, and open questions.
You need a Rust toolchain that supports the workspace's Rust 2024 edition. You need a Rust toolchain that supports the workspace's Rust 2024 edition.
Run the current executable from the repository root: Start the live image from the repository root:
```sh ```sh
mise run run mise run serve-network
``` ```
In another terminal, connect the REPL:
```sh
cargo run -p runa
```
The REPL defaults to `127.0.0.1:3737`. To select another loopback endpoint,
use `cargo run -p runa -- --connect 127.0.0.1:4000` and start the server with
the corresponding `--listen` address. Quitting the REPL disconnects only that
client; actors and published behaviors survive until the server exits.
Reconnect and use `actors` to recover their references. REPL bindings and
command history are session-local and are not restored on reconnect.
For a local child-process protocol endpoint, run `mise run serve` and exchange For a local child-process protocol endpoint, run `mise run serve` and exchange
one JSON request per line on stdin; responses are written to stdout. Request one JSON request per line on stdin; responses are written to stdout. Request
lines are limited to 1 MiB. lines are limited to 1 MiB.
@ -61,14 +74,18 @@ simultaneous clients sharing one live image. The listener rejects non-loopback
addresses and has no authentication; any local process that can connect has addresses and has no authentication; any local process that can connect has
full access to the development API. Use it only on a trusted local machine. full access to the development API. Use it only on a trusted local machine.
The `runa-client` crate can launch the stdio process and exchange typed The `runa-client` crate can launch the stdio process or connect over loopback
protocol requests with request/response correlation. The VS Code extension is TCP and exchange typed protocol requests with request/response correlation.
TCP connection establishment is limited to 5 seconds and reads/writes to 30
seconds. Transport failures disconnect the client and are never automatically
retried: an operation may have committed even if its response was lost.
The VS Code extension is
intended to connect over the network to this same live image; actors remain in intended to connect over the network to this same live image; actors remain in
the image regardless of how clients connect. Remote network access requires the image regardless of how clients connect. Remote network access requires
authentication and authorization, which are not implemented. authentication and authorization, which are not implemented.
The CLI opens an interactive runtime session with the `counter` behavior The TCP server publishes the bundled `counter` behavior once at startup;
published: connecting a client does not publish or reset behavior revisions:
```text ```text
runa> let sender = spawn counter 0 runa> let sender = spawn counter 0
@ -133,7 +150,7 @@ test task runs `cargo test --workspace`.
│ ├── runa-image-api/ # Trusted in-process image operations │ ├── runa-image-api/ # Trusted in-process image operations
│ ├── runa-protocol/ # Versioned transport-neutral wire DTOs │ ├── runa-protocol/ # Versioned transport-neutral wire DTOs
│ ├── runa-server/ # Protocol dispatcher and local JSON-lines transport │ ├── runa-server/ # Protocol dispatcher and local JSON-lines transport
│ └── runa-client/ # Local child-process protocol client │ └── runa-client/ # Stdio child-process and loopback TCP clients
├── docs/ ├── docs/
│ ├── architecture.md │ ├── architecture.md
│ ├── protocol.md │ ├── protocol.md

View File

@ -1,9 +1,11 @@
//! Local child-process client for the versioned JSON-lines protocol. //! Stdio and loopback TCP clients for the versioned JSON-lines protocol.
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fmt; use std::fmt;
use std::io::{self, BufRead, BufReader, BufWriter, Write}; use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::net::{Shutdown, SocketAddr, TcpStream};
use std::process::{Child, ChildStdin, ChildStdout, Command, ExitStatus, Stdio}; use std::process::{Child, ChildStdin, ChildStdout, Command, ExitStatus, Stdio};
use std::time::Duration;
use runa_protocol::{Command as ProtocolCommand, PROTOCOL_VERSION, Request, Response}; use runa_protocol::{Command as ProtocolCommand, PROTOCOL_VERSION, Request, Response};
@ -20,6 +22,51 @@ pub struct ProtocolClient<R, W> {
next_request_id: u64, next_request_id: u64,
} }
pub struct TcpClient {
client: ProtocolClient<BufReader<TcpStream>, BufWriter<TcpStream>>,
disconnected: bool,
}
impl TcpClient {
/// Connect only to loopback, without authentication or automatic retries.
pub fn connect(address: SocketAddr) -> io::Result<Self> {
if !address.ip().is_loopback() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"server address must be a loopback address",
));
}
let stream = TcpStream::connect_timeout(&address, Duration::from_secs(5))?;
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
stream.set_write_timeout(Some(Duration::from_secs(30)))?;
Ok(Self {
client: ProtocolClient::new(
BufReader::new(stream.try_clone()?),
BufWriter::new(stream),
),
disconnected: false,
})
}
pub fn request(&mut self, command: ProtocolCommand) -> Result<Response, ClientError> {
if self.disconnected {
return Err(io::Error::new(
io::ErrorKind::NotConnected,
"TCP client is disconnected; reconnect explicitly",
)
.into());
}
let result = self.client.request(command);
if result.is_err() {
self.disconnected = true;
// A lost response may follow a committed operation. Never retry it
// or reuse a stream whose request/response alignment is uncertain.
let _ = self.client.writer.get_ref().shutdown(Shutdown::Both);
}
result
}
}
#[derive(Debug)] #[derive(Debug)]
pub enum ClientError { pub enum ClientError {
Io(io::Error), Io(io::Error),
@ -34,7 +81,7 @@ pub enum ClientError {
impl fmt::Display for ClientError { impl fmt::Display for ClientError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::Io(error) => write!(formatter, "local protocol transport failed: {error}"), Self::Io(error) => write!(formatter, "protocol transport failed: {error}"),
Self::Serialization(error) => write!(formatter, "protocol JSON failed: {error}"), Self::Serialization(error) => write!(formatter, "protocol JSON failed: {error}"),
Self::RequestIdsExhausted => write!(formatter, "protocol request IDs exhausted"), Self::RequestIdsExhausted => write!(formatter, "protocol request IDs exhausted"),
Self::RequestTooLarge => write!( Self::RequestTooLarge => write!(
@ -162,7 +209,7 @@ impl<R: BufRead, W: Write> ProtocolClient<R, W> {
if !has_line { if !has_line {
return Err(ClientError::Io(io::Error::new( return Err(ClientError::Io(io::Error::new(
io::ErrorKind::UnexpectedEof, io::ErrorKind::UnexpectedEof,
"server closed stdout before responding", "server closed the connection before responding",
))); )));
} }
let response: Response = serde_json::from_slice(&line)?; let response: Response = serde_json::from_slice(&line)?;
@ -220,6 +267,35 @@ mod tests {
use runa_protocol::{ProtocolError, Reply}; use runa_protocol::{ProtocolError, Reply};
use std::io::Cursor; use std::io::Cursor;
#[test]
fn tcp_client_rejects_non_loopback_addresses() {
let error = TcpClient::connect("192.0.2.1:3737".parse().unwrap())
.err()
.expect("non-loopback connection must be rejected");
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn tcp_client_reports_disconnect_and_does_not_retry() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let peer = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let request: Request = serde_json::from_str(&line).unwrap();
assert_eq!(request.command, ProtocolCommand::ListActors);
// Close after receiving the request, without sending a response.
});
let mut client = TcpClient::connect(address).unwrap();
assert!(matches!(client.request(ProtocolCommand::ListActors),
Err(ClientError::Io(error)) if error.kind() == io::ErrorKind::UnexpectedEof));
assert!(matches!(client.request(ProtocolCommand::ListActors),
Err(ClientError::Io(error)) if error.kind() == io::ErrorKind::NotConnected));
peer.join().unwrap();
}
fn response(request_id: &str) -> Vec<u8> { fn response(request_id: &str) -> Vec<u8> {
let response = Response { let response = Response {
version: PROTOCOL_VERSION, version: PROTOCOL_VERSION,

View File

@ -2,8 +2,26 @@ use std::io;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::process::ExitCode; use std::process::ExitCode;
use runa_protocol::{Command, PROTOCOL_VERSION, Request};
use runa_server::{Server, bind_loopback_listener, serve_json_lines, serve_tcp}; use runa_server::{Server, bind_loopback_listener, serve_json_lines, serve_tcp};
fn initial_image() -> io::Result<Server> {
let mut server = Server::new();
server
.dispatch(Request {
version: PROTOCOL_VERSION,
request_id: "bootstrap-counter".to_owned(),
command: Command::PublishBehavior {
name: "counter".to_owned(),
expected_revision: None,
source: include_str!("../../../runa-std/actors/counter.rn").to_owned(),
},
})
.result
.map_err(|error| io::Error::other(error.message))?;
Ok(server)
}
fn main() -> ExitCode { fn main() -> ExitCode {
match run() { match run() {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,
@ -37,7 +55,7 @@ fn run() -> io::Result<()> {
format!("invalid listener address: {error}"), format!("invalid listener address: {error}"),
) )
})?; })?;
serve_tcp(bind_loopback_listener(address)?, Server::new()) serve_tcp(bind_loopback_listener(address)?, initial_image()?)
} }
Some(_) => Err(io::Error::new( Some(_) => Err(io::Error::new(
io::ErrorKind::InvalidInput, io::ErrorKind::InvalidInput,

View File

@ -3,7 +3,7 @@ use std::net::{Shutdown, SocketAddr, TcpStream};
use std::process::{Child, Command as ProcessCommand, Stdio}; use std::process::{Child, Command as ProcessCommand, Stdio};
use std::time::Duration; use std::time::Duration;
use runa_client::LocalClient; use runa_client::{LocalClient, TcpClient};
use runa_protocol::{Command, MessageDto, PROTOCOL_VERSION, Reply, Request, Response}; use runa_protocol::{Command, MessageDto, PROTOCOL_VERSION, Reply, Request, Response};
struct ServerProcess(Child); struct ServerProcess(Child);
@ -104,4 +104,70 @@ fn tcp_server_accepts_local_protocol_requests() {
let response: Response = serde_json::from_str(&response_line).unwrap(); let response: Response = serde_json::from_str(&response_line).unwrap();
assert_eq!(response.request_id, "tcp-process-test"); assert_eq!(response.request_id, "tcp-process-test");
assert_eq!(response.result, Ok(Reply::Actors(Vec::new()))); assert_eq!(response.result, Ok(Reply::Actors(Vec::new())));
let mut first = TcpClient::connect(address).unwrap();
let mut second = TcpClient::connect(address).unwrap();
let actor_ref = match first
.request(Command::Spawn {
behavior_name: "counter".to_owned(),
state: 5,
mailbox_capacity: 2,
})
.unwrap()
.result
.unwrap()
{
Reply::ActorSpawned { actor_ref } => actor_ref,
other => panic!("unexpected spawn response: {other:?}"),
};
assert!(matches!(second.request(Command::InspectActor {
actor_ref: actor_ref.clone(),
}).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == 5));
assert!(matches!(
first
.request(Command::RollbackBehavior {
name: "counter".to_owned(),
expected_revision: 1,
target_revision: 1,
})
.unwrap()
.result,
Ok(Reply::Behavior(behavior)) if behavior.revision == 2
));
drop(first);
drop(second);
let mut reconnected = TcpClient::connect(address).unwrap();
assert!(matches!(
reconnected
.request(Command::GetBehavior {
name: "counter".to_owned(),
})
.unwrap()
.result,
Ok(Reply::Behavior(behavior)) if behavior.revision == 2
));
reconnected
.request(Command::Send {
actor_ref: actor_ref.clone(),
message: MessageDto {
kind: 0,
value: 7,
target_ref: None,
},
})
.unwrap()
.result
.unwrap();
reconnected
.request(Command::Tick {
ticks: 100,
instruction_budget: 10,
})
.unwrap()
.result
.unwrap();
assert!(matches!(reconnected.request(Command::InspectActor {
actor_ref,
}).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == 12));
} }

View File

@ -4,6 +4,9 @@ version = "0.0.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
runa-client = { path = "../runa-client" }
runa-protocol = { path = "../runa-protocol" } runa-protocol = { path = "../runa-protocol" }
runa-server = { path = "../runa-server" }
rustyline = "15.0" rustyline = "15.0"
[dev-dependencies]
runa-server = { path = "../runa-server" }

View File

@ -2,9 +2,12 @@ use std::collections::BTreeMap;
#[cfg(test)] #[cfg(test)]
use std::io::BufRead; use std::io::BufRead;
use std::io::{self, Write}; use std::io::{self, Write};
use std::net::SocketAddr;
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use runa_protocol::{Command, MessageDto, PROTOCOL_VERSION, Reply, Request, Response}; use runa_client::TcpClient;
use runa_protocol::{Command, MessageDto, Reply};
#[cfg(test)]
use runa_server::Server; use runa_server::Server;
use rustyline::completion::{Completer, Pair}; use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError; use rustyline::error::ReadlineError;
@ -16,8 +19,38 @@ use rustyline::{Context, Editor, Helper};
const MAILBOX_CAPACITY: usize = 64; const MAILBOX_CAPACITY: usize = 64;
const DEFAULT_TICK_BUDGET: usize = 32; const DEFAULT_TICK_BUDGET: usize = 32;
#[cfg(test)]
const COUNTER_BEHAVIOR: &str = "counter"; const COUNTER_BEHAVIOR: &str = "counter";
trait Connection {
fn dispatch(&mut self, command: Command) -> Result<Reply, String>;
}
impl Connection for TcpClient {
fn dispatch(&mut self, command: Command) -> Result<Reply, String> {
self.request(command)
.map_err(|error| error.to_string())?
.result
.map_err(|error| error.message)
}
}
#[cfg(test)]
impl Connection for Server {
fn dispatch(&mut self, command: Command) -> Result<Reply, String> {
Server::dispatch(
self,
runa_protocol::Request {
version: runa_protocol::PROTOCOL_VERSION,
request_id: "runa-cli-test".to_owned(),
command,
},
)
.result
.map_err(|error| error.message)
}
}
#[derive(Default)] #[derive(Default)]
struct ReplState { struct ReplState {
bindings: BTreeMap<String, String>, bindings: BTreeMap<String, String>,
@ -134,34 +167,34 @@ impl Validator for ReplHelper {}
impl Helper for ReplHelper {} impl Helper for ReplHelper {}
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut server = Server::new(); let address = parse_address(&std::env::args().skip(1).collect::<Vec<_>>())?;
let behavior = match dispatch( let mut server = TcpClient::connect(address).map_err(|error| {
&mut server, format!("cannot connect to {address}: {error}; start `mise run serve-network` first")
Command::PublishBehavior { })?;
name: COUNTER_BEHAVIOR.to_owned(),
expected_revision: None,
source: include_str!("../../../runa-std/actors/counter.rn").to_owned(),
},
)? {
Reply::Behavior(behavior) => behavior,
other => return Err(format!("unexpected publish response: {other:?}").into()),
};
println!("Runa — Rune Actors"); println!("Runa — Rune Actors");
println!( println!("Connected to {address}. The image remains running when you quit.");
"Loaded behavior `{}` revision {}.",
behavior.name, behavior.revision
);
println!("Type `help` for commands. Use Up/Down for history and Tab for completion."); println!("Type `help` for commands. Use Up/Down for history and Tab for completion.");
run_interactive_repl(&mut server)?; run_interactive_repl(&mut server)?;
Ok(()) Ok(())
} }
fn parse_address(args: &[String]) -> Result<SocketAddr, String> {
let address = match args {
[] => "127.0.0.1:3737",
[flag, address] if flag == "--connect" => address,
_ => return Err("usage: runa [--connect <loopback-address>]".to_owned()),
};
address
.parse()
.map_err(|error| format!("invalid server address: {error}"))
}
#[cfg(test)] #[cfg(test)]
fn run_repl<R: BufRead, W: Write>( fn run_repl<R: BufRead, W: Write>(
mut input: R, mut input: R,
mut output: W, mut output: W,
server: &mut Server, server: &mut dyn Connection,
) -> io::Result<()> { ) -> io::Result<()> {
let mut state = ReplState::default(); let mut state = ReplState::default();
let mut line = String::new(); let mut line = String::new();
@ -180,7 +213,7 @@ fn run_repl<R: BufRead, W: Write>(
Ok(()) Ok(())
} }
fn run_interactive_repl(server: &mut Server) -> Result<(), Box<dyn std::error::Error>> { fn run_interactive_repl(server: &mut dyn Connection) -> Result<(), Box<dyn std::error::Error>> {
let mut editor = Editor::<ReplHelper, DefaultHistory>::new()?; let mut editor = Editor::<ReplHelper, DefaultHistory>::new()?;
editor.set_helper(Some(ReplHelper::default())); editor.set_helper(Some(ReplHelper::default()));
let mut state = ReplState::default(); let mut state = ReplState::default();
@ -220,7 +253,7 @@ fn run_interactive_repl(server: &mut Server) -> Result<(), Box<dyn std::error::E
fn execute_line<W: Write>( fn execute_line<W: Write>(
line: &str, line: &str,
output: &mut W, output: &mut W,
server: &mut Server, server: &mut dyn Connection,
state: &mut ReplState, state: &mut ReplState,
) -> io::Result<bool> { ) -> io::Result<bool> {
let words: Vec<_> = line.split_whitespace().collect(); let words: Vec<_> = line.split_whitespace().collect();
@ -363,7 +396,7 @@ fn execute_line<W: Write>(
fn lifecycle_command<W: Write>( fn lifecycle_command<W: Write>(
output: &mut W, output: &mut W,
args: &[&str], args: &[&str],
server: &mut Server, server: &mut dyn Connection,
state: &ReplState, state: &ReplState,
operation: impl FnOnce(String) -> Command, operation: impl FnOnce(String) -> Command,
) -> io::Result<()> { ) -> io::Result<()> {
@ -377,7 +410,11 @@ fn lifecycle_command<W: Write>(
} }
} }
fn spawn_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server) -> io::Result<()> { fn spawn_command<W: Write>(
output: &mut W,
args: &[&str],
server: &mut dyn Connection,
) -> io::Result<()> {
let [behavior_name, initial_state] = args else { let [behavior_name, initial_state] = args else {
return writeln!( return writeln!(
output, output,
@ -393,7 +430,7 @@ fn spawn_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server) -
fn spawn_actor( fn spawn_actor(
behavior_name: &str, behavior_name: &str,
initial_state: &str, initial_state: &str,
server: &mut Server, server: &mut dyn Connection,
) -> Result<String, String> { ) -> Result<String, String> {
let state = initial_state let state = initial_state
.parse() .parse()
@ -437,7 +474,11 @@ fn is_binding_name(name: &str) -> bool {
&& characters.all(|character| character == '_' || character.is_ascii_alphanumeric()) && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
} }
fn publish_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server) -> io::Result<()> { fn publish_command<W: Write>(
output: &mut W,
args: &[&str],
server: &mut dyn Connection,
) -> io::Result<()> {
let [name, expected_revision, source_path] = args else { let [name, expected_revision, source_path] = args else {
return writeln!( return writeln!(
output, output,
@ -477,7 +518,11 @@ fn publish_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server)
) )
} }
fn history_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server) -> io::Result<()> { fn history_command<W: Write>(
output: &mut W,
args: &[&str],
server: &mut dyn Connection,
) -> io::Result<()> {
let [name] = args else { let [name] = args else {
return writeln!(output, "error: usage: history <behavior-name>"); return writeln!(output, "error: usage: history <behavior-name>");
}; };
@ -509,7 +554,7 @@ fn history_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server)
fn rollback_command<W: Write>( fn rollback_command<W: Write>(
output: &mut W, output: &mut W,
args: &[&str], args: &[&str],
server: &mut Server, server: &mut dyn Connection,
) -> io::Result<()> { ) -> io::Result<()> {
let [name, expected_revision, target_revision] = args else { let [name, expected_revision, target_revision] = args else {
return writeln!( return writeln!(
@ -546,7 +591,7 @@ fn rollback_command<W: Write>(
fn install_command<W: Write>( fn install_command<W: Write>(
output: &mut W, output: &mut W,
args: &[&str], args: &[&str],
server: &mut Server, server: &mut dyn Connection,
state: &ReplState, state: &ReplState,
) -> io::Result<()> { ) -> io::Result<()> {
let [actor_arg, name] = args else { let [actor_arg, name] = args else {
@ -588,13 +633,8 @@ fn install_command<W: Write>(
} }
} }
fn dispatch(server: &mut Server, command: Command) -> Result<Reply, String> { fn dispatch(server: &mut dyn Connection, command: Command) -> Result<Reply, String> {
let response: Response = server.dispatch(Request { server.dispatch(command)
version: PROTOCOL_VERSION,
request_id: "runa-cli".to_owned(),
command,
});
response.result.map_err(|error| error.message)
} }
fn unexpected_reply<W: Write>(output: &mut W, reply: Reply) -> io::Result<()> { fn unexpected_reply<W: Write>(output: &mut W, reply: Reply) -> io::Result<()> {
@ -675,6 +715,68 @@ fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn connection_arguments_require_an_explicit_socket_address() {
assert_eq!(
parse_address(&[]).unwrap(),
"127.0.0.1:3737".parse::<SocketAddr>().unwrap()
);
assert_eq!(
parse_address(&["--connect".to_owned(), "[::1]:4000".to_owned()]).unwrap(),
"[::1]:4000".parse::<SocketAddr>().unwrap()
);
assert!(parse_address(&["--connect".to_owned()]).is_err());
assert!(parse_address(&["--connect".to_owned(), "invalid".to_owned()]).is_err());
}
#[test]
fn tcp_repl_reconnects_to_the_same_image() -> Result<(), Box<dyn std::error::Error>> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let address = listener.local_addr()?;
let worker = std::thread::spawn(move || {
let mut image = server();
for _ in 0..2 {
let (stream, _) = listener.accept().unwrap();
runa_server::serve_json_lines(
&mut image,
io::BufReader::new(stream.try_clone().unwrap()),
stream,
)
.unwrap();
}
});
let mut client = TcpClient::connect(address)?;
let mut output = Vec::new();
run_repl(
io::Cursor::new("let actor = spawn counter 5\nsend actor 0 7\ntick 100\nquit\n"),
&mut output,
&mut client,
)?;
let output = String::from_utf8(output)?;
let actor_ref = output
.lines()
.find_map(|line| line.strip_prefix("runa> actor = @"))
.ok_or("spawn did not return an actor reference")?;
assert_eq!(actor_ref.len(), 21);
drop(client);
let mut client = TcpClient::connect(address)?;
let mut output = Vec::new();
run_repl(
io::Cursor::new(format!("actors\ninspect @{actor_ref}\nquit\n")),
&mut output,
&mut client,
)?;
let output = String::from_utf8(output)?;
assert!(output.contains(actor_ref));
assert!(output.contains("state: 12"));
assert!(!output.contains("error:"));
drop(client);
worker.join().expect("TCP image worker completed");
Ok(())
}
fn server() -> Server { fn server() -> Server {
let mut server = Server::new(); let mut server = Server::new();
dispatch( dispatch(

View File

@ -452,9 +452,11 @@ inspection remain future work.
The first Phase 5 increments are a trusted in-process application API used by The first Phase 5 increments are a trusted in-process application API used by
the CLI, a transport-neutral versioned protocol contract, and a dispatcher the CLI, a transport-neutral versioned protocol contract, and a dispatcher
exposed through bounded local JSON-lines stdio. The interactive CLI uses the exposed through bounded local JSON-lines stdio. The interactive CLI now uses
same protocol commands and dispatcher in-process. `runa-client` launches the `runa-client` to connect over loopback TCP to a server-owned image, rather than
stdio server for external local tools. The server also provides an embedding a runtime. Multiple clients share actors and behavior revisions;
client disconnect/reconnect leaves the running image intact. `runa-client`
also launches the stdio server for external local tools. The server provides an
unauthenticated TCP JSON-lines endpoint restricted to loopback for local VS unauthenticated TCP JSON-lines endpoint restricted to loopback for local VS
Code development. Remote access will require authentication and Code development. Remote access will require authentication and
authorization; neither network transport nor client location changes the authorization; neither network transport nor client location changes the

View File

@ -62,7 +62,18 @@ response queued per client. Every local client can use the full development
API; loopback binding is not authentication or authorization. Remote access API; loopback binding is not authentication or authorization. Remote access
must not be enabled until appropriate authentication and authorization exist. must not be enabled until appropriate authentication and authorization exist.
The `runa-client` crate launches the stdio server locally and validates The `runa-client` crate launches the stdio server locally and validates
response protocol versions and request IDs. response protocol versions and request IDs. Its `TcpClient` also connects to
loopback endpoints using the same bounded framing and correlation checks.
Connection establishment has a 5-second timeout; reads and writes have
30-second timeouts. A failed TCP request invalidates that client connection.
No automatic retry is performed: loss of a response does not imply that the
operation failed to commit.
The interactive REPL uses TCP and never owns the image. Disconnecting clients
does not stop actors or reset behavior revisions; reconnecting clients can
retrieve them through `list_actors` and `get_behaviors`. The TCP executable
publishes the bundled `counter` behavior on image startup, not on connection.
The stdio executable continues to start with an empty image.
The current validation checks the protocol version, request ID, actor-reference The current validation checks the protocol version, request ID, actor-reference
shape, positive revisions where required, non-empty behavior names, mailbox shape, positive revisions where required, non-empty behavior names, mailbox

View File

@ -1,12 +1,18 @@
# Phase 1 actor prototype # Phase 1 actor prototype
Start the interactive, in-memory runtime session with: Start the server-owned, in-memory image:
```sh ```sh
mise run run mise run serve-network
``` ```
The executable publishes [counter.rn](../runa-std/actors/counter.rn) as the Then connect from another terminal with `cargo run -p runa`, or use
`cargo run -p runa -- --connect <loopback-address>` for a custom listener.
Quitting the REPL leaves the image running. Reconnecting clients can list and
inspect the same actors; local bindings and history do not survive a REPL
restart. Server exit still loses the entire image.
The TCP server publishes [counter.rn](../runa-std/actors/counter.rn) as the
`counter` behavior. Commands include `spawn`, `send`, `tick`, `inspect`, `actors`, `counter` behavior. Commands include `spawn`, `send`, `tick`, `inspect`, `actors`,
`events`, `stop`, `restart`, `pause`, `resume`, `cancel`, `behaviors`, `events`, `stop`, `restart`, `pause`, `resume`, `cancel`, `behaviors`,
`publish`, `history`, `rollback`, and `install`; enter `help` to see usage. `publish`, `history`, `rollback`, and `install`; enter `help` to see usage.
@ -16,8 +22,8 @@ current revision and a target historical revision. `install` selects a named
behavior's current implementation and fails if the actor is not at an idle behavior's current implementation and fails if the actor is not at an idle
handler boundary. `spawn` requires a behavior name and initial state, in that handler boundary. `spawn` requires a behavior name and initial state, in that
order. All commands operate on the same process-local runtime and code store. order. All commands operate on the same process-local runtime and code store.
The CLI uses the versioned protocol dispatcher, also available over stdio or The CLI sends versioned protocol requests to the loopback-only TCP listener.
the loopback-only TCP listener for local development. The separate stdio endpoint starts an empty image for child-process clients.
Example: Example:
@ -57,8 +63,9 @@ instructions.
- `runa-server` dispatches those requests through `runa-image-api` and maps - `runa-server` dispatches those requests through `runa-image-api` and maps
results to protocol replies; its executable accepts newline-delimited JSON results to protocol replies; its executable accepts newline-delimited JSON
over stdin/stdout or TCP bound to loopback, with a 1 MiB request-line limit. over stdin/stdout or TCP bound to loopback, with a 1 MiB request-line limit.
- `runa` provides an interactive CLI client using protocol requests through the - `runa-client` provides correlated, bounded stdio and loopback TCP clients.
dispatcher. - `runa` provides an interactive CLI using the TCP client, without an embedded
runtime. Connection errors are explicit; requests are never auto-retried.
The image API is process-local and intended for trusted callers. It has no The image API is process-local and intended for trusted callers. It has no
transport, authentication, or authorization layer; do not expose it directly transport, authentication, or authorization layer; do not expose it directly