feat: add localhost TCP protocol listener
Expose the existing versioned JSON-lines dispatcher over TCP, with an enforced IPv4/IPv6 loopback-only bind, bounded nonblocking client handling, request and response size limits, and a shared single-threaded image. Add a serve-network mise task, subprocess coverage, and document the unauthenticated trusted-local-development boundary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
e5d839f419
commit
d96d603286
|
|
@ -41,6 +41,8 @@ All notable changes to this project will be documented in this file.
|
||||||
request limit and explicit malformed-input failures.
|
request limit and explicit malformed-input failures.
|
||||||
- Reusable local child-process client for issuing correlated protocol requests
|
- Reusable local child-process client for issuing correlated protocol requests
|
||||||
and shutting down the server process cleanly.
|
and shutting down the server process cleanly.
|
||||||
|
- Loopback-only TCP JSON-lines endpoint for local development clients, sharing
|
||||||
|
the same live image as stdio and limiting concurrent clients.
|
||||||
- Execution-model decision and Phase 1 runtime documentation.
|
- Execution-model decision and Phase 1 runtime documentation.
|
||||||
- Lifecycle tests proving failed-message retry from committed state and
|
- Lifecycle tests proving failed-message retry from committed state and
|
||||||
stop/restart preservation of reserved-first FIFO order.
|
stop/restart preservation of reserved-first FIFO order.
|
||||||
|
|
|
||||||
30
README.md
30
README.md
|
|
@ -13,16 +13,16 @@ experiment using Rune 0.14.2: script execution, native calls, async suspension,
|
||||||
instruction budgets, stepping, and compiled-code sharing. It also runs a minimal
|
instruction budgets, stepping, and compiled-code sharing. It also runs a minimal
|
||||||
single-worker actor demonstration with bounded mailboxes and explicit message
|
single-worker actor demonstration with bounded mailboxes and explicit message
|
||||||
effects. Source and compiled implementation identities are BLAKE3 content
|
effects. Source and compiled implementation identities are BLAKE3 content
|
||||||
addresses with an in-memory compile cache. Persistence, canonicalization, the
|
addresses with an in-memory compile cache. Persistence, canonicalization,
|
||||||
network development API, graphical world, and VS Code extension are not
|
remote network access, graphical world, and VS Code extension are not
|
||||||
implemented yet. The code library now supports an in-memory versioned behavior
|
implemented yet. The code library now supports an in-memory versioned behavior
|
||||||
registry, and the CLI exposes publishing, history, rollback, safe-boundary
|
registry, and the CLI exposes publishing, history, rollback, safe-boundary
|
||||||
installation, pause/resume, and cancellation through a trusted in-process image
|
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
|
||||||
network listener or authentication is implemented. The dispatcher is available
|
authentication is implemented. The dispatcher is available through local
|
||||||
through a local JSON-lines stdio process, with a bounded request size. The
|
JSON-lines stdio and an unauthenticated TCP listener restricted to localhost;
|
||||||
interactive CLI now uses the versioned protocol and dispatcher rather than
|
both use a bounded request size. The interactive CLI now uses the versioned
|
||||||
calling the image API directly.
|
protocol and dispatcher rather than calling the image API directly.
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -53,13 +53,19 @@ mise run run
|
||||||
|
|
||||||
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. This is not a network server and is intended for a
|
lines are limited to 1 MiB.
|
||||||
trusted parent process.
|
|
||||||
|
|
||||||
The `runa-client` crate can launch that process and exchange typed protocol
|
For a TCP endpoint, run `mise run serve-network`. It listens on
|
||||||
requests with request/response correlation. The VS Code extension is intended
|
`127.0.0.1:3737` and accepts the same JSON-lines protocol, with up to 16
|
||||||
to use an authenticated network connection to this same live image; actors
|
simultaneous clients sharing one live image. The listener rejects non-loopback
|
||||||
remain in the image regardless of how clients connect.
|
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
|
||||||
|
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 using the default counter behavior:
|
The CLI opens an interactive runtime session using the default counter behavior:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
//! Transport-neutral dispatch from protocol requests to image API operations.
|
//! Transport-neutral dispatch from protocol requests to image API operations.
|
||||||
|
|
||||||
use std::io::{self, BufRead, Write};
|
use std::io::{self, BufRead, Read, Write};
|
||||||
|
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use runa_actor::{ActorId, Event, Message, Status};
|
use runa_actor::{ActorId, Event, Message, Status};
|
||||||
use runa_image_api::ImageApi;
|
use runa_image_api::ImageApi;
|
||||||
|
|
@ -12,6 +15,7 @@ use runa_protocol::{
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const MAX_REQUEST_LINE_BYTES: usize = 1024 * 1024;
|
pub const MAX_REQUEST_LINE_BYTES: usize = 1024 * 1024;
|
||||||
|
const MAX_NETWORK_CLIENTS: usize = 16;
|
||||||
|
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
image: ImageApi,
|
image: ImageApi,
|
||||||
|
|
@ -23,18 +27,204 @@ pub struct Server {
|
||||||
/// oversized input terminates the stream with `InvalidData`.
|
/// oversized input terminates the stream with `InvalidData`.
|
||||||
pub fn serve_json_lines<R: BufRead, W: Write>(
|
pub fn serve_json_lines<R: BufRead, W: Write>(
|
||||||
server: &mut Server,
|
server: &mut Server,
|
||||||
mut reader: R,
|
reader: R,
|
||||||
mut writer: W,
|
writer: W,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
|
serve_json_lines_with(reader, writer, |request| Ok(server.dispatch(request)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind the protocol listener to an IPv4 or IPv6 loopback address only.
|
||||||
|
pub fn bind_loopback_listener(address: SocketAddr) -> io::Result<TcpListener> {
|
||||||
|
if !address.ip().is_loopback() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"network listener address must be a loopback address",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
TcpListener::bind(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve JSON-lines requests from loopback clients using one shared image.
|
||||||
|
///
|
||||||
|
/// The nonblocking event loop keeps the non-`Send` Rune runtime on one thread.
|
||||||
|
/// At most `MAX_NETWORK_CLIENTS` are active.
|
||||||
|
pub fn serve_tcp(listener: TcpListener, mut server: Server) -> io::Result<()> {
|
||||||
|
let address = listener.local_addr()?;
|
||||||
|
if !address.ip().is_loopback() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"network listener must be bound to a loopback address",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
listener.set_nonblocking(true)?;
|
||||||
|
eprintln!("runa-server: listening on {address}");
|
||||||
|
let mut clients = Vec::new();
|
||||||
|
loop {
|
||||||
|
loop {
|
||||||
|
match listener.accept() {
|
||||||
|
Ok((stream, peer_address)) => {
|
||||||
|
if clients.len() >= MAX_NETWORK_CLIENTS {
|
||||||
|
eprintln!("runa-server: rejecting {peer_address}; client limit reached");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stream.set_nonblocking(true)?;
|
||||||
|
clients.push(NetworkClient::new(stream, peer_address));
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut index = 0;
|
||||||
|
while index < clients.len() {
|
||||||
|
match clients[index].poll(&mut server) {
|
||||||
|
Ok(true) => index += 1,
|
||||||
|
Ok(false) => {
|
||||||
|
clients.swap_remove(index);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("runa-server: client {}: {error}", clients[index].address);
|
||||||
|
clients.swap_remove(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NetworkClient {
|
||||||
|
stream: TcpStream,
|
||||||
|
address: SocketAddr,
|
||||||
|
input: Vec<u8>,
|
||||||
|
output: Vec<u8>,
|
||||||
|
output_offset: usize,
|
||||||
|
read_closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkClient {
|
||||||
|
fn new(stream: TcpStream, address: SocketAddr) -> Self {
|
||||||
|
Self {
|
||||||
|
stream,
|
||||||
|
address,
|
||||||
|
input: Vec::new(),
|
||||||
|
output: Vec::new(),
|
||||||
|
output_offset: 0,
|
||||||
|
read_closed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll(&mut self, server: &mut Server) -> io::Result<bool> {
|
||||||
|
if self.output_offset == self.output.len() {
|
||||||
|
self.output.clear();
|
||||||
|
self.output_offset = 0;
|
||||||
|
self.process_pending_requests(server, self.read_closed)?;
|
||||||
|
|
||||||
|
if self.output.is_empty() && !self.read_closed {
|
||||||
|
let mut buffer = [0; 8192];
|
||||||
|
match self.stream.read(&mut buffer) {
|
||||||
|
Ok(0) => {
|
||||||
|
self.read_closed = true;
|
||||||
|
self.process_pending_requests(server, true)?;
|
||||||
|
}
|
||||||
|
Ok(length) => {
|
||||||
|
self.input.extend_from_slice(&buffer[..length]);
|
||||||
|
self.process_pending_requests(server, false)?;
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.output_offset < self.output.len() {
|
||||||
|
match self.stream.write(&self.output[self.output_offset..]) {
|
||||||
|
Ok(0) => {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::WriteZero,
|
||||||
|
"failed to write protocol response",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(length) => {
|
||||||
|
self.output_offset += length;
|
||||||
|
if self.output_offset == self.output.len() {
|
||||||
|
self.output.clear();
|
||||||
|
self.output_offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(!self.read_closed || !self.input.is_empty() || self.output_offset < self.output.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_pending_requests(
|
||||||
|
&mut self,
|
||||||
|
server: &mut Server,
|
||||||
|
final_line: bool,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let Some(line_end) = self.input.iter().position(|byte| *byte == b'\n') else {
|
||||||
|
if self.input.len() > MAX_REQUEST_LINE_BYTES {
|
||||||
|
return Err(invalid_data(format!(
|
||||||
|
"request line exceeds {MAX_REQUEST_LINE_BYTES} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !final_line || self.input.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let line = self.input.clone();
|
||||||
|
self.input.clear();
|
||||||
|
self.dispatch_line(server, &line)?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if line_end > MAX_REQUEST_LINE_BYTES {
|
||||||
|
return Err(invalid_data(format!(
|
||||||
|
"request line exceeds {MAX_REQUEST_LINE_BYTES} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let line = self.input[..line_end].to_vec();
|
||||||
|
self.input.drain(..=line_end);
|
||||||
|
self.dispatch_line(server, &line)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_line(&mut self, server: &mut Server, line: &[u8]) -> io::Result<()> {
|
||||||
|
let request: Request = serde_json::from_slice(line)
|
||||||
|
.map_err(|error| invalid_data(format!("invalid protocol request JSON: {error}")))?;
|
||||||
|
let response = server.dispatch(request);
|
||||||
|
let mut encoded = serde_json::to_vec(&response).map_err(io::Error::other)?;
|
||||||
|
if encoded.len() > MAX_REQUEST_LINE_BYTES {
|
||||||
|
return Err(invalid_data(format!(
|
||||||
|
"response exceeds {MAX_REQUEST_LINE_BYTES} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
encoded.push(b'\n');
|
||||||
|
self.output.extend_from_slice(&encoded);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_data(message: String) -> io::Error {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_json_lines_with<R, W, F>(mut reader: R, mut writer: W, mut dispatch: F) -> io::Result<()>
|
||||||
|
where
|
||||||
|
R: BufRead,
|
||||||
|
W: Write,
|
||||||
|
F: FnMut(Request) -> io::Result<Response>,
|
||||||
|
{
|
||||||
let mut line = Vec::new();
|
let mut line = Vec::new();
|
||||||
while read_bounded_line(&mut reader, &mut line)? {
|
while read_bounded_line(&mut reader, &mut line)? {
|
||||||
let request = serde_json::from_slice(&line).map_err(|error| {
|
let request: Request = serde_json::from_slice(&line)
|
||||||
io::Error::new(
|
.map_err(|error| invalid_data(format!("invalid protocol request JSON: {error}")))?;
|
||||||
io::ErrorKind::InvalidData,
|
let response = dispatch(request)?;
|
||||||
format!("invalid protocol request JSON: {error}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let response = server.dispatch(request);
|
|
||||||
serde_json::to_writer(&mut writer, &response).map_err(io::Error::other)?;
|
serde_json::to_writer(&mut writer, &response).map_err(io::Error::other)?;
|
||||||
writer.write_all(b"\n")?;
|
writer.write_all(b"\n")?;
|
||||||
writer.flush()?;
|
writer.flush()?;
|
||||||
|
|
@ -347,7 +537,8 @@ fn invalid_request(message: impl Into<String>) -> ProtocolError {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use runa_protocol::{Command, PROTOCOL_VERSION};
|
use runa_protocol::{Command, PROTOCOL_VERSION};
|
||||||
use std::io::Cursor;
|
use std::io::{BufRead, BufReader, Cursor};
|
||||||
|
use std::net::Shutdown;
|
||||||
|
|
||||||
fn request(command: Command) -> Request {
|
fn request(command: Command) -> Request {
|
||||||
Request {
|
Request {
|
||||||
|
|
@ -545,4 +736,50 @@ mod tests {
|
||||||
let error = serve_json_lines(&mut server, Cursor::new(oversized), Vec::new()).unwrap_err();
|
let error = serve_json_lines(&mut server, Cursor::new(oversized), Vec::new()).unwrap_err();
|
||||||
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
|
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loopback_listener_rejects_non_loopback_addresses() {
|
||||||
|
let error = bind_loopback_listener("0.0.0.0:0".parse().unwrap()).unwrap_err();
|
||||||
|
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
||||||
|
|
||||||
|
let listener = bind_loopback_listener("127.0.0.1:0".parse().unwrap()).unwrap();
|
||||||
|
assert!(listener.local_addr().unwrap().ip().is_loopback());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tcp_transport_dispatches_requests_to_the_shared_server() {
|
||||||
|
let listener = bind_loopback_listener("127.0.0.1:0".parse().unwrap()).unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let mut client = TcpStream::connect(address).unwrap();
|
||||||
|
let (server_stream, peer_address) = listener.accept().unwrap();
|
||||||
|
let mut network_client = NetworkClient::new(server_stream, peer_address);
|
||||||
|
|
||||||
|
let first_request = request(Command::ListActors);
|
||||||
|
let mut second_request = request(Command::ListActors);
|
||||||
|
second_request.request_id = "second-request".to_owned();
|
||||||
|
writeln!(
|
||||||
|
client,
|
||||||
|
"{}\n{}",
|
||||||
|
serde_json::to_string(&first_request).unwrap(),
|
||||||
|
serde_json::to_string(&second_request).unwrap()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
client.shutdown(Shutdown::Write).unwrap();
|
||||||
|
|
||||||
|
assert!(network_client.poll(&mut Server::new()).unwrap());
|
||||||
|
let mut response_line = String::new();
|
||||||
|
let mut client_reader = BufReader::new(client);
|
||||||
|
client_reader.read_line(&mut response_line).unwrap();
|
||||||
|
let first_response: Response = serde_json::from_str(&response_line).unwrap();
|
||||||
|
assert_eq!(first_response.request_id, first_request.request_id);
|
||||||
|
|
||||||
|
assert!(network_client.poll(&mut Server::new()).unwrap());
|
||||||
|
response_line.clear();
|
||||||
|
client_reader.read_line(&mut response_line).unwrap();
|
||||||
|
let second_response: Response = serde_json::from_str(&response_line).unwrap();
|
||||||
|
assert_eq!(second_response.request_id, second_request.request_id);
|
||||||
|
assert_eq!(second_response.result, Ok(Reply::Actors(Vec::new())));
|
||||||
|
|
||||||
|
assert!(!network_client.poll(&mut Server::new()).unwrap());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use runa_server::{Server, serve_json_lines};
|
use runa_server::{Server, bind_loopback_listener, serve_json_lines, serve_tcp};
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
let stdin = io::stdin();
|
match run() {
|
||||||
let stdout = io::stdout();
|
|
||||||
match serve_json_lines(&mut Server::new(), stdin.lock(), stdout.lock()) {
|
|
||||||
Ok(()) => ExitCode::SUCCESS,
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!("runa-server: {error}");
|
eprintln!("runa-server: {error}");
|
||||||
|
|
@ -14,3 +13,41 @@ fn main() -> ExitCode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run() -> io::Result<()> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
match args.next().as_deref() {
|
||||||
|
None => serve_stdio(),
|
||||||
|
Some("--listen") => {
|
||||||
|
let address = args.next().ok_or_else(|| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"usage: runa-server [--listen <loopback-address>]",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if args.next().is_some() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"usage: runa-server [--listen <loopback-address>]",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let address = address.parse::<SocketAddr>().map_err(|error| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
format!("invalid listener address: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
serve_tcp(bind_loopback_listener(address)?, Server::new())
|
||||||
|
}
|
||||||
|
Some(_) => Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"usage: runa-server [--listen <loopback-address>]",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_stdio() -> io::Result<()> {
|
||||||
|
let stdin = io::stdin();
|
||||||
|
let stdout = io::stdout();
|
||||||
|
serve_json_lines(&mut Server::new(), stdin.lock(), stdout.lock())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
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;
|
||||||
use runa_protocol::{Command, MessageDto, Reply};
|
use runa_protocol::{Command, MessageDto, PROTOCOL_VERSION, Reply, Request, Response};
|
||||||
|
|
||||||
|
struct ServerProcess(Child);
|
||||||
|
|
||||||
|
impl Drop for ServerProcess {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.0.kill();
|
||||||
|
let _ = self.0.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_client_launches_server_and_operates_the_same_live_image() {
|
fn local_client_launches_server_and_operates_the_same_live_image() {
|
||||||
|
|
@ -46,3 +60,41 @@ fn local_client_launches_server_and_operates_the_same_live_image() {
|
||||||
));
|
));
|
||||||
assert!(client.shutdown().unwrap().success());
|
assert!(client.shutdown().unwrap().success());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tcp_server_accepts_local_protocol_requests() {
|
||||||
|
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa-server"))
|
||||||
|
.args(["--listen", "127.0.0.1:0"])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let mut server = ServerProcess(child);
|
||||||
|
let stderr = server.0.stderr.take().unwrap();
|
||||||
|
let mut stderr = BufReader::new(stderr);
|
||||||
|
let mut startup = String::new();
|
||||||
|
stderr.read_line(&mut startup).unwrap();
|
||||||
|
let address = startup
|
||||||
|
.strip_prefix("runa-server: listening on ")
|
||||||
|
.expect("server should report its bound address")
|
||||||
|
.trim()
|
||||||
|
.parse::<SocketAddr>()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut client = TcpStream::connect_timeout(&address, Duration::from_secs(2)).unwrap();
|
||||||
|
let request = Request {
|
||||||
|
version: PROTOCOL_VERSION,
|
||||||
|
request_id: "tcp-process-test".to_owned(),
|
||||||
|
command: Command::ListActors,
|
||||||
|
};
|
||||||
|
writeln!(client, "{}", serde_json::to_string(&request).unwrap()).unwrap();
|
||||||
|
client.shutdown(Shutdown::Write).unwrap();
|
||||||
|
|
||||||
|
let mut response_line = String::new();
|
||||||
|
BufReader::new(client)
|
||||||
|
.read_line(&mut response_line)
|
||||||
|
.unwrap();
|
||||||
|
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())));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
**Initial embedded language:** Rune
|
**Initial embedded language:** Rune
|
||||||
**Inspirations:** Smalltalk/Squeak/Pharo, Morphic, Erlang/OTP
|
**Inspirations:** Smalltalk/Squeak/Pharo, Morphic, Erlang/OTP
|
||||||
**Primary interfaces:** Self-contained graphical world and VS Code extension
|
**Primary interfaces:** Self-contained graphical world and VS Code extension
|
||||||
connected to the same live image through an authenticated network API
|
connected to the same live image through a network API
|
||||||
|
|
||||||
## 1. Vision
|
## 1. Vision
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ Rust supplies a minimal, reliable substrate; increasingly much of the actor fram
|
||||||
+-----------------------------------------------------------+
|
+-----------------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
The graphical world should use internal service calls to the image API. The VS Code extension connects to the same live image through a versioned network transport. CLI tools, tests, and local automation can use in-process calls or a child-process protocol over stdio. The network boundary must authenticate and authorize clients; it does not create a separate actor runtime or image.
|
The graphical world should use internal service calls to the image API. The VS Code extension connects to the same live image through a versioned network transport. CLI tools, tests, and local automation can use in-process calls or a child-process protocol over stdio. The initial TCP transport is unauthenticated and must bind only to loopback; a remote-capable network boundary must authenticate and authorize clients. Neither transport creates a separate actor runtime or image.
|
||||||
|
|
||||||
## 4. Fundamental Actor Model
|
## 4. Fundamental Actor Model
|
||||||
|
|
||||||
|
|
@ -229,11 +229,11 @@ These are desired capabilities, not a requirement to implement every endpoint im
|
||||||
|
|
||||||
### 8.1 Security
|
### 8.1 Security
|
||||||
|
|
||||||
Normal actors should not automatically receive development privileges. Arbitrary evaluation, state mutation, code replacement, and process control require explicit capabilities. The current stdio bridge is limited to a trusted parent process. Any future network bridge must bind locally by default and use a defensible authentication mechanism; never expose unauthenticated remote code execution endpoints.
|
Normal actors should not automatically receive development privileges. Arbitrary evaluation, state mutation, code replacement, and process control require explicit capabilities. The stdio bridge is intended for a trusted parent process. The initial TCP bridge accepts unauthenticated connections only on IPv4 or IPv6 loopback and grants each connection the full development API; any local process may connect. It is suitable only for trusted local development. Never bind this endpoint to a non-loopback address or expose it remotely until authentication and authorization are implemented.
|
||||||
|
|
||||||
## 9. VS Code Extension
|
## 9. VS Code Extension
|
||||||
|
|
||||||
VS Code is an external client of the same live image, not a separate runtime. It connects through an authenticated versioned network transport, initially planned as JSON-RPC over WebSocket. The extension does not host actors or create a separate image. The local stdio protocol remains useful for CLI tools, tests, and other child-process clients. Network security, connection lifecycle, and event subscription semantics must be designed before exposing the listener.
|
VS Code is an external client of the same live image, not a separate runtime. The initial development transport is versioned JSON Lines over TCP, bound to loopback and unauthenticated; the extension must connect only to a trusted local image. Remote connections remain out of scope until authentication and authorization are implemented. The extension does not host actors or create a separate image. The local stdio protocol remains useful for CLI tools, tests, and other child-process clients. Connection lifecycle and event subscription semantics still need design.
|
||||||
|
|
||||||
Required extension UX:
|
Required extension UX:
|
||||||
|
|
||||||
|
|
@ -311,7 +311,7 @@ The diagram is conceptual; Cargo dependencies should be kept minimal. A dependen
|
||||||
| `runa-runtime` | Scheduling, actor execution coordination, state commits, crash recovery, binding actor semantics to Rune execution | `runa-core`, `runa-actor`, `runa-code`, `runa-rune` |
|
| `runa-runtime` | Scheduling, actor execution coordination, state commits, crash recovery, binding actor semantics to Rune execution | `runa-core`, `runa-actor`, `runa-code`, `runa-rune` |
|
||||||
| `runa-image-api` | Trusted in-process application services for spawning, inspecting, sending, editing, and upgrading | `runa-core`, `runa-actor`, `runa-code`, `runa-runtime` |
|
| `runa-image-api` | Trusted in-process application services for spawning, inspecting, sending, editing, and upgrading | `runa-core`, `runa-actor`, `runa-code`, `runa-runtime` |
|
||||||
| `runa-protocol` | Versioned transport-neutral request/response/event DTOs and validation | `serde` |
|
| `runa-protocol` | Versioned transport-neutral request/response/event DTOs and validation | `serde` |
|
||||||
| `runa-server` | Protocol dispatch and local JSON-lines stdio transport; authenticated network transport for VS Code clients | `runa-protocol`, `runa-image-api` |
|
| `runa-server` | Protocol dispatch, JSON-lines stdio, and loopback-only TCP transport | `runa-protocol`, `runa-image-api` |
|
||||||
| `runa-client` | Local child-process client for the JSON-lines stdio protocol | `runa-protocol` |
|
| `runa-client` | Local child-process client for the JSON-lines stdio protocol | `runa-protocol` |
|
||||||
| `runa-graphics` | Native window, rendering and input mechanisms; no actor policy | `runa-core` if necessary |
|
| `runa-graphics` | Native window, rendering and input mechanisms; no actor policy | `runa-core` if necessary |
|
||||||
| `runa-world` | Morphic-style graphical composition and in-world development UI | `runa-image-api`, `runa-graphics` |
|
| `runa-world` | Morphic-style graphical composition and in-world development UI | `runa-image-api`, `runa-graphics` |
|
||||||
|
|
@ -331,14 +331,15 @@ than reaching into the code store and runtime directly. `runa-protocol` now
|
||||||
defines versioned, serializable request, response, and event DTOs without
|
defines versioned, serializable request, response, and event DTOs without
|
||||||
depending on a transport or the runtime implementation. `runa-server` now
|
depending on a transport or the runtime implementation. `runa-server` now
|
||||||
dispatches those commands through the image API and maps service results into
|
dispatches those commands through the image API and maps service results into
|
||||||
protocol replies and typed errors. It remains an in-process dispatcher; there
|
protocol replies and typed errors. The dispatcher is exposed through local
|
||||||
is no network listener, session model, authentication, or authorization. The
|
child-process JSON-lines stdio and an unauthenticated TCP listener restricted
|
||||||
dispatcher is exposed through local child-process JSON-lines stdio with a 1
|
to IPv4 or IPv6 loopback; both enforce a 1 MiB request-line limit. The TCP
|
||||||
MiB request-line limit. In-process consumers call `runa-image-api`, and
|
listener shares one image across at most 16 concurrent clients. Every local
|
||||||
|
client receives full development API access, so the endpoint is for trusted
|
||||||
|
local development only. In-process consumers call `runa-image-api`, and
|
||||||
external local tools can use `runa-client` to launch the stdio server. Actors
|
external local tools can use `runa-client` to launch the stdio server. Actors
|
||||||
do not depend on network transport to coexist in one live image; the network
|
do not depend on network transport to coexist in one live image. Remote
|
||||||
boundary exists for external clients such as VS Code. Authenticated network
|
network access will require authentication and authorization.
|
||||||
transport is a planned integration requirement, not a runtime prerequisite.
|
|
||||||
|
|
||||||
**Important:** `runa-core` must not contain schedulers, mailbox implementations, persistence engines, Rune VM types, service traits used by only one crate, generic helper collections, or GUI abstractions. Prefer a type defined in its owning crate until two independent consumers demonstrably need a stable shared representation.
|
**Important:** `runa-core` must not contain schedulers, mailbox implementations, persistence engines, Rune VM types, service traits used by only one crate, generic helper collections, or GUI abstractions. Prefer a type defined in its owning crate until two independent consumers demonstrably need a stable shared representation.
|
||||||
|
|
||||||
|
|
@ -447,9 +448,11 @@ 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 uses the
|
||||||
same protocol commands and dispatcher in-process. `runa-client` launches the
|
same protocol commands and dispatcher in-process. `runa-client` launches the
|
||||||
stdio server for external local tools. The next server increment for the VS
|
stdio server for external local tools. The server also provides an
|
||||||
Code integration is an authenticated network transport over the same API and
|
unauthenticated TCP JSON-lines endpoint restricted to loopback for local VS
|
||||||
live image; it is not needed for actors to share that image.
|
Code development. Remote access will require authentication and
|
||||||
|
authorization; neither network transport nor client location changes the
|
||||||
|
single-image actor model.
|
||||||
|
|
||||||
### Phase 6 — Graphical world
|
### Phase 6 — Graphical world
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
`runa-protocol` defines versioned request, response, and event data contracts
|
`runa-protocol` defines versioned request, response, and event data contracts
|
||||||
for clients and the `runa-server` dispatcher. It is independent of transport
|
for clients and the `runa-server` dispatcher. It is independent of transport
|
||||||
and runtime implementation. The `runa-server` executable currently accepts
|
and runtime implementation. `runa-server` accepts JSON Lines over stdio or,
|
||||||
JSON Lines over stdin and emits one response line per valid request to stdout.
|
when started with `--listen`, over TCP bound to an IPv4 or IPv6 loopback
|
||||||
It is a local child-process transport, not a network endpoint, and provides no
|
address. The TCP listener is unauthenticated and is intended only for trusted
|
||||||
authentication.
|
local development.
|
||||||
|
|
||||||
## Version 1
|
## Version 1
|
||||||
|
|
||||||
|
|
@ -46,14 +46,17 @@ rather than claiming a more specific category.
|
||||||
|
|
||||||
`get_events` drains and returns the events currently accumulated by this
|
`get_events` drains and returns the events currently accumulated by this
|
||||||
single-worker image. It is not a durable subscription or replayable event log.
|
single-worker image. It is not a durable subscription or replayable event log.
|
||||||
The stdio transport limits each request line to 1 MiB before parsing. Malformed
|
Both transports limit each request line to 1 MiB before parsing. Malformed
|
||||||
JSON or an oversized line terminates the stream with an error on stderr; no
|
JSON or an oversized line terminates that stream with an error; no protocol
|
||||||
protocol response is emitted when a request cannot be decoded. EOF cleanly
|
response is emitted when a request cannot be decoded. Stdio EOF terminates the
|
||||||
terminates the process. The `runa-client` crate launches this server locally
|
process, while TCP EOF closes only that client connection. The TCP listener
|
||||||
and validates response protocol versions and request IDs. The VS Code
|
allows up to 16 simultaneous clients and serializes each dispatch through the
|
||||||
extension is planned to use a separate authenticated network transport to
|
same image. TCP response payloads are capped at 1 MiB, with at most one
|
||||||
the same image; any such transport must impose its own message and
|
response queued per client. Every local client can use the full development
|
||||||
request-size limits before dispatch.
|
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.
|
||||||
|
|
||||||
The current validation checks the protocol version, request ID, positive
|
The current validation checks the protocol version, request ID, positive
|
||||||
actor/revision identifiers where required, non-empty behavior names, mailbox
|
actor/revision identifiers where required, non-empty behavior names, mailbox
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ run = "cargo run -p runa"
|
||||||
description = "Run the local JSON-lines protocol server"
|
description = "Run the local JSON-lines protocol server"
|
||||||
run = "cargo run -p runa-server"
|
run = "cargo run -p runa-server"
|
||||||
|
|
||||||
|
[tasks.serve-network]
|
||||||
|
description = "Run the protocol server on localhost"
|
||||||
|
run = "cargo run -p runa-server -- --listen 127.0.0.1:3737"
|
||||||
|
|
||||||
[tasks.lint]
|
[tasks.lint]
|
||||||
description = "Lint all workspace targets with Clippy"
|
description = "Lint all workspace targets with Clippy"
|
||||||
run = "cargo clippy --workspace --all-targets -- -D warnings"
|
run = "cargo clippy --workspace --all-targets -- -D warnings"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue