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
|
### 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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue