//! Transport-neutral dispatch from protocol requests to image API operations. use std::io::{self, BufRead, Write}; use std::net::{SocketAddr, TcpListener}; use std::num::NonZeroUsize; mod network; pub use network::serve_websocket; use runa_actor::{ActorRef, Event, Status}; use runa_image_api::ImageApi; use runa_protocol::{ ActorSnapshotDto, ActorStatusDto, BehaviorDto, BehaviorVersionDto, Command, ErrorCode, EventDto, ExecutionSnapshotDto, PROTOCOL_VERSION, ProtocolError, Reply, Request, Response, }; pub const MAX_REQUEST_LINE_BYTES: usize = 1024 * 1024; pub struct Server { image: ImageApi, } /// Serve newline-delimited protocol requests over an already-connected stream. /// /// Each valid request line receives one JSON response line. Malformed or /// oversized input terminates the stream with `InvalidData`. pub fn serve_json_lines( server: &mut Server, reader: R, writer: W, ) -> 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 { if !address.ip().is_loopback() { return Err(io::Error::new( io::ErrorKind::InvalidInput, "network listener address must be a loopback address", )); } TcpListener::bind(address) } fn invalid_data(message: String) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message) } fn serve_json_lines_with(mut reader: R, mut writer: W, mut dispatch: F) -> io::Result<()> where R: BufRead, W: Write, F: FnMut(Request) -> io::Result, { let mut line = Vec::new(); while read_bounded_line(&mut reader, &mut line)? { let request: Request = serde_json::from_slice(&line) .map_err(|error| invalid_data(format!("invalid protocol request JSON: {error}")))?; let response = dispatch(request)?; serde_json::to_writer(&mut writer, &response).map_err(io::Error::other)?; writer.write_all(b"\n")?; writer.flush()?; } Ok(()) } fn read_bounded_line(reader: &mut R, line: &mut Vec) -> io::Result { line.clear(); loop { let available = reader.fill_buf()?; if available.is_empty() { return Ok(!line.is_empty()); } let newline = available.iter().position(|byte| *byte == b'\n'); let payload_len = newline.unwrap_or(available.len()); if line.len().saturating_add(payload_len) > MAX_REQUEST_LINE_BYTES { return Err(io::Error::new( io::ErrorKind::InvalidData, format!("request line exceeds {MAX_REQUEST_LINE_BYTES} bytes"), )); } line.extend_from_slice(&available[..payload_len]); let consumed = payload_len + usize::from(newline.is_some()); reader.consume(consumed); if newline.is_some() { return Ok(true); } } } impl Default for Server { fn default() -> Self { Self::new() } } impl Server { pub fn new() -> Self { Self { image: ImageApi::new(), } } pub fn with_image_api(image: ImageApi) -> Self { Self { image } } pub fn dispatch(&mut self, request: Request) -> Response { let request_id = request.request_id.clone(); let result = request .validate() .and_then(|()| self.dispatch_command(request.command)); Response { version: PROTOCOL_VERSION, request_id, result, } } fn dispatch_command(&mut self, command: Command) -> Result { match command { Command::PublishBehavior { name, expected_revision, source, } => self .image .publish_behavior(&name, expected_revision, &source) .map(behavior_dto) .map(Reply::Behavior) .map_err(api_error), Command::RollbackBehavior { name, expected_revision, target_revision, } => self .image .rollback_behavior(&name, expected_revision, target_revision) .map(behavior_dto) .map(Reply::Behavior) .map_err(api_error), Command::GetBehavior { name } => self .image .behavior(&name) .map(behavior_dto) .map(Reply::Behavior) .ok_or_else(|| ProtocolError::new(ErrorCode::NotFound, "unknown behavior")), Command::GetBehaviors => Ok(Reply::Behaviors( self.image .behaviors() .into_iter() .map(behavior_dto) .collect(), )), Command::GetBehaviorSource { name, revision } => self .image .behavior_source(&name, revision) .map(|source| Reply::BehaviorSource { source: source.to_owned(), }) .map_err(api_error), Command::Spawn { behavior_name, state, mailbox_capacity, } => { let capacity = NonZeroUsize::new(mailbox_capacity) .ok_or_else(|| invalid_request("mailbox_capacity must be positive"))?; self.image .spawn(&behavior_name, state, capacity) .map(|actor_ref| Reply::ActorSpawned { actor_ref: actor_ref.as_str().to_owned(), }) .map_err(api_error) } Command::Send { actor_ref, message } => { let actor_ref = parse_actor_ref(&actor_ref)?; self.image .send(actor_ref, message) .map(|()| Reply::Acknowledged) .map_err(api_error) } Command::Tick { ticks, instruction_budget, } => { let budget = NonZeroUsize::new(instruction_budget) .ok_or_else(|| invalid_request("instruction_budget must be positive"))?; self.image.tick(ticks, budget); Ok(Reply::TickCompleted { ticks }) } Command::InspectActor { actor_ref } => { let actor_ref = parse_actor_ref(&actor_ref)?; self.image .inspect(actor_ref) .map(|snapshot| actor_snapshot_dto(&snapshot)) .map(Reply::Actor) .map_err(api_error) } Command::ListActors => Ok(Reply::Actors( self.image.actors().iter().map(actor_snapshot_dto).collect(), )), Command::GetEvents => Ok(Reply::Events( self.image.events().iter().map(event_dto).collect(), )), Command::StopActor { actor_ref } => { let actor_ref = parse_actor_ref(&actor_ref)?; self.image .stop(actor_ref) .map(|()| Reply::Acknowledged) .map_err(api_error) } Command::RestartActor { actor_ref } => { let actor_ref = parse_actor_ref(&actor_ref)?; self.image .restart(actor_ref) .map(|()| Reply::Acknowledged) .map_err(api_error) } Command::PauseActor { actor_ref } => { let actor_ref = parse_actor_ref(&actor_ref)?; self.image .pause(actor_ref) .map(|()| Reply::Acknowledged) .map_err(api_error) } Command::ResumeActor { actor_ref } => { let actor_ref = parse_actor_ref(&actor_ref)?; self.image .resume(actor_ref) .map(|()| Reply::Acknowledged) .map_err(api_error) } Command::CancelActor { actor_ref } => { let actor_ref = parse_actor_ref(&actor_ref)?; self.image .cancel(actor_ref) .map(|()| Reply::Acknowledged) .map_err(api_error) } Command::InstallBehavior { actor_ref, behavior_name, } => { let actor_ref = parse_actor_ref(&actor_ref)?; self.image .install(actor_ref, &behavior_name) .map(|implementation_id| Reply::BehaviorInstalled { implementation_id: implementation_id.to_string(), }) .map_err(api_error) } } } } fn behavior_dto(behavior: runa_code::BehaviorSnapshot) -> BehaviorDto { BehaviorDto { name: behavior.name, revision: behavior.revision, current_implementation_id: behavior.current.to_string(), history: behavior .history .into_iter() .map(|version| BehaviorVersionDto { revision: version.revision, implementation_id: version.implementation_id.to_string(), }) .collect(), } } fn actor_snapshot_dto(snapshot: &runa_actor::ActorSnapshot) -> ActorSnapshotDto { ActorSnapshotDto { actor_ref: snapshot.actor_ref.as_str().to_owned(), mailbox_id: snapshot.mailbox.0, implementation_id: snapshot.implementation.to_string(), incarnation: snapshot.incarnation, state: snapshot.state.clone(), status: match &snapshot.status { Status::Idle => ActorStatusDto::Idle, Status::Running => ActorStatusDto::Running, Status::Awaiting => ActorStatusDto::Awaiting, Status::Paused => ActorStatusDto::Paused, Status::Stopped => ActorStatusDto::Stopped, Status::Failed(message) => ActorStatusDto::Failed(message.clone()), }, execution: snapshot.execution.map(|execution| ExecutionSnapshotDto { vm_steps: execution.vm_steps, instruction_pointer: execution.instruction_pointer, call_depth: execution.call_depth, }), queued: snapshot.queued.clone(), reserved: snapshot.reserved.clone(), } } fn event_dto(event: &Event) -> EventDto { match event { Event::Spawned(actor_ref) => EventDto::Spawned { actor_ref: actor_ref.as_str().to_owned(), }, Event::Committed(actor_ref) => EventDto::Committed { actor_ref: actor_ref.as_str().to_owned(), }, Event::Failed(actor_ref, message) => EventDto::Failed { actor_ref: actor_ref.as_str().to_owned(), message: message.clone(), }, Event::Stopped(actor_ref) => EventDto::Stopped { actor_ref: actor_ref.as_str().to_owned(), }, Event::Restarted(actor_ref) => EventDto::Restarted { actor_ref: actor_ref.as_str().to_owned(), }, Event::Paused(actor_ref) => EventDto::Paused { actor_ref: actor_ref.as_str().to_owned(), }, Event::Resumed(actor_ref) => EventDto::Resumed { actor_ref: actor_ref.as_str().to_owned(), }, Event::InvocationCancelled(actor_ref) => EventDto::InvocationCancelled { actor_ref: actor_ref.as_str().to_owned(), }, Event::BehaviorInstalled(actor_ref, implementation_id) => EventDto::BehaviorInstalled { actor_ref: actor_ref.as_str().to_owned(), implementation_id: implementation_id.to_string(), }, Event::StateMigrated(actor_ref, previous_state, current_state) => EventDto::StateMigrated { actor_ref: actor_ref.as_str().to_owned(), previous_state: previous_state.clone(), current_state: current_state.clone(), }, } } fn api_error(error: impl std::fmt::Display) -> ProtocolError { let message = error.to_string(); let code = if message.starts_with("behavior revision conflict:") { ErrorCode::Conflict } else if matches!( message.as_str(), "unknown actor" | "unknown behavior" | "unknown behavior revision" | "unknown target behavior revision" | "unknown implementation" ) { ErrorCode::NotFound } else if matches!( message.as_str(), "mailbox full" | "effect exceeds mailbox capacity" ) { ErrorCode::CapacityExceeded } else if message == "invalid behavior name" { ErrorCode::InvalidRequest } else { ErrorCode::RuntimeFailure }; ProtocolError::new(code, message) } fn invalid_request(message: impl Into) -> ProtocolError { ProtocolError::new(ErrorCode::InvalidRequest, message) } fn parse_actor_ref(value: &str) -> Result { ActorRef::parse(value).ok_or_else(|| invalid_request("actor_ref must be a valid Nano ID")) } #[cfg(test)] mod tests { use super::*; use runa_protocol::Value; fn test_message(kind: i64, value: i64, target: Option) -> Value { Value::Record(std::collections::BTreeMap::from([ ("kind".to_owned(), Value::Integer(kind)), ("value".to_owned(), Value::Integer(value)), ( "target".to_owned(), target.map_or(Value::Null, Value::ActorRef), ), ])) } use runa_protocol::{Command, PROTOCOL_VERSION}; use std::io::Cursor; fn request(command: Command) -> Request { Request { version: PROTOCOL_VERSION, request_id: "test-request".to_owned(), command, } } fn publish(server: &mut Server) -> BehaviorDto { let response = server.dispatch(request(Command::PublishBehavior { name: "counter".to_owned(), expected_revision: None, source: "pub fn handle(state, message) { (state + message.value, []) }".to_owned(), })); match response.result.unwrap() { Reply::Behavior(behavior) => behavior, other => panic!("unexpected reply: {other:?}"), } } #[test] fn dispatches_behavior_actor_and_event_operations() { let mut server = Server::new(); let behavior = publish(&mut server); assert_eq!(behavior.revision, 1); assert_eq!(behavior.history.len(), 1); let spawned = server.dispatch(request(Command::Spawn { behavior_name: "counter".to_owned(), state: Value::Integer(5), mailbox_capacity: 2, })); let actor_ref = match spawned.result.unwrap() { Reply::ActorSpawned { actor_ref } => actor_ref, other => panic!("unexpected reply: {other:?}"), }; assert_eq!(actor_ref.len(), 21); assert!(!actor_ref.parse::().is_ok()); let sent = server.dispatch(request(Command::Send { actor_ref: actor_ref.clone(), message: test_message(0, 7, None), })); assert_eq!(sent.result, Ok(Reply::Acknowledged)); server.dispatch(request(Command::Tick { ticks: 100, instruction_budget: 10, })); let inspected = server.dispatch(request(Command::InspectActor { actor_ref: actor_ref.clone(), })); match inspected.result.unwrap() { Reply::Actor(snapshot) => assert_eq!(snapshot.state, Value::Integer(12)), other => panic!("unexpected reply: {other:?}"), } let events = server.dispatch(request(Command::GetEvents)); match events.result.unwrap() { Reply::Events(events) => assert!(events.iter().any( |event| matches!(event, EventDto::Committed { actor_ref: id } if id == &actor_ref) )), other => panic!("unexpected reply: {other:?}"), } assert_eq!( server.dispatch(request(Command::GetEvents)).result, Ok(Reply::Events(Vec::new())) ); } #[test] fn dispatch_preserves_request_id_and_maps_validation_and_domain_errors() { let mut server = Server::new(); let mut invalid = request(Command::GetEvents); invalid.version += 1; let response = server.dispatch(invalid); assert_eq!(response.request_id, "test-request"); assert_eq!( response.result.unwrap_err().code, ErrorCode::UnsupportedVersion ); let missing = server.dispatch(request(Command::InspectActor { actor_ref: "000000000000000000000".to_owned(), })); assert_eq!(missing.result.unwrap_err().code, ErrorCode::NotFound); publish(&mut server); let conflict = server.dispatch(request(Command::PublishBehavior { name: "counter".to_owned(), expected_revision: Some(99), source: "pub fn handle(state, message) { (state, []) }".to_owned(), })); assert_eq!(conflict.result.unwrap_err().code, ErrorCode::Conflict); } #[test] fn dispatch_supports_behavior_history_source_and_safe_install() { let mut server = Server::new(); let first = publish(&mut server); let updated = server.dispatch(request(Command::PublishBehavior { name: "counter".to_owned(), expected_revision: Some(first.revision), source: "pub fn handle(state, message) { (state * message.value, []) }".to_owned(), })); assert!(matches!(updated.result, Ok(Reply::Behavior(_)))); let source = server.dispatch(request(Command::GetBehaviorSource { name: "counter".to_owned(), revision: 1, })); assert_eq!( source.result, Ok(Reply::BehaviorSource { source: "pub fn handle(state, message) { (state + message.value, []) }".to_owned() }) ); let actor_ref = match server .dispatch(request(Command::Spawn { behavior_name: "counter".to_owned(), state: Value::Integer(3), mailbox_capacity: 1, })) .result .unwrap() { Reply::ActorSpawned { actor_ref } => actor_ref, other => panic!("unexpected reply: {other:?}"), }; let installed = server.dispatch(request(Command::InstallBehavior { actor_ref, behavior_name: "counter".to_owned(), })); assert!(matches!( installed.result, Ok(Reply::BehaviorInstalled { .. }) )); } #[test] fn json_lines_transport_dispatches_multiple_requests_and_accepts_final_line_without_newline() { let publish_request = request(Command::PublishBehavior { name: "counter".to_owned(), expected_revision: None, source: "pub fn handle(state, message) { (state + message.value, []) }".to_owned(), }); let mut spawn_request = request(Command::Spawn { behavior_name: "counter".to_owned(), state: Value::Integer(4), mailbox_capacity: 2, }); spawn_request.request_id = "spawn-request".to_owned(); let mut list_request = request(Command::ListActors); list_request.request_id = "list-request".to_owned(); let mut input = [publish_request, spawn_request] .iter() .map(serde_json::to_string) .collect::, _>>() .unwrap() .join("\n"); input.push('\n'); input.push_str(&serde_json::to_string(&list_request).unwrap()); let mut output = Vec::new(); serve_json_lines(&mut Server::new(), Cursor::new(input), &mut output).unwrap(); let responses = output .split(|byte| *byte == b'\n') .filter(|line| !line.is_empty()) .map(serde_json::from_slice::) .collect::, _>>() .unwrap(); assert_eq!(responses.len(), 3); assert_eq!( responses .iter() .map(|response| response.request_id.as_str()) .collect::>(), vec!["test-request", "spawn-request", "list-request"] ); assert!(matches!(responses[0].result, Ok(Reply::Behavior(_)))); assert!(matches!( &responses[1].result, Ok(Reply::ActorSpawned { actor_ref }) if actor_ref.len() == 21 )); assert!(matches!( &responses[2].result, Ok(Reply::Actors(actors)) if actors.len() == 1 && actors[0].state == Value::Integer(4) )); } #[test] fn json_lines_transport_rejects_malformed_and_oversized_input() { let mut server = Server::new(); let malformed = serve_json_lines(&mut server, Cursor::new(b"{not-json}\n"), Vec::new()).unwrap_err(); assert_eq!(malformed.kind(), io::ErrorKind::InvalidData); let mut oversized = vec![b'a'; MAX_REQUEST_LINE_BYTES + 1]; oversized.push(b'\n'); let error = serve_json_lines(&mut server, Cursor::new(oversized), Vec::new()).unwrap_err(); 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 notification_contract_is_versioned_and_distinct_from_responses() { let notification = runa_protocol::Notification { version: PROTOCOL_VERSION, event: runa_protocol::ImageEvent::ImageChanged { revision: 42 }, }; assert_eq!( serde_json::to_value(notification).unwrap(), serde_json::json!({ "version": 3, "event": {"event": "image_changed", "data": {"revision": 42}} }) ); } }