use runa_protocol::Value; use std::io::{BufRead, BufReader}; use std::net::SocketAddr; use std::process::{Child, Command as ProcessCommand, Stdio}; use runa_client::{LocalClient, WebSocketClient}; use runa_protocol::{Command, Reply}; use tokio_tungstenite::tungstenite::{self, Message, client::IntoClientRequest}; fn start_server() -> (ServerProcess, SocketAddr) { 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 mut stderr = BufReader::new(server.0.stderr.take().unwrap()); 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() .unwrap(); std::thread::spawn(move || { std::io::copy(&mut stderr, &mut std::io::sink()).unwrap(); }); (server, address) } fn watcher(address: SocketAddr) -> tungstenite::WebSocket { let mut request = format!("ws://{address}/").into_client_request().unwrap(); request .headers_mut() .insert("sec-websocket-protocol", "runa-debug".parse().unwrap()); let stream = std::net::TcpStream::connect(address).unwrap(); stream .set_read_timeout(Some(std::time::Duration::from_secs(2))) .unwrap(); let (socket, response) = tungstenite::client(request, stream).unwrap(); assert_eq!(response.headers()["sec-websocket-protocol"], "runa-debug"); socket } fn revision(socket: &mut tungstenite::WebSocket) -> u64 { let message = socket.read().unwrap(); let event: runa_protocol::Notification = serde_json::from_str(message.to_text().unwrap()).unwrap(); assert_eq!(event.version, runa_protocol::PROTOCOL_VERSION); let runa_protocol::ImageEvent::ImageChanged { revision } = event.event; revision } #[test] fn websocket_broadcasts_non_destructive_invalidations_for_mailbox_state_and_lifecycle() { let (_server, address) = start_server(); let mut first = watcher(address); let mut second = watcher(address); let mut client = WebSocketClient::connect(address).unwrap(); let actor_ref = match client .request(Command::Spawn { behavior_name: "counter".to_owned(), state: Value::Integer(1), mailbox_capacity: 4, }) .unwrap() .result .unwrap() { Reply::ActorSpawned { actor_ref } => actor_ref, other => panic!("unexpected spawn: {other:?}"), }; let mut previous = 0; for command in [ Command::ListActors, Command::Send { actor_ref: actor_ref.clone(), message: Value::Record(std::collections::BTreeMap::from([ ("op".to_owned(), Value::String("add".to_owned())), ("value".to_owned(), Value::Integer(3)), ])), }, Command::Tick { ticks: 100, instruction_budget: 10, }, Command::PauseActor { actor_ref: actor_ref.clone(), }, Command::ResumeActor { actor_ref: actor_ref.clone(), }, Command::StopActor { actor_ref: actor_ref.clone(), }, Command::RestartActor { actor_ref: actor_ref.clone(), }, ] { if !matches!(command, Command::ListActors) { client.request(command).unwrap().result.unwrap(); } let current = revision(&mut first); assert!(current > previous); assert_eq!(revision(&mut second), current); previous = current; } // Both subscribers have seen notifications, but neither consumed runtime events. let Reply::Events(events) = client.request(Command::GetEvents).unwrap().result.unwrap() else { panic!("expected runtime events") }; assert!( events .iter() .any(|event| matches!(event, runa_protocol::EventDto::Spawned { .. })) ); let Reply::Actor(snapshot) = client .request(Command::InspectActor { actor_ref }) .unwrap() .result .unwrap() else { panic!("expected actor") }; assert_eq!(snapshot.state, Value::Integer(4)); // A read after a failed mutation is a fence: no invalidation should precede its reply. assert!( client .request(Command::Spawn { behavior_name: "missing".to_owned(), state: Value::Null, mailbox_capacity: 1, }) .unwrap() .result .is_err() ); first .send(Message::Text( serde_json::to_string(&runa_protocol::Request { version: 3, request_id: "read-fence".to_owned(), command: Command::ListActors, }) .unwrap() .into(), )) .unwrap(); let message = first.read().unwrap(); let response: runa_protocol::Response = serde_json::from_str(message.to_text().unwrap()).unwrap(); assert_eq!(response.request_id, "read-fence"); } #[test] fn websocket_requires_subprotocol_and_rejects_browser_origins_without_blocking_other_clients() { let (_server, address) = start_server(); // An idle HTTP handshake cannot stall the image or another client's upgrade. let _idle = std::net::TcpStream::connect(address).unwrap(); for (protocol, origin) in [ (None, false), (Some("debug"), false), (Some("runa-debug"), true), ] { let mut request = format!("ws://{address}/").into_client_request().unwrap(); if let Some(protocol) = protocol { request .headers_mut() .insert("sec-websocket-protocol", protocol.parse().unwrap()); } if origin { request .headers_mut() .insert("origin", "https://example.test".parse().unwrap()); } let stream = std::net::TcpStream::connect(address).unwrap(); stream .set_read_timeout(Some(std::time::Duration::from_secs(2))) .unwrap(); assert!(tungstenite::client(request, stream).is_err()); } let mut client = WebSocketClient::connect(address).unwrap(); assert_eq!( client.request(Command::ListActors).unwrap().result.unwrap(), Reply::Actors(Vec::new()) ); } #[test] fn websocket_rejects_binary_malformed_and_oversized_requests() { let (_server, address) = start_server(); for message in [ Message::Binary(b"{}".to_vec().into()), Message::Text("{not-json}".into()), Message::Text(" ".repeat(runa_server::MAX_REQUEST_LINE_BYTES + 1).into()), ] { let mut socket = watcher(address); // An oversized frame may be rejected as soon as its header arrives, // before the sender finishes writing its payload. let _ = socket.send(message); assert!(socket.read().is_err()); } let mut client = WebSocketClient::connect(address).unwrap(); assert!(client.request(Command::ListActors).unwrap().result.is_ok()); } struct ServerProcess(Child); impl Drop for ServerProcess { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } } #[test] fn local_client_launches_server_and_operates_the_same_live_image() { let mut client = LocalClient::spawn(env!("CARGO_BIN_EXE_runa-server"), &[] as &[&str]).unwrap(); let published = client .request(Command::PublishBehavior { name: "counter".to_owned(), expected_revision: None, source: "pub fn handle(state, message) { (state + message.value, []) }".to_owned(), }) .unwrap(); assert_eq!(published.request_id, "runa-client-1"); assert!(matches!(published.result, Ok(Reply::Behavior(_)))); let spawned = client .request(Command::Spawn { behavior_name: "counter".to_owned(), state: Value::Integer(5), mailbox_capacity: 2, }) .unwrap(); assert_eq!(spawned.request_id, "runa-client-2"); let actor_ref = match spawned.result { Ok(Reply::ActorSpawned { actor_ref }) => actor_ref, other => panic!("unexpected spawn response: {other:?}"), }; assert_eq!(actor_ref.len(), 21); client .request(Command::Send { actor_ref: actor_ref.clone(), message: test_message(0, 7, None), }) .unwrap(); client .request(Command::Tick { ticks: 100, instruction_budget: 10, }) .unwrap(); let inspected = client.request(Command::InspectActor { actor_ref }).unwrap(); assert!(matches!( inspected.result, Ok(Reply::Actor(actor)) if actor.state == Value::Integer(12) )); assert!(client.shutdown().unwrap().success()); } #[test] fn websocket_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::() .unwrap(); let mut client = WebSocketClient::connect(address).unwrap(); let response = client.request(Command::ListActors).unwrap(); assert_eq!(response.result, Ok(Reply::Actors(Vec::new()))); let mut first = WebSocketClient::connect(address).unwrap(); let mut second = WebSocketClient::connect(address).unwrap(); let actor_ref = match first .request(Command::Spawn { behavior_name: "counter".to_owned(), state: Value::Integer(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 == Value::Integer(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 = WebSocketClient::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: Value::Record(std::collections::BTreeMap::from([ ("op".to_owned(), Value::String("add".to_owned())), ("value".to_owned(), Value::Integer(7)), ])), }) .unwrap() .result .unwrap(); reconnected .request(Command::Tick { ticks: 100, instruction_budget: 10, }) .unwrap() .result .unwrap(); assert!(matches!(reconnected.request(Command::InspectActor { actor_ref: actor_ref.clone(), }).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == Value::Integer(12))); reconnected .request(Command::PublishBehavior { name: "echo".to_owned(), expected_revision: None, source: "pub fn handle(state, message) { (message, []) }".to_owned(), }) .unwrap() .result .unwrap(); let nested = Value::Record(std::collections::BTreeMap::from([( "items".to_owned(), Value::List(vec![ Value::Null, Value::Bool(true), Value::Integer(-42), Value::String("hello world".to_owned()), Value::ActorRef(runa_actor::ActorRef::parse(&actor_ref).unwrap()), ]), )])); let echo = match reconnected .request(Command::Spawn { behavior_name: "echo".to_owned(), state: nested.clone(), mailbox_capacity: 2, }) .unwrap() .result .unwrap() { Reply::ActorSpawned { actor_ref } => actor_ref, other => panic!("unexpected spawn: {other:?}"), }; assert!(matches!(reconnected.request(Command::InspectActor { actor_ref: echo.clone(), }).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == nested)); reconnected .request(Command::Send { actor_ref: echo.clone(), message: nested.clone(), }) .unwrap() .result .unwrap(); reconnected .request(Command::Tick { ticks: 100, instruction_budget: 10, }) .unwrap() .result .unwrap(); assert!(matches!(reconnected.request(Command::InspectActor { actor_ref: echo, }).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == nested && actor.reserved.is_none())); } 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), ), ])) }