422 lines
14 KiB
Rust
422 lines
14 KiB
Rust
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<std::net::TcpStream> {
|
|
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<std::net::TcpStream>) -> 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::<SocketAddr>()
|
|
.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<runa_actor::ActorRef>) -> 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),
|
|
),
|
|
]))
|
|
}
|