865 lines
25 KiB
Rust
865 lines
25 KiB
Rust
//! Versioned, transport-neutral wire contracts for Runa clients and servers.
|
|
|
|
pub use runa_core::Value;
|
|
pub use runa_core::{
|
|
DebugStopReason, DebuggerSnapshot, ExecutionInspection, SourceBreakpoint, StackFrame,
|
|
StackFrameKind, StepMode,
|
|
};
|
|
pub use runa_core::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
|
|
pub use runa_core::{LogEntry, LogLevel};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
pub const PROTOCOL_VERSION: u16 = 5;
|
|
pub const WEBSOCKET_SUBPROTOCOL: &str = "runa-debug";
|
|
|
|
/// Coalescible invalidation, not a drain of the image's runtime event queue.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Notification {
|
|
pub version: u16,
|
|
pub event: ImageEvent,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
|
pub enum ImageEvent {
|
|
ImageChanged { revision: u64 },
|
|
Log(LogEntry),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Request {
|
|
pub version: u16,
|
|
pub request_id: String,
|
|
pub command: Command,
|
|
}
|
|
|
|
impl Request {
|
|
pub fn validate(&self) -> Result<(), ProtocolError> {
|
|
if self.version != PROTOCOL_VERSION {
|
|
return Err(ProtocolError::new(
|
|
ErrorCode::UnsupportedVersion,
|
|
format!("unsupported protocol version {}", self.version),
|
|
));
|
|
}
|
|
if self.request_id.trim().is_empty() {
|
|
return Err(ProtocolError::new(
|
|
ErrorCode::InvalidRequest,
|
|
"request_id must not be empty",
|
|
));
|
|
}
|
|
|
|
match &self.command {
|
|
Command::SaveImage { file } | Command::RestoreImage { file } => {
|
|
if file.trim().is_empty() || file.len() > 4096 || file.contains('\0') {
|
|
return Err(invalid_request(
|
|
"snapshot file must be a nonempty server-local path of at most 4096 bytes",
|
|
));
|
|
}
|
|
}
|
|
Command::SetBreakpoints {
|
|
actor_ref,
|
|
implementation_id,
|
|
lines,
|
|
} => {
|
|
validate_actor_ref(actor_ref)?;
|
|
if implementation_id.is_empty()
|
|
|| lines.len() > 128
|
|
|| lines.iter().any(|line| *line > u32::MAX as usize)
|
|
{
|
|
return Err(invalid_request(
|
|
"expected implementation and at most 128 breakpoint lines are required",
|
|
));
|
|
}
|
|
}
|
|
Command::StepSource {
|
|
actor_ref,
|
|
implementation_id,
|
|
instruction_budget,
|
|
..
|
|
} => {
|
|
validate_actor_ref(actor_ref)?;
|
|
if implementation_id.is_empty()
|
|
|| *instruction_budget == 0
|
|
|| *instruction_budget > 10_000
|
|
{
|
|
return Err(invalid_request(
|
|
"expected implementation and a step budget from 1 to 10000 are required",
|
|
));
|
|
}
|
|
}
|
|
Command::CheckSource { .. } => {}
|
|
Command::RenameSource { path, target, .. } => {
|
|
validate_name(path)?;
|
|
validate_name(target)?;
|
|
}
|
|
Command::DeleteSource { path, .. } => validate_name(path)?,
|
|
Command::InspectExecution { actor_ref }
|
|
| Command::ActorBehavior { actor_ref }
|
|
| Command::WriteActorBehavior { actor_ref, .. } => validate_actor_ref(actor_ref)?,
|
|
Command::UpdateBehaviorSource {
|
|
actor_ref, path, ..
|
|
} => {
|
|
validate_actor_ref(actor_ref)?;
|
|
validate_name(path)?;
|
|
}
|
|
Command::EditActor {
|
|
actor_ref,
|
|
state,
|
|
messages,
|
|
} => {
|
|
validate_actor_ref(actor_ref)?;
|
|
if state.is_none() && messages.is_none() {
|
|
return Err(invalid_request("edit must specify state or messages"));
|
|
}
|
|
if let Some(state) = state {
|
|
state.validate().map_err(invalid_request)?;
|
|
}
|
|
if let Some(messages) = messages {
|
|
for message in messages.reserved.iter().chain(&messages.queued) {
|
|
message.validate().map_err(invalid_request)?;
|
|
}
|
|
}
|
|
}
|
|
Command::SetLogLevel { actor_ref, .. } => validate_actor_ref(actor_ref)?,
|
|
Command::Logs { actors, .. } => {
|
|
for actor in actors {
|
|
validate_actor_ref(actor)?;
|
|
}
|
|
}
|
|
Command::ListSources => {}
|
|
Command::ReadSource { path }
|
|
| Command::CommitSource { path }
|
|
| Command::SourceHistory { path } => validate_name(path)?,
|
|
Command::WriteSource { path, .. } => validate_name(path)?,
|
|
Command::RevisionSource { path, revision }
|
|
| Command::RollbackSource { path, revision } => {
|
|
validate_name(path)?;
|
|
if revision.is_empty()
|
|
|| revision.len() > 64
|
|
|| !revision.bytes().all(|byte| byte.is_ascii_alphanumeric())
|
|
{
|
|
return Err(invalid_request("invalid source revision"));
|
|
}
|
|
}
|
|
Command::System { actor_ref, message } => {
|
|
validate_actor_ref(actor_ref)?;
|
|
if let SystemMessageDto::Become { path } = message {
|
|
validate_name(path)?;
|
|
}
|
|
}
|
|
Command::PublishBehavior {
|
|
name,
|
|
expected_revision,
|
|
..
|
|
} => {
|
|
validate_name(name)?;
|
|
validate_optional_revision(*expected_revision)?;
|
|
}
|
|
Command::RollbackBehavior {
|
|
name,
|
|
expected_revision,
|
|
target_revision,
|
|
} => {
|
|
validate_name(name)?;
|
|
validate_revision(*expected_revision)?;
|
|
validate_revision(*target_revision)?;
|
|
}
|
|
Command::GetBehavior { name } => validate_name(name)?,
|
|
Command::GetBehaviorSource { name, revision } => {
|
|
validate_name(name)?;
|
|
validate_revision(*revision)?;
|
|
}
|
|
Command::Spawn {
|
|
behavior_name,
|
|
mailbox_capacity,
|
|
state,
|
|
} => {
|
|
validate_name(behavior_name)?;
|
|
state.validate().map_err(invalid_request)?;
|
|
if *mailbox_capacity == 0 {
|
|
return Err(invalid_request("mailbox_capacity must be positive"));
|
|
}
|
|
}
|
|
Command::Send { actor_ref, message } => {
|
|
validate_actor_ref(actor_ref)?;
|
|
message.validate().map_err(invalid_request)?;
|
|
}
|
|
Command::InspectActor { actor_ref }
|
|
| Command::StopActor { actor_ref }
|
|
| Command::RestartActor { actor_ref }
|
|
| Command::PauseActor { actor_ref }
|
|
| Command::ResumeActor { actor_ref }
|
|
| Command::CancelActor { actor_ref } => validate_actor_ref(actor_ref)?,
|
|
Command::InstallBehavior {
|
|
actor_ref,
|
|
behavior_name,
|
|
} => {
|
|
validate_actor_ref(actor_ref)?;
|
|
validate_name(behavior_name)?;
|
|
}
|
|
Command::Tick {
|
|
instruction_budget, ..
|
|
} if *instruction_budget == 0 => {
|
|
return Err(invalid_request("instruction_budget must be positive"));
|
|
}
|
|
Command::GetBehaviors
|
|
| Command::ListActors
|
|
| Command::GetEvents
|
|
| Command::Tick { .. } => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "operation", content = "parameters", rename_all = "snake_case")]
|
|
pub enum Command {
|
|
SaveImage {
|
|
file: String,
|
|
},
|
|
RestoreImage {
|
|
file: String,
|
|
},
|
|
SetBreakpoints {
|
|
actor_ref: String,
|
|
implementation_id: String,
|
|
lines: Vec<usize>,
|
|
},
|
|
StepSource {
|
|
actor_ref: String,
|
|
implementation_id: String,
|
|
instruction_budget: usize,
|
|
#[serde(default)]
|
|
mode: StepMode,
|
|
},
|
|
InspectExecution {
|
|
actor_ref: String,
|
|
},
|
|
CheckSource {
|
|
source: String,
|
|
},
|
|
RenameSource {
|
|
path: String,
|
|
target: String,
|
|
expected_source_id: String,
|
|
update_actors: bool,
|
|
},
|
|
DeleteSource {
|
|
path: String,
|
|
expected_source_id: String,
|
|
},
|
|
ActorBehavior {
|
|
actor_ref: String,
|
|
},
|
|
WriteActorBehavior {
|
|
actor_ref: String,
|
|
expected_source_id: String,
|
|
source: String,
|
|
},
|
|
UpdateBehaviorSource {
|
|
actor_ref: String,
|
|
path: String,
|
|
expected_source_id: String,
|
|
temporary_source_id: String,
|
|
},
|
|
EditActor {
|
|
actor_ref: String,
|
|
state: Option<Value>,
|
|
messages: Option<MailboxEdit>,
|
|
},
|
|
SetLogLevel {
|
|
actor_ref: String,
|
|
level: LogLevel,
|
|
},
|
|
Logs {
|
|
actors: Vec<String>,
|
|
follow: bool,
|
|
after: u64,
|
|
},
|
|
ListSources,
|
|
ReadSource {
|
|
path: String,
|
|
},
|
|
WriteSource {
|
|
path: String,
|
|
expected_source_id: Option<String>,
|
|
source: String,
|
|
},
|
|
CommitSource {
|
|
path: String,
|
|
},
|
|
SourceHistory {
|
|
path: String,
|
|
},
|
|
RevisionSource {
|
|
path: String,
|
|
revision: String,
|
|
},
|
|
RollbackSource {
|
|
path: String,
|
|
revision: String,
|
|
},
|
|
System {
|
|
actor_ref: String,
|
|
message: SystemMessageDto,
|
|
},
|
|
#[serde(skip)]
|
|
PublishBehavior {
|
|
name: String,
|
|
expected_revision: Option<u64>,
|
|
source: String,
|
|
},
|
|
#[serde(skip)]
|
|
RollbackBehavior {
|
|
name: String,
|
|
expected_revision: u64,
|
|
target_revision: u64,
|
|
},
|
|
#[serde(skip)]
|
|
GetBehavior {
|
|
name: String,
|
|
},
|
|
#[serde(skip)]
|
|
GetBehaviors,
|
|
#[serde(skip)]
|
|
GetBehaviorSource {
|
|
name: String,
|
|
revision: u64,
|
|
},
|
|
Spawn {
|
|
#[serde(rename = "path")]
|
|
behavior_name: String,
|
|
state: Value,
|
|
mailbox_capacity: usize,
|
|
},
|
|
Send {
|
|
actor_ref: String,
|
|
message: MessageDto,
|
|
},
|
|
Tick {
|
|
ticks: usize,
|
|
instruction_budget: usize,
|
|
},
|
|
InspectActor {
|
|
actor_ref: String,
|
|
},
|
|
ListActors,
|
|
GetEvents,
|
|
StopActor {
|
|
actor_ref: String,
|
|
},
|
|
RestartActor {
|
|
actor_ref: String,
|
|
},
|
|
PauseActor {
|
|
actor_ref: String,
|
|
},
|
|
ResumeActor {
|
|
actor_ref: String,
|
|
},
|
|
CancelActor {
|
|
actor_ref: String,
|
|
},
|
|
#[serde(skip)]
|
|
InstallBehavior {
|
|
actor_ref: String,
|
|
behavior_name: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct MailboxEdit {
|
|
pub reserved: Option<Value>,
|
|
pub queued: Vec<Value>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Response {
|
|
pub version: u16,
|
|
pub request_id: String,
|
|
pub result: Result<Reply, ProtocolError>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
|
|
pub enum Reply {
|
|
BreakpointsSet {
|
|
breakpoints: Vec<SourceBreakpoint>,
|
|
},
|
|
DebugStepCompleted {
|
|
reason: DebugStopReason,
|
|
},
|
|
ExecutionInspected(ExecutionInspectionDto),
|
|
SourceChecked {
|
|
diagnostics: Vec<SourceDiagnostic>,
|
|
},
|
|
ActorBehavior {
|
|
source_id: String,
|
|
source: String,
|
|
},
|
|
Logs {
|
|
entries: Vec<LogEntry>,
|
|
next_sequence: u64,
|
|
has_more: bool,
|
|
},
|
|
Sources(Vec<SourceFileDto>),
|
|
Source(SourceFileDto),
|
|
SourceCommitted(SourceCommitDto),
|
|
SourceHistory(Vec<SourceCommitDto>),
|
|
Acknowledged,
|
|
Behavior(BehaviorDto),
|
|
Behaviors(Vec<BehaviorDto>),
|
|
BehaviorSource {
|
|
source: String,
|
|
},
|
|
ActorSpawned {
|
|
actor_ref: String,
|
|
},
|
|
Actor(ActorSnapshotDto),
|
|
Actors(Vec<ActorSnapshotDto>),
|
|
Events(Vec<EventDto>),
|
|
TickCompleted {
|
|
ticks: usize,
|
|
},
|
|
BehaviorInstalled {
|
|
implementation_id: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SourceFileDto {
|
|
pub path: String,
|
|
pub source_id: String,
|
|
pub source: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SourceCommitDto {
|
|
pub revision: String,
|
|
pub source_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
|
pub enum SystemMessageDto {
|
|
Stop,
|
|
Kill,
|
|
Restart,
|
|
Pause,
|
|
Resume,
|
|
Reload,
|
|
Become { path: String },
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ProtocolEvent {
|
|
pub version: u16,
|
|
pub event: EventDto,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
|
pub enum EventDto {
|
|
Spawned {
|
|
actor_ref: String,
|
|
},
|
|
Committed {
|
|
actor_ref: String,
|
|
},
|
|
Failed {
|
|
actor_ref: String,
|
|
message: String,
|
|
},
|
|
Stopped {
|
|
actor_ref: String,
|
|
},
|
|
Restarted {
|
|
actor_ref: String,
|
|
},
|
|
Paused {
|
|
actor_ref: String,
|
|
},
|
|
Resumed {
|
|
actor_ref: String,
|
|
},
|
|
InvocationCancelled {
|
|
actor_ref: String,
|
|
},
|
|
BehaviorInstalled {
|
|
actor_ref: String,
|
|
implementation_id: String,
|
|
},
|
|
StateMigrated {
|
|
actor_ref: String,
|
|
previous_state: Value,
|
|
current_state: Value,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct BehaviorDto {
|
|
pub name: String,
|
|
pub revision: u64,
|
|
pub current_implementation_id: String,
|
|
pub history: Vec<BehaviorVersionDto>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct BehaviorVersionDto {
|
|
pub revision: u64,
|
|
pub implementation_id: String,
|
|
}
|
|
|
|
pub type MessageDto = Value;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ActorSnapshotDto {
|
|
#[serde(default)]
|
|
pub pending_behavior_path: Option<String>,
|
|
#[serde(default)]
|
|
pub behavior_path: Option<String>,
|
|
#[serde(default)]
|
|
pub temporary_behavior: bool,
|
|
pub log_level: LogLevel,
|
|
pub actor_ref: String,
|
|
pub mailbox_id: u64,
|
|
pub implementation_id: String,
|
|
pub incarnation: u64,
|
|
pub state: Value,
|
|
pub status: ActorStatusDto,
|
|
pub execution: Option<ExecutionSnapshotDto>,
|
|
pub queued: Vec<MessageDto>,
|
|
pub reserved: Option<MessageDto>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "status", content = "message", rename_all = "snake_case")]
|
|
pub enum ActorStatusDto {
|
|
Idle,
|
|
Running,
|
|
Awaiting,
|
|
Paused,
|
|
Stopped,
|
|
Failed(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ExecutionSnapshotDto {
|
|
pub vm_steps: u64,
|
|
pub instruction_pointer: usize,
|
|
pub call_depth: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ExecutionInspectionDto {
|
|
pub actor_ref: String,
|
|
pub incarnation: u64,
|
|
pub implementation_id: String,
|
|
pub source_id: String,
|
|
pub source: String,
|
|
pub behavior_path: Option<String>,
|
|
pub execution: Option<ExecutionInspection>,
|
|
#[serde(default)]
|
|
pub debugger: DebuggerSnapshot,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ErrorCode {
|
|
InvalidRequest,
|
|
UnsupportedVersion,
|
|
NotFound,
|
|
Conflict,
|
|
CapacityExceeded,
|
|
RuntimeFailure,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ProtocolError {
|
|
pub code: ErrorCode,
|
|
pub message: String,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub diagnostics: Vec<SourceDiagnostic>,
|
|
}
|
|
|
|
impl ProtocolError {
|
|
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
|
|
Self {
|
|
code,
|
|
message: message.into(),
|
|
diagnostics: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn validate_name(name: &str) -> Result<(), ProtocolError> {
|
|
if name.trim().is_empty() {
|
|
return Err(invalid_request("behavior name must not be empty"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_actor_ref(actor_ref: &str) -> Result<(), ProtocolError> {
|
|
if actor_ref.len() != 21
|
|
|| !actor_ref
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
|
{
|
|
return Err(invalid_request(
|
|
"actor_ref must be a 21-character URL-safe identifier",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_revision(revision: u64) -> Result<(), ProtocolError> {
|
|
if revision == 0 {
|
|
return Err(invalid_request("revision must be positive"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_optional_revision(revision: Option<u64>) -> Result<(), ProtocolError> {
|
|
if let Some(revision) = revision {
|
|
validate_revision(revision)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn invalid_request(message: impl Into<String>) -> ProtocolError {
|
|
ProtocolError::new(ErrorCode::InvalidRequest, message)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_message(kind: i64, value: i64, target: Option<runa_core::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),
|
|
),
|
|
]))
|
|
}
|
|
|
|
fn request(command: Command) -> Request {
|
|
Request {
|
|
version: PROTOCOL_VERSION,
|
|
request_id: "req-1".to_owned(),
|
|
command,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn source_step_modes_are_additive_and_reject_unknown_modes() {
|
|
let mut wire = serde_json::json!({
|
|
"version": PROTOCOL_VERSION, "request_id": "step",
|
|
"command": {"operation": "step_source", "parameters": {
|
|
"actor_ref": "ABCDEFGHIJKLMNOPQRSTU", "implementation_id": "impl", "instruction_budget": 100
|
|
}}
|
|
});
|
|
let default: Request = serde_json::from_value(wire.clone()).unwrap();
|
|
assert!(matches!(
|
|
default.command,
|
|
Command::StepSource {
|
|
mode: StepMode::Into,
|
|
..
|
|
}
|
|
));
|
|
for (text, mode) in [
|
|
("into", StepMode::Into),
|
|
("over", StepMode::Over),
|
|
("out", StepMode::Out),
|
|
] {
|
|
wire["command"]["parameters"]["mode"] = serde_json::json!(text);
|
|
let request: Request = serde_json::from_value(wire.clone()).unwrap();
|
|
assert_eq!(request.validate(), Ok(()));
|
|
assert!(
|
|
matches!(request.command, Command::StepSource {mode: actual, ..} if actual == mode)
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(request).unwrap()["command"]["parameters"]["mode"],
|
|
text
|
|
);
|
|
}
|
|
for value in [
|
|
serde_json::json!("next"),
|
|
serde_json::json!(null),
|
|
serde_json::json!(1),
|
|
] {
|
|
wire["command"]["parameters"]["mode"] = value;
|
|
assert!(serde_json::from_value::<Request>(wire.clone()).is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn request_uses_a_stable_tagged_wire_shape() {
|
|
let value = serde_json::to_value(request(Command::Send {
|
|
actor_ref: "xK8_pQ2-Lm5_012345678".to_owned(),
|
|
message: Value::Integer(9),
|
|
}))
|
|
.unwrap();
|
|
assert_eq!(
|
|
value,
|
|
serde_json::json!({
|
|
"version": PROTOCOL_VERSION,
|
|
"request_id": "req-1",
|
|
"command": {
|
|
"operation": "send",
|
|
"parameters": {
|
|
"actor_ref": "xK8_pQ2-Lm5_012345678",
|
|
"message": { "type": "integer", "data": 9 }
|
|
}
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn requests_validate_versions_and_required_positive_values() {
|
|
let mut request = request(Command::Spawn {
|
|
behavior_name: "counter".to_owned(),
|
|
state: Value::Integer(0),
|
|
mailbox_capacity: 1,
|
|
});
|
|
assert_eq!(request.validate(), Ok(()));
|
|
|
|
request.version += 1;
|
|
assert_eq!(
|
|
request.validate().unwrap_err().code,
|
|
ErrorCode::UnsupportedVersion
|
|
);
|
|
|
|
request.version = PROTOCOL_VERSION;
|
|
request.command = Command::Spawn {
|
|
behavior_name: "counter".to_owned(),
|
|
state: Value::Integer(0),
|
|
mailbox_capacity: 0,
|
|
};
|
|
assert_eq!(
|
|
request.validate().unwrap_err().code,
|
|
ErrorCode::InvalidRequest
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn requests_reject_empty_correlation_ids_and_malformed_actor_refs() {
|
|
let mut request = request(Command::GetEvents);
|
|
request.request_id = " ".to_owned();
|
|
assert_eq!(
|
|
request.validate().unwrap_err().code,
|
|
ErrorCode::InvalidRequest
|
|
);
|
|
|
|
request.request_id = "req-2".to_owned();
|
|
request.command = Command::StopActor {
|
|
actor_ref: String::new(),
|
|
};
|
|
assert_eq!(
|
|
request.validate().unwrap_err().code,
|
|
ErrorCode::InvalidRequest
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nested_references_round_trip_and_malformed_references_are_rejected() {
|
|
let request = request(Command::Send {
|
|
actor_ref: "xK8_pQ2-Lm5_012345678".to_owned(),
|
|
message: test_message(2, 9, runa_core::ActorRef::parse("Hj2_9sM0-pQ4_7aBcD3eF")),
|
|
});
|
|
assert_eq!(request.validate(), Ok(()));
|
|
let encoded = serde_json::to_string(&request).unwrap();
|
|
assert_eq!(serde_json::from_str::<Request>(&encoded).unwrap(), request);
|
|
assert!(serde_json::from_str::<Value>(r#"{"type":"actor_ref","data":"bad"}"#).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn response_round_trips_with_typed_error_or_reply() {
|
|
let response = Response {
|
|
version: PROTOCOL_VERSION,
|
|
request_id: "req-1".to_owned(),
|
|
result: Err(ProtocolError::new(ErrorCode::NotFound, "unknown actor")),
|
|
};
|
|
let encoded = serde_json::to_string(&response).unwrap();
|
|
let decoded: Response = serde_json::from_str(&encoded).unwrap();
|
|
assert_eq!(decoded, response);
|
|
}
|
|
|
|
#[test]
|
|
fn structured_values_have_unambiguous_wire_types_and_reject_invalid_data() {
|
|
let reference = runa_core::ActorRef::new();
|
|
let value = Value::Record(std::collections::BTreeMap::from([(
|
|
"values".to_owned(),
|
|
Value::List(vec![
|
|
Value::Null,
|
|
Value::Bool(false),
|
|
Value::Integer(i64::MAX),
|
|
Value::String(reference.to_string()),
|
|
Value::ActorRef(reference),
|
|
]),
|
|
)]));
|
|
let encoded = serde_json::to_string(&value).unwrap();
|
|
assert_eq!(serde_json::from_str::<Value>(&encoded).unwrap(), value);
|
|
for encoded in [
|
|
r#"{"type":"integer","data":1.5}"#,
|
|
r#"{"type":"integer","data":9223372036854775808}"#,
|
|
r#"{"type":"actor_ref","data":"invalid"}"#,
|
|
r#"{"type":"unknown","data":1}"#,
|
|
r#"{"type":"bool","data":true,"extra":1}"#,
|
|
] {
|
|
assert!(serde_json::from_str::<Value>(encoded).is_err(), "{encoded}");
|
|
}
|
|
let invalid = Value::List(vec![Value::Null; runa_core::MAX_VALUE_NODES]);
|
|
let spawn = request(Command::Spawn {
|
|
behavior_name: "echo".to_owned(),
|
|
state: invalid.clone(),
|
|
mailbox_capacity: 2,
|
|
});
|
|
assert_eq!(
|
|
spawn.validate().unwrap_err().code,
|
|
ErrorCode::InvalidRequest
|
|
);
|
|
let send = request(Command::Send {
|
|
actor_ref: reference.to_string(),
|
|
message: invalid,
|
|
});
|
|
assert_eq!(send.validate().unwrap_err().code, ErrorCode::InvalidRequest);
|
|
let mut old = request(Command::ListActors);
|
|
old.version = 2;
|
|
assert_eq!(
|
|
old.validate().unwrap_err().code,
|
|
ErrorCode::UnsupportedVersion
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn event_envelopes_are_versioned_and_serializable() {
|
|
let event = ProtocolEvent {
|
|
version: PROTOCOL_VERSION,
|
|
event: EventDto::StateMigrated {
|
|
actor_ref: "actor_ref_000000000000".to_owned(),
|
|
previous_state: Value::Integer(4),
|
|
current_state: Value::Integer(40),
|
|
},
|
|
};
|
|
let encoded = serde_json::to_string(&event).unwrap();
|
|
let decoded: ProtocolEvent = serde_json::from_str(&encoded).unwrap();
|
|
assert_eq!(decoded, event);
|
|
}
|
|
}
|