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:
parent
4f6e7ccfa2
commit
350ddba0ce
|
|
@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### 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,
|
||||
wake-driven resumption, and cancellation tests.
|
||||
- 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
|
||||
|
||||
- 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
|
||||
of calling the image API directly.
|
||||
- Protocol version 2 replaces numeric actor IDs with NanoID actor references
|
||||
|
|
|
|||
|
|
@ -551,6 +551,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
|||
name = "runa"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"runa-client",
|
||||
"runa-protocol",
|
||||
"runa-server",
|
||||
"rustyline",
|
||||
|
|
|
|||
35
README.md
35
README.md
|
|
@ -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
|
||||
authentication is implemented. The dispatcher is available through local
|
||||
JSON-lines stdio and an unauthenticated TCP listener restricted to localhost;
|
||||
both use a bounded request size. The interactive CLI now uses the versioned
|
||||
protocol and dispatcher rather than calling the image API directly.
|
||||
both use a bounded request size. The interactive CLI connects to the localhost
|
||||
server over TCP, so multiple clients operate on the same server-owned image.
|
||||
|
||||
The prototype uses worker-local invocation stacks and cooperative instruction
|
||||
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.
|
||||
|
||||
Run the current executable from the repository root:
|
||||
Start the live image from the repository root:
|
||||
|
||||
```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
|
||||
one JSON request per line on stdin; responses are written to stdout. Request
|
||||
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
|
||||
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
|
||||
protocol requests with request/response correlation. The VS Code extension is
|
||||
The `runa-client` crate can launch the stdio process or connect over loopback
|
||||
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
|
||||
the image regardless of how clients connect. Remote network access requires
|
||||
authentication and authorization, which are not implemented.
|
||||
|
||||
The CLI opens an interactive runtime session with the `counter` behavior
|
||||
published:
|
||||
The TCP server publishes the bundled `counter` behavior once at startup;
|
||||
connecting a client does not publish or reset behavior revisions:
|
||||
|
||||
```text
|
||||
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-protocol/ # Versioned transport-neutral wire DTOs
|
||||
│ ├── 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/
|
||||
│ ├── architecture.md
|
||||
│ ├── protocol.md
|
||||
|
|
|
|||
|
|
@ -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::fmt;
|
||||
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::time::Duration;
|
||||
|
||||
use runa_protocol::{Command as ProtocolCommand, PROTOCOL_VERSION, Request, Response};
|
||||
|
||||
|
|
@ -20,6 +22,51 @@ pub struct ProtocolClient<R, W> {
|
|||
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)]
|
||||
pub enum ClientError {
|
||||
Io(io::Error),
|
||||
|
|
@ -34,7 +81,7 @@ pub enum ClientError {
|
|||
impl fmt::Display for ClientError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
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::RequestIdsExhausted => write!(formatter, "protocol request IDs exhausted"),
|
||||
Self::RequestTooLarge => write!(
|
||||
|
|
@ -162,7 +209,7 @@ impl<R: BufRead, W: Write> ProtocolClient<R, W> {
|
|||
if !has_line {
|
||||
return Err(ClientError::Io(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"server closed stdout before responding",
|
||||
"server closed the connection before responding",
|
||||
)));
|
||||
}
|
||||
let response: Response = serde_json::from_slice(&line)?;
|
||||
|
|
@ -220,6 +267,35 @@ mod tests {
|
|||
use runa_protocol::{ProtocolError, Reply};
|
||||
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> {
|
||||
let response = Response {
|
||||
version: PROTOCOL_VERSION,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,26 @@ use std::io;
|
|||
use std::net::SocketAddr;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use runa_protocol::{Command, PROTOCOL_VERSION, Request};
|
||||
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 {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
|
|
@ -37,7 +55,7 @@ fn run() -> io::Result<()> {
|
|||
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(
|
||||
io::ErrorKind::InvalidInput,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::net::{Shutdown, SocketAddr, TcpStream};
|
|||
use std::process::{Child, Command as ProcessCommand, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use runa_client::LocalClient;
|
||||
use runa_client::{LocalClient, TcpClient};
|
||||
use runa_protocol::{Command, MessageDto, PROTOCOL_VERSION, Reply, Request, Response};
|
||||
|
||||
struct ServerProcess(Child);
|
||||
|
|
@ -104,4 +104,70 @@ fn tcp_server_accepts_local_protocol_requests() {
|
|||
let response: Response = serde_json::from_str(&response_line).unwrap();
|
||||
assert_eq!(response.request_id, "tcp-process-test");
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ version = "0.0.0"
|
|||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
runa-client = { path = "../runa-client" }
|
||||
runa-protocol = { path = "../runa-protocol" }
|
||||
runa-server = { path = "../runa-server" }
|
||||
rustyline = "15.0"
|
||||
|
||||
[dev-dependencies]
|
||||
runa-server = { path = "../runa-server" }
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ use std::collections::BTreeMap;
|
|||
#[cfg(test)]
|
||||
use std::io::BufRead;
|
||||
use std::io::{self, Write};
|
||||
use std::net::SocketAddr;
|
||||
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 rustyline::completion::{Completer, Pair};
|
||||
use rustyline::error::ReadlineError;
|
||||
|
|
@ -16,8 +19,38 @@ use rustyline::{Context, Editor, Helper};
|
|||
|
||||
const MAILBOX_CAPACITY: usize = 64;
|
||||
const DEFAULT_TICK_BUDGET: usize = 32;
|
||||
#[cfg(test)]
|
||||
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)]
|
||||
struct ReplState {
|
||||
bindings: BTreeMap<String, String>,
|
||||
|
|
@ -134,34 +167,34 @@ impl Validator for ReplHelper {}
|
|||
impl Helper for ReplHelper {}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut server = Server::new();
|
||||
let behavior = match dispatch(
|
||||
&mut server,
|
||||
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()),
|
||||
};
|
||||
let address = parse_address(&std::env::args().skip(1).collect::<Vec<_>>())?;
|
||||
let mut server = TcpClient::connect(address).map_err(|error| {
|
||||
format!("cannot connect to {address}: {error}; start `mise run serve-network` first")
|
||||
})?;
|
||||
|
||||
println!("Runa — Rune Actors");
|
||||
println!(
|
||||
"Loaded behavior `{}` revision {}.",
|
||||
behavior.name, behavior.revision
|
||||
);
|
||||
println!("Connected to {address}. The image remains running when you quit.");
|
||||
println!("Type `help` for commands. Use Up/Down for history and Tab for completion.");
|
||||
run_interactive_repl(&mut server)?;
|
||||
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)]
|
||||
fn run_repl<R: BufRead, W: Write>(
|
||||
mut input: R,
|
||||
mut output: W,
|
||||
server: &mut Server,
|
||||
server: &mut dyn Connection,
|
||||
) -> io::Result<()> {
|
||||
let mut state = ReplState::default();
|
||||
let mut line = String::new();
|
||||
|
|
@ -180,7 +213,7 @@ fn run_repl<R: BufRead, W: Write>(
|
|||
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()?;
|
||||
editor.set_helper(Some(ReplHelper::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>(
|
||||
line: &str,
|
||||
output: &mut W,
|
||||
server: &mut Server,
|
||||
server: &mut dyn Connection,
|
||||
state: &mut ReplState,
|
||||
) -> io::Result<bool> {
|
||||
let words: Vec<_> = line.split_whitespace().collect();
|
||||
|
|
@ -363,7 +396,7 @@ fn execute_line<W: Write>(
|
|||
fn lifecycle_command<W: Write>(
|
||||
output: &mut W,
|
||||
args: &[&str],
|
||||
server: &mut Server,
|
||||
server: &mut dyn Connection,
|
||||
state: &ReplState,
|
||||
operation: impl FnOnce(String) -> Command,
|
||||
) -> 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 {
|
||||
return writeln!(
|
||||
output,
|
||||
|
|
@ -393,7 +430,7 @@ fn spawn_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server) -
|
|||
fn spawn_actor(
|
||||
behavior_name: &str,
|
||||
initial_state: &str,
|
||||
server: &mut Server,
|
||||
server: &mut dyn Connection,
|
||||
) -> Result<String, String> {
|
||||
let state = initial_state
|
||||
.parse()
|
||||
|
|
@ -437,7 +474,11 @@ fn is_binding_name(name: &str) -> bool {
|
|||
&& 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 {
|
||||
return writeln!(
|
||||
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 {
|
||||
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>(
|
||||
output: &mut W,
|
||||
args: &[&str],
|
||||
server: &mut Server,
|
||||
server: &mut dyn Connection,
|
||||
) -> io::Result<()> {
|
||||
let [name, expected_revision, target_revision] = args else {
|
||||
return writeln!(
|
||||
|
|
@ -546,7 +591,7 @@ fn rollback_command<W: Write>(
|
|||
fn install_command<W: Write>(
|
||||
output: &mut W,
|
||||
args: &[&str],
|
||||
server: &mut Server,
|
||||
server: &mut dyn Connection,
|
||||
state: &ReplState,
|
||||
) -> io::Result<()> {
|
||||
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> {
|
||||
let response: Response = server.dispatch(Request {
|
||||
version: PROTOCOL_VERSION,
|
||||
request_id: "runa-cli".to_owned(),
|
||||
command,
|
||||
});
|
||||
response.result.map_err(|error| error.message)
|
||||
fn dispatch(server: &mut dyn Connection, command: Command) -> Result<Reply, String> {
|
||||
server.dispatch(command)
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
let mut server = Server::new();
|
||||
dispatch(
|
||||
|
|
|
|||
|
|
@ -452,9 +452,11 @@ inspection remain future work.
|
|||
|
||||
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
|
||||
exposed through bounded local JSON-lines stdio. The interactive CLI uses the
|
||||
same protocol commands and dispatcher in-process. `runa-client` launches the
|
||||
stdio server for external local tools. The server also provides an
|
||||
exposed through bounded local JSON-lines stdio. The interactive CLI now uses
|
||||
`runa-client` to connect over loopback TCP to a server-owned image, rather than
|
||||
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
|
||||
Code development. Remote access will require authentication and
|
||||
authorization; neither network transport nor client location changes the
|
||||
|
|
|
|||
|
|
@ -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
|
||||
must not be enabled until appropriate authentication and authorization exist.
|
||||
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
|
||||
shape, positive revisions where required, non-empty behavior names, mailbox
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
# Phase 1 actor prototype
|
||||
|
||||
Start the interactive, in-memory runtime session with:
|
||||
Start the server-owned, in-memory image:
|
||||
|
||||
```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`,
|
||||
`events`, `stop`, `restart`, `pause`, `resume`, `cancel`, `behaviors`,
|
||||
`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
|
||||
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.
|
||||
The CLI uses the versioned protocol dispatcher, also available over stdio or
|
||||
the loopback-only TCP listener for local development.
|
||||
The CLI sends versioned protocol requests to the loopback-only TCP listener.
|
||||
The separate stdio endpoint starts an empty image for child-process clients.
|
||||
|
||||
Example:
|
||||
|
||||
|
|
@ -57,8 +63,9 @@ instructions.
|
|||
- `runa-server` dispatches those requests through `runa-image-api` and maps
|
||||
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.
|
||||
- `runa` provides an interactive CLI client using protocol requests through the
|
||||
dispatcher.
|
||||
- `runa-client` provides correlated, bounded stdio and loopback TCP clients.
|
||||
- `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
|
||||
transport, authentication, or authorization layer; do not expose it directly
|
||||
|
|
|
|||
Loading…
Reference in New Issue