Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
2519 lines
83 KiB
Rust
2519 lines
83 KiB
Rust
//! Direct, author-owned Call-to-Play state.
|
|
|
|
use std::{
|
|
collections::{BTreeMap, HashMap, HashSet},
|
|
fmt,
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use lanspread_proto::{
|
|
CallId,
|
|
CallNonce,
|
|
CallToPlayAction,
|
|
CallToPlayAuthorEvent,
|
|
CallToPlayAuthorSnapshot,
|
|
ControlValidationError,
|
|
EventNonce,
|
|
MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES,
|
|
MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR,
|
|
PeerId,
|
|
RuntimeSessionId,
|
|
};
|
|
|
|
use crate::peer_db::PeerEndpointGeneration;
|
|
|
|
pub(crate) const MAX_CALL_TO_PLAY_AUTHORS: usize = 64;
|
|
pub(crate) const LOCAL_TERMINAL_EVENT_RESERVE: usize = 1;
|
|
pub(crate) const LOCAL_TERMINAL_BYTE_RESERVE: usize = 512;
|
|
|
|
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
|
|
const TERMINAL_RETENTION_MS: i64 = 15 * 60_000;
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) struct CallToPlayLocalIntent {
|
|
pub(crate) call_id: Option<CallId>,
|
|
pub(crate) action: CallToPlayLocalAction,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) enum CallToPlayLocalAction {
|
|
Create {
|
|
game_id: String,
|
|
max_players: u16,
|
|
scheduled_for: Option<i64>,
|
|
deadline: i64,
|
|
},
|
|
Respond {
|
|
ready_at: Option<i64>,
|
|
},
|
|
Rsvp,
|
|
SendMessage {
|
|
text: String,
|
|
},
|
|
Leave,
|
|
Cancel,
|
|
Start,
|
|
AddTime {
|
|
deadline: i64,
|
|
},
|
|
}
|
|
|
|
impl CallToPlayLocalAction {
|
|
fn into_wire(self) -> CallToPlayAction {
|
|
match self {
|
|
Self::Create {
|
|
game_id,
|
|
max_players,
|
|
scheduled_for,
|
|
deadline,
|
|
} => CallToPlayAction::Create {
|
|
game_id,
|
|
max_players,
|
|
scheduled_for,
|
|
deadline,
|
|
},
|
|
Self::Respond { ready_at } => CallToPlayAction::Respond { ready_at },
|
|
Self::Rsvp => CallToPlayAction::Rsvp,
|
|
Self::SendMessage { text } => CallToPlayAction::SendMessage { text },
|
|
Self::Leave => CallToPlayAction::Leave,
|
|
Self::Cancel => CallToPlayAction::Cancel,
|
|
Self::Start => CallToPlayAction::Start,
|
|
Self::AddTime { deadline } => CallToPlayAction::AddTime { deadline },
|
|
}
|
|
}
|
|
|
|
const fn is_create(&self) -> bool {
|
|
matches!(self, Self::Create { .. })
|
|
}
|
|
|
|
const fn is_terminal(&self) -> bool {
|
|
matches!(self, Self::Cancel | Self::Start)
|
|
}
|
|
|
|
const fn requires_creator_authority(&self) -> bool {
|
|
matches!(self, Self::Cancel | Self::Start | Self::AddTime { .. })
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) struct CallToPlayReceipt {
|
|
pub(crate) call_id: CallId,
|
|
pub(crate) event_id: EventNonce,
|
|
pub(crate) revision: u64,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) struct CallToPlayMutation {
|
|
pub(crate) revision: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) struct CallToPlayPublication {
|
|
pub(crate) view: CallToPlayView,
|
|
pub(crate) local_revision: u64,
|
|
pub(crate) local_changed: bool,
|
|
}
|
|
|
|
/// A fallibility boundary captured before an atomic remote-state commit.
|
|
///
|
|
/// Preparation samples the clock and computes any fallible local-pruning
|
|
/// candidate without mutating the store. Projection after a remote slice
|
|
/// mutation is then infallible and uses this single time boundary.
|
|
#[derive(Debug)]
|
|
pub(crate) struct PreparedCallToPlayPublication {
|
|
now: i64,
|
|
base_local_revision: u64,
|
|
pruned_local: Option<CallToPlayAuthorSnapshot>,
|
|
}
|
|
|
|
impl PreparedCallToPlayPublication {
|
|
#[must_use]
|
|
pub(crate) const fn local_changed(&self) -> bool {
|
|
self.pruned_local.is_some()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) struct CallToPlayView {
|
|
pub(crate) events: Vec<CallToPlayViewEvent>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) struct CallToPlayViewEvent {
|
|
pub(crate) id: EventNonce,
|
|
pub(crate) call_id: CallId,
|
|
pub(crate) author_id: PeerId,
|
|
pub(crate) author_name: String,
|
|
pub(crate) at: i64,
|
|
pub(crate) action: CallToPlayAction,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) struct RemoteAuthorState {
|
|
pub(crate) endpoint_generation: PeerEndpointGeneration,
|
|
pub(crate) runtime_session_id: RuntimeSessionId,
|
|
pub(crate) revision: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) enum CallToPlayValidationError {
|
|
Wire(ControlValidationError),
|
|
SnapshotTooLarge {
|
|
actual: usize,
|
|
maximum: usize,
|
|
},
|
|
SnapshotEncoding,
|
|
DuplicateEventId(EventNonce),
|
|
DuplicateCreate(CallId),
|
|
InvalidEvent {
|
|
event_id: EventNonce,
|
|
reason: &'static str,
|
|
},
|
|
UnauthorizedAction {
|
|
event_id: EventNonce,
|
|
call_id: CallId,
|
|
},
|
|
MissingCreatorRoot(CallId),
|
|
NonMonotonicAuthorHistory,
|
|
NonMonotonicCallHistory(CallId),
|
|
ActionAfterTerminal(CallId),
|
|
}
|
|
|
|
impl fmt::Display for CallToPlayValidationError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Wire(error) => write!(formatter, "{error}"),
|
|
Self::SnapshotTooLarge { actual, maximum } => write!(
|
|
formatter,
|
|
"Call-to-Play author snapshot is {actual} bytes; maximum is {maximum}"
|
|
),
|
|
Self::SnapshotEncoding => {
|
|
formatter.write_str("Call-to-Play author snapshot could not be encoded")
|
|
}
|
|
Self::DuplicateEventId(event_id) => {
|
|
write!(formatter, "duplicate Call-to-Play event ID {event_id}")
|
|
}
|
|
Self::DuplicateCreate(call_id) => {
|
|
write!(formatter, "duplicate Call-to-Play creator root {call_id}")
|
|
}
|
|
Self::InvalidEvent { event_id, reason } => {
|
|
write!(formatter, "invalid Call-to-Play event {event_id}: {reason}")
|
|
}
|
|
Self::UnauthorizedAction { event_id, call_id } => write!(
|
|
formatter,
|
|
"Call-to-Play event {event_id} is not authoritative for {call_id}"
|
|
),
|
|
Self::MissingCreatorRoot(call_id) => {
|
|
write!(formatter, "Call-to-Play call {call_id} has no creator root")
|
|
}
|
|
Self::NonMonotonicAuthorHistory => {
|
|
formatter.write_str("Call-to-Play author events are not timestamp ordered")
|
|
}
|
|
Self::NonMonotonicCallHistory(call_id) => {
|
|
write!(
|
|
formatter,
|
|
"Call-to-Play call {call_id} has non-monotonic history"
|
|
)
|
|
}
|
|
Self::ActionAfterTerminal(call_id) => {
|
|
write!(
|
|
formatter,
|
|
"Call-to-Play call {call_id} has an action after termination"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for CallToPlayValidationError {}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) enum CallToPlayMutationError {
|
|
InvalidIntent(&'static str),
|
|
UnknownOrExpiredCall(CallId),
|
|
CreatorAuthorityRequired(CallId),
|
|
CallAlreadyTerminal(CallId),
|
|
EventHistoryFull,
|
|
RevisionExhausted,
|
|
ClockUnavailable,
|
|
EntropyUnavailable,
|
|
InvalidSnapshot(CallToPlayValidationError),
|
|
}
|
|
|
|
impl fmt::Display for CallToPlayMutationError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::InvalidIntent(reason) => formatter.write_str(reason),
|
|
Self::UnknownOrExpiredCall(call_id) => {
|
|
write!(
|
|
formatter,
|
|
"Call-to-Play call {call_id} is unknown or expired"
|
|
)
|
|
}
|
|
Self::CreatorAuthorityRequired(call_id) => {
|
|
write!(formatter, "creator authority is required for {call_id}")
|
|
}
|
|
Self::CallAlreadyTerminal(call_id) => {
|
|
write!(formatter, "Call-to-Play call {call_id} is already terminal")
|
|
}
|
|
Self::EventHistoryFull => {
|
|
formatter.write_str("Call-to-Play local event history is full")
|
|
}
|
|
Self::RevisionExhausted => {
|
|
formatter.write_str("Call-to-Play local revision is exhausted")
|
|
}
|
|
Self::ClockUnavailable => formatter.write_str("system clock is unavailable"),
|
|
Self::EntropyUnavailable => {
|
|
formatter.write_str("secure random number generation is unavailable")
|
|
}
|
|
Self::InvalidSnapshot(error) => write!(formatter, "{error}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for CallToPlayMutationError {}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct PreparedRemoteAuthor {
|
|
author_id: PeerId,
|
|
endpoint_generation: PeerEndpointGeneration,
|
|
runtime_session_id: RuntimeSessionId,
|
|
candidate: PreparedRemoteCandidate,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum PreparedRemoteCandidate {
|
|
Valid(CallToPlayAuthorSnapshot),
|
|
Invalid(CallToPlayValidationError),
|
|
}
|
|
|
|
impl PreparedRemoteAuthor {
|
|
/// Performs all allocation, encoding, and semantic validation without a
|
|
/// store or peer-database lock. The invalid candidate is retained so the
|
|
/// locked observation can apply session-clearing rules atomically.
|
|
pub(crate) fn prepare(
|
|
author_id: PeerId,
|
|
endpoint_generation: PeerEndpointGeneration,
|
|
runtime_session_id: RuntimeSessionId,
|
|
snapshot: CallToPlayAuthorSnapshot,
|
|
) -> Self {
|
|
let candidate = match validate_author_snapshot(author_id, &snapshot) {
|
|
Ok(()) => PreparedRemoteCandidate::Valid(snapshot),
|
|
Err(error) => PreparedRemoteCandidate::Invalid(error),
|
|
};
|
|
Self {
|
|
author_id,
|
|
endpoint_generation,
|
|
runtime_session_id,
|
|
candidate,
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub(crate) fn validation_error(&self) -> Option<&CallToPlayValidationError> {
|
|
match &self.candidate {
|
|
PreparedRemoteCandidate::Valid(_) => None,
|
|
PreparedRemoteCandidate::Invalid(error) => Some(error),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) enum ObserveRemoteAuthorOutcome {
|
|
Applied {
|
|
session_changed: bool,
|
|
},
|
|
Unchanged {
|
|
generation_rebound: bool,
|
|
},
|
|
IgnoredStale {
|
|
generation_rebound: bool,
|
|
},
|
|
EqualRevisionConflict {
|
|
generation_rebound: bool,
|
|
},
|
|
InvalidCleared(CallToPlayValidationError),
|
|
InvalidPreserved {
|
|
error: CallToPlayValidationError,
|
|
generation_rebound: bool,
|
|
},
|
|
InvalidAbsent(CallToPlayValidationError),
|
|
AtCapacity,
|
|
RejectedLocalIdentity,
|
|
}
|
|
|
|
impl ObserveRemoteAuthorOutcome {
|
|
#[must_use]
|
|
pub(crate) const fn view_changed(&self) -> bool {
|
|
matches!(self, Self::Applied { .. } | Self::InvalidCleared(_))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct RemoteAuthorSlice {
|
|
endpoint_generation: PeerEndpointGeneration,
|
|
runtime_session_id: RuntimeSessionId,
|
|
snapshot: CallToPlayAuthorSnapshot,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct CallToPlayStore {
|
|
local_peer_id: PeerId,
|
|
local: CallToPlayAuthorSnapshot,
|
|
remote: BTreeMap<PeerId, RemoteAuthorSlice>,
|
|
last_publication_at: i64,
|
|
#[cfg(test)]
|
|
projection_count: usize,
|
|
}
|
|
|
|
impl CallToPlayStore {
|
|
pub(crate) fn new(
|
|
local_peer_id: PeerId,
|
|
_runtime_session_id: RuntimeSessionId,
|
|
display_name: String,
|
|
) -> Result<Self, CallToPlayMutationError> {
|
|
let local = CallToPlayAuthorSnapshot {
|
|
revision: 0,
|
|
display_name,
|
|
events: Vec::new(),
|
|
};
|
|
validate_author_snapshot(local_peer_id, &local)
|
|
.map_err(CallToPlayMutationError::InvalidSnapshot)?;
|
|
ensure_local_terminal_reserve(&local, false)?;
|
|
Ok(Self {
|
|
local_peer_id,
|
|
local,
|
|
remote: BTreeMap::new(),
|
|
last_publication_at: 0,
|
|
#[cfg(test)]
|
|
projection_count: 0,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn publish_local(
|
|
&mut self,
|
|
intent: CallToPlayLocalIntent,
|
|
display_name: String,
|
|
) -> Result<(CallToPlayReceipt, CallToPlayPublication), CallToPlayMutationError> {
|
|
let now = now_ms()?.max(self.last_publication_at);
|
|
let event_nonce = EventNonce::from_bytes(random_nonce_bytes()?);
|
|
let call_nonce = intent
|
|
.action
|
|
.is_create()
|
|
.then(|| random_nonce_bytes().map(CallNonce::from_bytes))
|
|
.transpose()?;
|
|
let receipt = self.publish_local_at(intent, display_name, now, call_nonce, event_nonce)?;
|
|
Ok((receipt, self.project_publication_at(now, true)))
|
|
}
|
|
|
|
pub(crate) fn set_local_display_name(
|
|
&mut self,
|
|
display_name: String,
|
|
) -> Result<(Option<CallToPlayMutation>, CallToPlayPublication), CallToPlayMutationError> {
|
|
let now = now_ms()?.max(self.last_publication_at);
|
|
let mutation = self.set_local_display_name_at(display_name, now)?;
|
|
let publication = self.project_publication_at(now, mutation.is_some());
|
|
Ok((mutation, publication))
|
|
}
|
|
|
|
pub(crate) fn current_publication(
|
|
&mut self,
|
|
) -> Result<CallToPlayPublication, CallToPlayMutationError> {
|
|
let now = now_ms()?.max(self.last_publication_at);
|
|
self.publication_at(now)
|
|
}
|
|
|
|
pub(crate) fn current_responder_state(
|
|
&mut self,
|
|
) -> Result<(u64, Option<CallToPlayPublication>), CallToPlayMutationError> {
|
|
let now = now_ms()?.max(self.last_publication_at);
|
|
self.responder_state_at(now)
|
|
}
|
|
|
|
pub(crate) fn local_responder_snapshot(
|
|
&mut self,
|
|
) -> Result<
|
|
(CallToPlayAuthorSnapshot, u64, Option<CallToPlayPublication>),
|
|
CallToPlayMutationError,
|
|
> {
|
|
let now = now_ms()?.max(self.last_publication_at);
|
|
self.local_responder_snapshot_at(now)
|
|
}
|
|
|
|
fn local_responder_snapshot_at(
|
|
&mut self,
|
|
now: i64,
|
|
) -> Result<
|
|
(CallToPlayAuthorSnapshot, u64, Option<CallToPlayPublication>),
|
|
CallToPlayMutationError,
|
|
> {
|
|
let (revision, publication) = self.responder_state_at(now)?;
|
|
Ok((self.local.clone(), revision, publication))
|
|
}
|
|
|
|
pub(crate) fn prepare_publication(
|
|
&self,
|
|
) -> Result<PreparedCallToPlayPublication, CallToPlayMutationError> {
|
|
self.prepare_publication_at(now_ms()?.max(self.last_publication_at))
|
|
}
|
|
|
|
fn prepare_publication_at(
|
|
&self,
|
|
now: i64,
|
|
) -> Result<PreparedCallToPlayPublication, CallToPlayMutationError> {
|
|
Ok(PreparedCallToPlayPublication {
|
|
now,
|
|
base_local_revision: self.local.revision,
|
|
pruned_local: self.pruned_local_candidate_at(now)?,
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub(crate) fn view_from_prepared(
|
|
&mut self,
|
|
prepared: PreparedCallToPlayPublication,
|
|
) -> CallToPlayPublication {
|
|
let PreparedCallToPlayPublication {
|
|
mut now,
|
|
base_local_revision,
|
|
pruned_local,
|
|
} = prepared;
|
|
now = now.max(self.last_publication_at);
|
|
let local_changed = if self.local.revision == base_local_revision {
|
|
if let Some(pruned_local) = pruned_local {
|
|
self.local = pruned_local;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
} else {
|
|
false
|
|
};
|
|
self.project_publication_at(now, local_changed)
|
|
}
|
|
|
|
/// Commits a prepared candidate while the caller holds the peer-database
|
|
/// write lock before this store's write lock and has rechecked the pinned
|
|
/// endpoint generation.
|
|
pub(crate) fn observe_prepared_remote(
|
|
&mut self,
|
|
prepared: PreparedRemoteAuthor,
|
|
) -> ObserveRemoteAuthorOutcome {
|
|
let PreparedRemoteAuthor {
|
|
author_id,
|
|
endpoint_generation,
|
|
runtime_session_id,
|
|
candidate,
|
|
} = prepared;
|
|
|
|
if author_id == self.local_peer_id {
|
|
return ObserveRemoteAuthorOutcome::RejectedLocalIdentity;
|
|
}
|
|
|
|
let snapshot = match candidate {
|
|
PreparedRemoteCandidate::Valid(snapshot) => snapshot,
|
|
PreparedRemoteCandidate::Invalid(error) => {
|
|
let Some(current) = self.remote.get_mut(&author_id) else {
|
|
return ObserveRemoteAuthorOutcome::InvalidAbsent(error);
|
|
};
|
|
if current.runtime_session_id != runtime_session_id {
|
|
self.remote.remove(&author_id);
|
|
return ObserveRemoteAuthorOutcome::InvalidCleared(error);
|
|
}
|
|
let generation_rebound = current.endpoint_generation != endpoint_generation;
|
|
current.endpoint_generation = endpoint_generation;
|
|
return ObserveRemoteAuthorOutcome::InvalidPreserved {
|
|
error,
|
|
generation_rebound,
|
|
};
|
|
}
|
|
};
|
|
|
|
if let Some(current) = self.remote.get_mut(&author_id) {
|
|
let session_changed = current.runtime_session_id != runtime_session_id;
|
|
if session_changed || snapshot.revision > current.snapshot.revision {
|
|
*current = RemoteAuthorSlice {
|
|
endpoint_generation,
|
|
runtime_session_id,
|
|
snapshot,
|
|
};
|
|
return ObserveRemoteAuthorOutcome::Applied { session_changed };
|
|
}
|
|
|
|
let generation_rebound = current.endpoint_generation != endpoint_generation;
|
|
current.endpoint_generation = endpoint_generation;
|
|
if snapshot.revision < current.snapshot.revision {
|
|
return ObserveRemoteAuthorOutcome::IgnoredStale { generation_rebound };
|
|
}
|
|
return if snapshot == current.snapshot {
|
|
ObserveRemoteAuthorOutcome::Unchanged { generation_rebound }
|
|
} else {
|
|
ObserveRemoteAuthorOutcome::EqualRevisionConflict { generation_rebound }
|
|
};
|
|
}
|
|
|
|
if self.remote.len() >= MAX_CALL_TO_PLAY_AUTHORS - 1 {
|
|
return ObserveRemoteAuthorOutcome::AtCapacity;
|
|
}
|
|
self.remote.insert(
|
|
author_id,
|
|
RemoteAuthorSlice {
|
|
endpoint_generation,
|
|
runtime_session_id,
|
|
snapshot,
|
|
},
|
|
);
|
|
ObserveRemoteAuthorOutcome::Applied {
|
|
session_changed: true,
|
|
}
|
|
}
|
|
|
|
/// Clears every remote author and returns the resulting full, local-only
|
|
/// publication.
|
|
///
|
|
/// The operation is infallible: a failed clock sample or normal local
|
|
/// prune is returned as the optional diagnostic after the remote slices
|
|
/// have still been cleared and projected. Such a failure preserves the
|
|
/// local author exactly. On success, the local revision changes only when
|
|
/// ordinary retention pruning requires it.
|
|
#[must_use = "the replacement publication and any maintenance diagnostic must be handled"]
|
|
pub(crate) fn clear_remote_authors_and_project(
|
|
&mut self,
|
|
) -> (CallToPlayPublication, Option<CallToPlayMutationError>) {
|
|
self.clear_remote_authors_and_project_at(now_ms())
|
|
}
|
|
|
|
fn clear_remote_authors_and_project_at(
|
|
&mut self,
|
|
now: Result<i64, CallToPlayMutationError>,
|
|
) -> (CallToPlayPublication, Option<CallToPlayMutationError>) {
|
|
let now = match now {
|
|
Ok(now) => now.max(self.last_publication_at),
|
|
Err(error) => {
|
|
self.remote.clear();
|
|
let fallback_now = self.last_publication_at;
|
|
let publication = self.project_publication_at(fallback_now, false);
|
|
return (publication, Some(error));
|
|
}
|
|
};
|
|
let pruned_local = self.pruned_local_candidate_at(now);
|
|
self.remote.clear();
|
|
match pruned_local {
|
|
Ok(pruned_local) => {
|
|
let local_changed = pruned_local.is_some();
|
|
if let Some(pruned_local) = pruned_local {
|
|
self.local = pruned_local;
|
|
}
|
|
(self.project_publication_at(now, local_changed), None)
|
|
}
|
|
Err(error) => (self.project_publication_at(now, false), Some(error)),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn remove_remote_author_if_generation(
|
|
&mut self,
|
|
author_id: PeerId,
|
|
endpoint_generation: PeerEndpointGeneration,
|
|
) -> bool {
|
|
if self
|
|
.remote
|
|
.get(&author_id)
|
|
.is_none_or(|slice| slice.endpoint_generation != endpoint_generation)
|
|
{
|
|
return false;
|
|
}
|
|
self.remote.remove(&author_id).is_some()
|
|
}
|
|
|
|
#[must_use]
|
|
pub(crate) fn remote_author_state(&self, author_id: PeerId) -> Option<RemoteAuthorState> {
|
|
self.remote.get(&author_id).map(|slice| RemoteAuthorState {
|
|
endpoint_generation: slice.endpoint_generation,
|
|
runtime_session_id: slice.runtime_session_id,
|
|
revision: slice.snapshot.revision,
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
#[cfg(test)]
|
|
pub(crate) fn remote_author_count(&self) -> usize {
|
|
self.remote.len()
|
|
}
|
|
|
|
fn publish_local_at(
|
|
&mut self,
|
|
intent: CallToPlayLocalIntent,
|
|
display_name: String,
|
|
now: i64,
|
|
call_nonce: Option<CallNonce>,
|
|
event_nonce: EventNonce,
|
|
) -> Result<CallToPlayReceipt, CallToPlayMutationError> {
|
|
if now <= 0 {
|
|
return Err(CallToPlayMutationError::ClockUnavailable);
|
|
}
|
|
|
|
let CallToPlayLocalIntent { call_id, action } = intent;
|
|
let is_create = action.is_create();
|
|
let is_terminal = action.is_terminal();
|
|
let requires_creator_authority = action.requires_creator_authority();
|
|
let call_id = if is_create {
|
|
if call_id.is_some() {
|
|
return Err(CallToPlayMutationError::InvalidIntent(
|
|
"Create must not supply a call ID",
|
|
));
|
|
}
|
|
CallId::new(
|
|
self.local_peer_id,
|
|
call_nonce.ok_or(CallToPlayMutationError::EntropyUnavailable)?,
|
|
)
|
|
} else {
|
|
call_id.ok_or(CallToPlayMutationError::InvalidIntent(
|
|
"non-Create action requires a call ID",
|
|
))?
|
|
};
|
|
|
|
if requires_creator_authority && call_id.creator != self.local_peer_id {
|
|
return Err(CallToPlayMutationError::CreatorAuthorityRequired(call_id));
|
|
}
|
|
|
|
let retained_events = self.retained_local_events_at(now);
|
|
let event_at = retained_events
|
|
.last()
|
|
.map_or(now, |event| now.max(event.at));
|
|
if !is_create {
|
|
let Some(window) = self.call_window_at(call_id, now, &retained_events) else {
|
|
return Err(CallToPlayMutationError::UnknownOrExpiredCall(call_id));
|
|
};
|
|
if window.terminal_at.is_some() {
|
|
return Err(CallToPlayMutationError::CallAlreadyTerminal(call_id));
|
|
}
|
|
}
|
|
|
|
let mut candidate = CallToPlayAuthorSnapshot {
|
|
revision: self
|
|
.local
|
|
.revision
|
|
.checked_add(1)
|
|
.ok_or(CallToPlayMutationError::RevisionExhausted)?,
|
|
display_name,
|
|
events: retained_events,
|
|
};
|
|
candidate.events.push(CallToPlayAuthorEvent {
|
|
id: event_nonce,
|
|
call_id,
|
|
at: event_at,
|
|
action: action.into_wire(),
|
|
});
|
|
validate_author_snapshot(self.local_peer_id, &candidate)
|
|
.map_err(CallToPlayMutationError::InvalidSnapshot)?;
|
|
ensure_local_terminal_reserve(&candidate, is_terminal)?;
|
|
|
|
self.local = candidate;
|
|
Ok(CallToPlayReceipt {
|
|
call_id,
|
|
event_id: event_nonce,
|
|
revision: self.local.revision,
|
|
})
|
|
}
|
|
|
|
fn set_local_display_name_at(
|
|
&mut self,
|
|
display_name: String,
|
|
now: i64,
|
|
) -> Result<Option<CallToPlayMutation>, CallToPlayMutationError> {
|
|
let retained_events = self.retained_local_events_at(now);
|
|
if display_name == self.local.display_name && retained_events == self.local.events {
|
|
return Ok(None);
|
|
}
|
|
let candidate = CallToPlayAuthorSnapshot {
|
|
revision: self
|
|
.local
|
|
.revision
|
|
.checked_add(1)
|
|
.ok_or(CallToPlayMutationError::RevisionExhausted)?,
|
|
display_name,
|
|
events: retained_events,
|
|
};
|
|
validate_author_snapshot(self.local_peer_id, &candidate)
|
|
.map_err(CallToPlayMutationError::InvalidSnapshot)?;
|
|
self.local = candidate;
|
|
Ok(Some(CallToPlayMutation {
|
|
revision: self.local.revision,
|
|
}))
|
|
}
|
|
|
|
fn prune_local_at(
|
|
&mut self,
|
|
now: i64,
|
|
) -> Result<Option<CallToPlayMutation>, CallToPlayMutationError> {
|
|
let Some(candidate) = self.pruned_local_candidate_at(now)? else {
|
|
return Ok(None);
|
|
};
|
|
self.local = candidate;
|
|
Ok(Some(CallToPlayMutation {
|
|
revision: self.local.revision,
|
|
}))
|
|
}
|
|
|
|
fn pruned_local_candidate_at(
|
|
&self,
|
|
now: i64,
|
|
) -> Result<Option<CallToPlayAuthorSnapshot>, CallToPlayMutationError> {
|
|
let expired = self.expired_local_call_ids_at(now);
|
|
if expired.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let retained_events = self
|
|
.local
|
|
.events
|
|
.iter()
|
|
.filter(|event| !expired.contains(&event.call_id))
|
|
.cloned()
|
|
.collect();
|
|
let candidate = CallToPlayAuthorSnapshot {
|
|
revision: self
|
|
.local
|
|
.revision
|
|
.checked_add(1)
|
|
.ok_or(CallToPlayMutationError::RevisionExhausted)?,
|
|
display_name: self.local.display_name.clone(),
|
|
events: retained_events,
|
|
};
|
|
validate_author_snapshot(self.local_peer_id, &candidate)
|
|
.map_err(CallToPlayMutationError::InvalidSnapshot)?;
|
|
Ok(Some(candidate))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn local_snapshot_at(
|
|
&mut self,
|
|
now: i64,
|
|
) -> Result<CallToPlayAuthorSnapshot, CallToPlayMutationError> {
|
|
self.prune_local_at(now)?;
|
|
Ok(self.local.clone())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn view_at(&mut self, now: i64) -> Result<CallToPlayView, CallToPlayMutationError> {
|
|
Ok(self.publication_at(now)?.view)
|
|
}
|
|
|
|
fn publication_at(
|
|
&mut self,
|
|
now: i64,
|
|
) -> Result<CallToPlayPublication, CallToPlayMutationError> {
|
|
let local_changed = self.prune_local_at(now)?.is_some();
|
|
Ok(self.project_publication_at(now, local_changed))
|
|
}
|
|
|
|
fn responder_state_at(
|
|
&mut self,
|
|
now: i64,
|
|
) -> Result<(u64, Option<CallToPlayPublication>), CallToPlayMutationError> {
|
|
let local_changed = self.prune_local_at(now)?.is_some();
|
|
if !local_changed {
|
|
return Ok((self.local.revision, None));
|
|
}
|
|
let publication = self.project_publication_at(now, true);
|
|
Ok((publication.local_revision, Some(publication)))
|
|
}
|
|
|
|
fn project_publication_at(&mut self, now: i64, local_changed: bool) -> CallToPlayPublication {
|
|
let now = now.max(self.last_publication_at);
|
|
self.last_publication_at = now;
|
|
#[cfg(test)]
|
|
{
|
|
self.projection_count += 1;
|
|
}
|
|
CallToPlayPublication {
|
|
view: self.project_view_at(now),
|
|
local_revision: self.local.revision,
|
|
local_changed,
|
|
}
|
|
}
|
|
|
|
fn project_view_at(&self, now: i64) -> CallToPlayView {
|
|
let windows = self.call_windows();
|
|
let mut events = Vec::new();
|
|
Self::extend_visible_author_events(
|
|
self.local_peer_id,
|
|
&self.local,
|
|
now,
|
|
&windows,
|
|
&mut events,
|
|
);
|
|
for (author_id, slice) in &self.remote {
|
|
Self::extend_visible_author_events(
|
|
*author_id,
|
|
&slice.snapshot,
|
|
now,
|
|
&windows,
|
|
&mut events,
|
|
);
|
|
}
|
|
events.sort_by_key(|event| (event.at, event.call_id, event.author_id, event.id));
|
|
CallToPlayView { events }
|
|
}
|
|
|
|
fn extend_visible_author_events(
|
|
author_id: PeerId,
|
|
snapshot: &CallToPlayAuthorSnapshot,
|
|
now: i64,
|
|
windows: &HashMap<CallId, CallWindow>,
|
|
output: &mut Vec<CallToPlayViewEvent>,
|
|
) {
|
|
for event in &snapshot.events {
|
|
let Some(window) = windows.get(&event.call_id) else {
|
|
continue;
|
|
};
|
|
if !window.is_visible_at(now)
|
|
|| event.at < window.created_at
|
|
|| window
|
|
.terminal_at
|
|
.is_some_and(|terminal_at| event.at > terminal_at)
|
|
{
|
|
continue;
|
|
}
|
|
output.push(CallToPlayViewEvent {
|
|
id: event.id,
|
|
call_id: event.call_id,
|
|
author_id,
|
|
author_name: snapshot.display_name.clone(),
|
|
at: event.at,
|
|
action: event.action.clone(),
|
|
});
|
|
}
|
|
}
|
|
|
|
fn retained_local_events_at(&self, now: i64) -> Vec<CallToPlayAuthorEvent> {
|
|
let expired = self.expired_local_call_ids_at(now);
|
|
self.local
|
|
.events
|
|
.iter()
|
|
.filter(|event| !expired.contains(&event.call_id))
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
fn expired_local_call_ids_at(&self, now: i64) -> HashSet<CallId> {
|
|
let local_call_ids = self
|
|
.local
|
|
.events
|
|
.iter()
|
|
.map(|event| event.call_id)
|
|
.collect::<HashSet<_>>();
|
|
if local_call_ids.is_empty() {
|
|
return HashSet::new();
|
|
}
|
|
self.call_windows()
|
|
.into_iter()
|
|
.filter_map(|(call_id, window)| {
|
|
(local_call_ids.contains(&call_id) && !window.is_visible_at(now)).then_some(call_id)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn call_window_at(
|
|
&self,
|
|
call_id: CallId,
|
|
now: i64,
|
|
local_events: &[CallToPlayAuthorEvent],
|
|
) -> Option<CallWindow> {
|
|
let window = if call_id.creator == self.local_peer_id {
|
|
call_window_from_creator_events(call_id, local_events)
|
|
} else {
|
|
self.remote
|
|
.get(&call_id.creator)
|
|
.and_then(|slice| call_window_from_creator_events(call_id, &slice.snapshot.events))
|
|
}?;
|
|
window.is_visible_at(now).then_some(window)
|
|
}
|
|
|
|
fn call_windows(&self) -> HashMap<CallId, CallWindow> {
|
|
let mut windows = call_windows_from_creator(self.local_peer_id, &self.local.events);
|
|
for (author_id, slice) in &self.remote {
|
|
windows.extend(call_windows_from_creator(
|
|
*author_id,
|
|
&slice.snapshot.events,
|
|
));
|
|
}
|
|
windows
|
|
}
|
|
}
|
|
|
|
fn validate_author_snapshot(
|
|
author_id: PeerId,
|
|
snapshot: &CallToPlayAuthorSnapshot,
|
|
) -> Result<(), CallToPlayValidationError> {
|
|
snapshot
|
|
.validate()
|
|
.map_err(CallToPlayValidationError::Wire)?;
|
|
let encoded_size = serde_json::to_vec(snapshot)
|
|
.map_err(|_| CallToPlayValidationError::SnapshotEncoding)?
|
|
.len();
|
|
if encoded_size > MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES {
|
|
return Err(CallToPlayValidationError::SnapshotTooLarge {
|
|
actual: encoded_size,
|
|
maximum: MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES,
|
|
});
|
|
}
|
|
|
|
let mut event_ids = HashSet::with_capacity(snapshot.events.len());
|
|
let mut creator_calls = HashMap::<CallId, CreatorValidationState>::new();
|
|
if snapshot
|
|
.events
|
|
.windows(2)
|
|
.any(|events| events[0].at > events[1].at)
|
|
{
|
|
return Err(CallToPlayValidationError::NonMonotonicAuthorHistory);
|
|
}
|
|
for event in &snapshot.events {
|
|
if !event_ids.insert(event.id) {
|
|
return Err(CallToPlayValidationError::DuplicateEventId(event.id));
|
|
}
|
|
validate_event_fields(event)?;
|
|
if is_creator_only_action(&event.action) && event.call_id.creator != author_id {
|
|
return Err(CallToPlayValidationError::UnauthorizedAction {
|
|
event_id: event.id,
|
|
call_id: event.call_id,
|
|
});
|
|
}
|
|
if let CallToPlayAction::Create { deadline, .. } = event.action
|
|
&& creator_calls
|
|
.insert(
|
|
event.call_id,
|
|
CreatorValidationState {
|
|
created_at: event.at,
|
|
last_at: event.at,
|
|
deadline,
|
|
terminal: false,
|
|
},
|
|
)
|
|
.is_some()
|
|
{
|
|
return Err(CallToPlayValidationError::DuplicateCreate(event.call_id));
|
|
}
|
|
}
|
|
|
|
let mut seen_roots = HashSet::new();
|
|
for event in &snapshot.events {
|
|
if event.call_id.creator != author_id {
|
|
continue;
|
|
}
|
|
if matches!(event.action, CallToPlayAction::Create { .. }) {
|
|
seen_roots.insert(event.call_id);
|
|
continue;
|
|
}
|
|
if !seen_roots.contains(&event.call_id) {
|
|
return Err(CallToPlayValidationError::MissingCreatorRoot(event.call_id));
|
|
}
|
|
let state = creator_calls
|
|
.get_mut(&event.call_id)
|
|
.ok_or(CallToPlayValidationError::MissingCreatorRoot(event.call_id))?;
|
|
if event.at < state.created_at || event.at < state.last_at {
|
|
return Err(CallToPlayValidationError::NonMonotonicCallHistory(
|
|
event.call_id,
|
|
));
|
|
}
|
|
if state.terminal {
|
|
return Err(CallToPlayValidationError::ActionAfterTerminal(
|
|
event.call_id,
|
|
));
|
|
}
|
|
if let CallToPlayAction::AddTime { deadline } = event.action {
|
|
if deadline <= state.deadline {
|
|
return Err(CallToPlayValidationError::InvalidEvent {
|
|
event_id: event.id,
|
|
reason: "AddTime must extend the current deadline",
|
|
});
|
|
}
|
|
state.deadline = deadline;
|
|
}
|
|
if matches!(
|
|
event.action,
|
|
CallToPlayAction::Cancel | CallToPlayAction::Start
|
|
) {
|
|
state.terminal = true;
|
|
}
|
|
state.last_at = event.at;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_event_fields(event: &CallToPlayAuthorEvent) -> Result<(), CallToPlayValidationError> {
|
|
event.validate().map_err(CallToPlayValidationError::Wire)?;
|
|
if event.at <= 0 {
|
|
return Err(CallToPlayValidationError::InvalidEvent {
|
|
event_id: event.id,
|
|
reason: "event timestamp must be positive",
|
|
});
|
|
}
|
|
|
|
match &event.action {
|
|
CallToPlayAction::Create {
|
|
max_players,
|
|
scheduled_for,
|
|
deadline,
|
|
..
|
|
} => {
|
|
if !(2..=64).contains(max_players) {
|
|
return Err(CallToPlayValidationError::InvalidEvent {
|
|
event_id: event.id,
|
|
reason: "max players must be between 2 and 64",
|
|
});
|
|
}
|
|
if *deadline <= event.at {
|
|
return Err(CallToPlayValidationError::InvalidEvent {
|
|
event_id: event.id,
|
|
reason: "deadline must be after creation",
|
|
});
|
|
}
|
|
if scheduled_for.is_some_and(|scheduled| scheduled != *deadline) {
|
|
return Err(CallToPlayValidationError::InvalidEvent {
|
|
event_id: event.id,
|
|
reason: "scheduled call deadline must match its start time",
|
|
});
|
|
}
|
|
checked_retention_boundary(*deadline, EXPIRED_RETENTION_MS, event.id)?;
|
|
}
|
|
CallToPlayAction::Respond { ready_at } => {
|
|
if ready_at.is_some_and(|ready| ready < event.at) {
|
|
return Err(CallToPlayValidationError::InvalidEvent {
|
|
event_id: event.id,
|
|
reason: "ready time cannot be before the response",
|
|
});
|
|
}
|
|
}
|
|
CallToPlayAction::AddTime { deadline } => {
|
|
if *deadline <= event.at {
|
|
return Err(CallToPlayValidationError::InvalidEvent {
|
|
event_id: event.id,
|
|
reason: "extended deadline must be after the action",
|
|
});
|
|
}
|
|
checked_retention_boundary(*deadline, EXPIRED_RETENTION_MS, event.id)?;
|
|
}
|
|
CallToPlayAction::Cancel | CallToPlayAction::Start => {
|
|
checked_retention_boundary(event.at, TERMINAL_RETENTION_MS, event.id)?;
|
|
}
|
|
CallToPlayAction::Rsvp | CallToPlayAction::SendMessage { .. } | CallToPlayAction::Leave => {
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn checked_retention_boundary(
|
|
timestamp: i64,
|
|
retention: i64,
|
|
event_id: EventNonce,
|
|
) -> Result<i64, CallToPlayValidationError> {
|
|
timestamp
|
|
.checked_add(retention)
|
|
.ok_or(CallToPlayValidationError::InvalidEvent {
|
|
event_id,
|
|
reason: "timestamp overflows its retention boundary",
|
|
})
|
|
}
|
|
|
|
const fn is_creator_only_action(action: &CallToPlayAction) -> bool {
|
|
matches!(
|
|
action,
|
|
CallToPlayAction::Create { .. }
|
|
| CallToPlayAction::Cancel
|
|
| CallToPlayAction::Start
|
|
| CallToPlayAction::AddTime { .. }
|
|
)
|
|
}
|
|
|
|
fn ensure_local_terminal_reserve(
|
|
snapshot: &CallToPlayAuthorSnapshot,
|
|
terminal_action: bool,
|
|
) -> Result<(), CallToPlayMutationError> {
|
|
let event_limit = if terminal_action {
|
|
MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR
|
|
} else {
|
|
MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR - LOCAL_TERMINAL_EVENT_RESERVE
|
|
};
|
|
if snapshot.events.len() > event_limit {
|
|
return Err(CallToPlayMutationError::EventHistoryFull);
|
|
}
|
|
|
|
let byte_limit = if terminal_action {
|
|
MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES
|
|
} else {
|
|
MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES - LOCAL_TERMINAL_BYTE_RESERVE
|
|
};
|
|
let encoded_size = serde_json::to_vec(snapshot)
|
|
.map_err(|_| {
|
|
CallToPlayMutationError::InvalidSnapshot(CallToPlayValidationError::SnapshotEncoding)
|
|
})?
|
|
.len();
|
|
if encoded_size > byte_limit {
|
|
return Err(CallToPlayMutationError::EventHistoryFull);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct CreatorValidationState {
|
|
created_at: i64,
|
|
last_at: i64,
|
|
deadline: i64,
|
|
terminal: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct CallWindow {
|
|
created_at: i64,
|
|
deadline: i64,
|
|
terminal_at: Option<i64>,
|
|
}
|
|
|
|
impl CallWindow {
|
|
fn is_visible_at(self, now: i64) -> bool {
|
|
let boundary = self.terminal_at.map_or_else(
|
|
|| {
|
|
self.deadline
|
|
.checked_add(EXPIRED_RETENTION_MS)
|
|
.expect("validated deadline retention cannot overflow")
|
|
},
|
|
|terminal_at| {
|
|
terminal_at
|
|
.checked_add(TERMINAL_RETENTION_MS)
|
|
.expect("validated terminal retention cannot overflow")
|
|
},
|
|
);
|
|
now <= boundary
|
|
}
|
|
}
|
|
|
|
fn call_windows_from_creator(
|
|
creator: PeerId,
|
|
events: &[CallToPlayAuthorEvent],
|
|
) -> HashMap<CallId, CallWindow> {
|
|
let mut windows = HashMap::new();
|
|
for event in events {
|
|
if event.call_id.creator != creator {
|
|
continue;
|
|
}
|
|
match event.action {
|
|
CallToPlayAction::Create { deadline, .. } => {
|
|
windows.insert(
|
|
event.call_id,
|
|
CallWindow {
|
|
created_at: event.at,
|
|
deadline,
|
|
terminal_at: None,
|
|
},
|
|
);
|
|
}
|
|
CallToPlayAction::AddTime { deadline } => {
|
|
if let Some(window) = windows.get_mut(&event.call_id) {
|
|
window.deadline = deadline;
|
|
}
|
|
}
|
|
CallToPlayAction::Cancel | CallToPlayAction::Start => {
|
|
if let Some(window) = windows.get_mut(&event.call_id) {
|
|
window.terminal_at = Some(event.at);
|
|
}
|
|
}
|
|
CallToPlayAction::Respond { .. }
|
|
| CallToPlayAction::Rsvp
|
|
| CallToPlayAction::SendMessage { .. }
|
|
| CallToPlayAction::Leave => {}
|
|
}
|
|
}
|
|
windows
|
|
}
|
|
|
|
fn call_window_from_creator_events(
|
|
call_id: CallId,
|
|
events: &[CallToPlayAuthorEvent],
|
|
) -> Option<CallWindow> {
|
|
call_windows_from_creator(call_id.creator, events).remove(&call_id)
|
|
}
|
|
|
|
fn now_ms() -> Result<i64, CallToPlayMutationError> {
|
|
let millis = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_err(|_| CallToPlayMutationError::ClockUnavailable)?
|
|
.as_millis();
|
|
i64::try_from(millis).map_err(|_| CallToPlayMutationError::ClockUnavailable)
|
|
}
|
|
|
|
fn random_nonce_bytes() -> Result<[u8; 16], CallToPlayMutationError> {
|
|
let mut bytes = [0_u8; 16];
|
|
rustls::crypto::aws_lc_rs::default_provider()
|
|
.secure_random
|
|
.fill(&mut bytes)
|
|
.map_err(|_| CallToPlayMutationError::EntropyUnavailable)?;
|
|
Ok(bytes)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::net::SocketAddr;
|
|
|
|
use lanspread_proto::{
|
|
CallToPlayAuthorSnapshot,
|
|
ControlValidationError,
|
|
LibrarySnapshot,
|
|
MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS,
|
|
PeerEndpoint,
|
|
};
|
|
|
|
use super::*;
|
|
use crate::peer_db::PeerGameDB;
|
|
|
|
const NOW: i64 = 8_000_000_000_000;
|
|
|
|
fn peer(seed: u8) -> PeerId {
|
|
PeerId::from_bytes([seed; 32])
|
|
}
|
|
|
|
fn session(seed: u8) -> RuntimeSessionId {
|
|
RuntimeSessionId::from_bytes([seed; 16])
|
|
}
|
|
|
|
fn nonce(value: u128) -> [u8; 16] {
|
|
value.to_be_bytes()
|
|
}
|
|
|
|
fn event_nonce(value: u128) -> EventNonce {
|
|
EventNonce::from_bytes(nonce(value))
|
|
}
|
|
|
|
fn call_nonce(value: u128) -> CallNonce {
|
|
CallNonce::from_bytes(nonce(value))
|
|
}
|
|
|
|
fn store(local_peer: PeerId) -> CallToPlayStore {
|
|
CallToPlayStore::new(local_peer, session(1), "Local".to_owned())
|
|
.expect("test store should be valid")
|
|
}
|
|
|
|
fn snapshot(
|
|
revision: u64,
|
|
display_name: &str,
|
|
events: Vec<CallToPlayAuthorEvent>,
|
|
) -> CallToPlayAuthorSnapshot {
|
|
CallToPlayAuthorSnapshot {
|
|
revision,
|
|
display_name: display_name.to_owned(),
|
|
events,
|
|
}
|
|
}
|
|
|
|
fn create_event(
|
|
creator: PeerId,
|
|
call_id: CallId,
|
|
id: u128,
|
|
at: i64,
|
|
deadline: i64,
|
|
) -> CallToPlayAuthorEvent {
|
|
assert_eq!(creator, call_id.creator);
|
|
CallToPlayAuthorEvent {
|
|
id: event_nonce(id),
|
|
call_id,
|
|
at,
|
|
action: CallToPlayAction::Create {
|
|
game_id: "game".to_owned(),
|
|
max_players: 4,
|
|
scheduled_for: None,
|
|
deadline,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn action_event(
|
|
call_id: CallId,
|
|
id: u128,
|
|
at: i64,
|
|
action: CallToPlayAction,
|
|
) -> CallToPlayAuthorEvent {
|
|
CallToPlayAuthorEvent {
|
|
id: event_nonce(id),
|
|
call_id,
|
|
at,
|
|
action,
|
|
}
|
|
}
|
|
|
|
fn endpoint_generations(count: usize) -> Vec<PeerEndpointGeneration> {
|
|
let mut db = PeerGameDB::new();
|
|
let endpoint = PeerEndpoint::new(peer(250), SocketAddr::from(([127, 0, 0, 1], 31_337)));
|
|
(0..count)
|
|
.map(|index| {
|
|
let ticket = db
|
|
.begin_candidate_negotiation(endpoint)
|
|
.expect("candidate ticket");
|
|
db.commit_authenticated_snapshot(
|
|
endpoint,
|
|
ticket,
|
|
RuntimeSessionId::from_bytes(nonce(index as u128)),
|
|
Some(LibrarySnapshot {
|
|
revision: index as u64,
|
|
games: Vec::new(),
|
|
}),
|
|
)
|
|
.expect("commit should succeed")
|
|
.expect("ticket should remain current")
|
|
.endpoint_generation
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn prepare(
|
|
author: PeerId,
|
|
generation: PeerEndpointGeneration,
|
|
runtime_session_id: RuntimeSessionId,
|
|
snapshot: CallToPlayAuthorSnapshot,
|
|
) -> PreparedRemoteAuthor {
|
|
PreparedRemoteAuthor::prepare(author, generation, runtime_session_id, snapshot)
|
|
}
|
|
|
|
fn create_intent(deadline: i64) -> CallToPlayLocalIntent {
|
|
CallToPlayLocalIntent {
|
|
call_id: None,
|
|
action: CallToPlayLocalAction::Create {
|
|
game_id: "game".to_owned(),
|
|
max_players: 4,
|
|
scheduled_for: None,
|
|
deadline,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn intent(call_id: CallId, action: CallToPlayLocalAction) -> CallToPlayLocalIntent {
|
|
CallToPlayLocalIntent {
|
|
call_id: Some(call_id),
|
|
action,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn local_publish_generates_typed_ids_and_one_revision() {
|
|
let local = peer(1);
|
|
let mut store = store(local);
|
|
let receipt = store
|
|
.publish_local_at(
|
|
create_intent(NOW + 60_000),
|
|
"Alice".to_owned(),
|
|
NOW,
|
|
Some(call_nonce(7)),
|
|
event_nonce(8),
|
|
)
|
|
.expect("valid Create should publish");
|
|
|
|
assert_eq!(receipt.call_id, CallId::new(local, call_nonce(7)));
|
|
assert_eq!(receipt.event_id, event_nonce(8));
|
|
assert_eq!(receipt.revision, 1);
|
|
let local_snapshot = store.local_snapshot_at(NOW).expect("snapshot");
|
|
assert_eq!(local_snapshot.revision, 1);
|
|
assert_eq!(local_snapshot.display_name, "Alice");
|
|
assert_eq!(local_snapshot.events.len(), 1);
|
|
assert_eq!(local_snapshot.events[0].at, NOW);
|
|
|
|
let remote_call = CallId::new(peer(2), call_nonce(9));
|
|
let before = store.local_snapshot_at(NOW).expect("snapshot");
|
|
assert_eq!(
|
|
store.publish_local_at(
|
|
intent(remote_call, CallToPlayLocalAction::Start),
|
|
"Alice".to_owned(),
|
|
NOW + 1,
|
|
None,
|
|
event_nonce(10),
|
|
),
|
|
Err(CallToPlayMutationError::CreatorAuthorityRequired(
|
|
remote_call
|
|
))
|
|
);
|
|
assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before);
|
|
|
|
for invalid in [
|
|
CallToPlayLocalIntent {
|
|
call_id: Some(receipt.call_id),
|
|
action: CallToPlayLocalAction::Create {
|
|
game_id: "game".to_owned(),
|
|
max_players: 4,
|
|
scheduled_for: None,
|
|
deadline: NOW + 60_000,
|
|
},
|
|
},
|
|
CallToPlayLocalIntent {
|
|
call_id: None,
|
|
action: CallToPlayLocalAction::Rsvp,
|
|
},
|
|
] {
|
|
assert!(matches!(
|
|
store.publish_local_at(
|
|
invalid,
|
|
"Alice".to_owned(),
|
|
NOW + 1,
|
|
Some(call_nonce(11)),
|
|
event_nonce(12),
|
|
),
|
|
Err(CallToPlayMutationError::InvalidIntent(_))
|
|
));
|
|
assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn display_name_only_change_is_bounded_and_published() {
|
|
let mut store = store(peer(1));
|
|
assert_eq!(store.local.revision, 0);
|
|
assert_eq!(
|
|
store
|
|
.set_local_display_name_at("Alice".to_owned(), NOW)
|
|
.expect("valid name"),
|
|
Some(CallToPlayMutation { revision: 1 })
|
|
);
|
|
assert_eq!(
|
|
store
|
|
.set_local_display_name_at("Alice".to_owned(), NOW)
|
|
.expect("same name is a no-op"),
|
|
None
|
|
);
|
|
|
|
let before = store.local_snapshot_at(NOW).expect("snapshot");
|
|
assert!(
|
|
store
|
|
.set_local_display_name_at(
|
|
"x".repeat(MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS + 1),
|
|
NOW,
|
|
)
|
|
.is_err()
|
|
);
|
|
assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before);
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_reserve_keeps_one_settlement_slot() {
|
|
for terminal in [CallToPlayLocalAction::Start, CallToPlayLocalAction::Cancel] {
|
|
let local = peer(1);
|
|
let call_id = CallId::new(local, call_nonce(1));
|
|
let mut store = store(local);
|
|
let mut events = Vec::with_capacity(MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR - 1);
|
|
events.push(create_event(local, call_id, 1, NOW, NOW + 60_000));
|
|
events.extend((2..MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR as u128).map(|id| {
|
|
action_event(
|
|
call_id,
|
|
id,
|
|
NOW + i64::try_from(id).expect("event index fits in i64"),
|
|
CallToPlayAction::Rsvp,
|
|
)
|
|
}));
|
|
store.local.events = events;
|
|
store.local.revision = 1;
|
|
validate_author_snapshot(local, &store.local).expect("full reserved history is valid");
|
|
|
|
assert_eq!(
|
|
store.publish_local_at(
|
|
intent(call_id, CallToPlayLocalAction::Rsvp),
|
|
"Local".to_owned(),
|
|
NOW + 50_000,
|
|
None,
|
|
event_nonce(10_000),
|
|
),
|
|
Err(CallToPlayMutationError::EventHistoryFull)
|
|
);
|
|
let receipt = store
|
|
.publish_local_at(
|
|
intent(call_id, terminal),
|
|
"Local".to_owned(),
|
|
NOW + 50_000,
|
|
None,
|
|
event_nonce(10_001),
|
|
)
|
|
.expect("terminal action must use the reserved slot");
|
|
assert_eq!(receipt.revision, 2);
|
|
assert_eq!(store.local.events.len(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR);
|
|
assert_eq!(
|
|
store
|
|
.set_local_display_name_at("Renamed".to_owned(), NOW + 50_001)
|
|
.expect("a non-event mutation does not consume another event slot"),
|
|
Some(CallToPlayMutation { revision: 3 })
|
|
);
|
|
assert_eq!(store.local.events.len(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pruning_keeps_exact_boundaries_then_removes_without_tombstones_and_bumps() {
|
|
let local = peer(1);
|
|
let mut unresolved = store(local);
|
|
let deadline = NOW + 1_000;
|
|
unresolved
|
|
.publish_local_at(
|
|
create_intent(deadline),
|
|
"Local".to_owned(),
|
|
NOW,
|
|
Some(call_nonce(1)),
|
|
event_nonce(1),
|
|
)
|
|
.expect("create");
|
|
assert_eq!(
|
|
unresolved
|
|
.local_snapshot_at(deadline + EXPIRED_RETENTION_MS)
|
|
.expect("exact boundary")
|
|
.events
|
|
.len(),
|
|
1
|
|
);
|
|
let pruned = unresolved
|
|
.local_snapshot_at(deadline + EXPIRED_RETENTION_MS + 1)
|
|
.expect("past boundary");
|
|
assert!(pruned.events.is_empty());
|
|
assert_eq!(pruned.revision, 2);
|
|
|
|
let mut terminal = store(local);
|
|
let create = terminal
|
|
.publish_local_at(
|
|
create_intent(NOW + 60_000),
|
|
"Local".to_owned(),
|
|
NOW,
|
|
Some(call_nonce(2)),
|
|
event_nonce(2),
|
|
)
|
|
.expect("create");
|
|
terminal
|
|
.publish_local_at(
|
|
intent(create.call_id, CallToPlayLocalAction::Start),
|
|
"Local".to_owned(),
|
|
NOW + 10,
|
|
None,
|
|
event_nonce(3),
|
|
)
|
|
.expect("start");
|
|
assert_eq!(
|
|
terminal
|
|
.local_snapshot_at(NOW + 10 + TERMINAL_RETENTION_MS)
|
|
.expect("exact terminal boundary")
|
|
.events
|
|
.len(),
|
|
2
|
|
);
|
|
let pruned = terminal
|
|
.local_snapshot_at(NOW + 10 + TERMINAL_RETENTION_MS + 1)
|
|
.expect("past terminal boundary");
|
|
assert!(pruned.events.is_empty(), "no terminal tombstone remains");
|
|
assert_eq!(pruned.revision, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn responder_reads_project_only_when_local_pruning_changes_state() {
|
|
let local = peer(1);
|
|
let deadline = NOW + 1_000;
|
|
let boundary = deadline + EXPIRED_RETENTION_MS;
|
|
let mut store = store(local);
|
|
store
|
|
.publish_local_at(
|
|
create_intent(deadline),
|
|
"Local".to_owned(),
|
|
NOW,
|
|
Some(call_nonce(1)),
|
|
event_nonce(1),
|
|
)
|
|
.expect("create");
|
|
|
|
let (revision, publication) = store
|
|
.responder_state_at(boundary)
|
|
.expect("exact-boundary Pong state");
|
|
assert_eq!(revision, 1);
|
|
assert!(publication.is_none());
|
|
assert_eq!(store.projection_count, 0);
|
|
|
|
let (snapshot, revision, publication) = store
|
|
.local_responder_snapshot_at(boundary)
|
|
.expect("exact-boundary Hello state");
|
|
assert_eq!(snapshot.revision, revision);
|
|
assert!(publication.is_none());
|
|
assert_eq!(store.projection_count, 0);
|
|
|
|
let (revision, publication) = store
|
|
.responder_state_at(boundary + 1)
|
|
.expect("expired Pong state");
|
|
let publication = publication.expect("a local prune requires one full publication");
|
|
assert_eq!(revision, 2);
|
|
assert_eq!(publication.local_revision, revision);
|
|
assert!(publication.local_changed);
|
|
assert!(publication.view.events.is_empty());
|
|
assert_eq!(store.projection_count, 1);
|
|
|
|
let (snapshot, revision, publication) = store
|
|
.local_responder_snapshot_at(boundary + 1)
|
|
.expect("already-pruned Hello state");
|
|
assert_eq!(snapshot.revision, revision);
|
|
assert_eq!(revision, 2);
|
|
assert!(publication.is_none());
|
|
assert_eq!(store.projection_count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn responder_snapshot_excludes_remote_author_slices_from_full_local_view() {
|
|
let local = peer(1);
|
|
let participant = peer(2);
|
|
let generation = endpoint_generations(1)[0];
|
|
let mut store = store(local);
|
|
let create = store
|
|
.publish_local_at(
|
|
create_intent(NOW + 60_000),
|
|
"Alice".to_owned(),
|
|
NOW,
|
|
Some(call_nonce(1)),
|
|
event_nonce(1),
|
|
)
|
|
.expect("local Create should publish");
|
|
assert!(matches!(
|
|
store.observe_prepared_remote(prepare(
|
|
participant,
|
|
generation,
|
|
session(2),
|
|
snapshot(
|
|
1,
|
|
"Bob",
|
|
vec![action_event(
|
|
create.call_id,
|
|
2,
|
|
NOW + 1,
|
|
CallToPlayAction::Rsvp,
|
|
)],
|
|
),
|
|
)),
|
|
ObserveRemoteAuthorOutcome::Applied { .. }
|
|
));
|
|
|
|
let full_view = store.view_at(NOW + 2).expect("full local view");
|
|
assert_eq!(full_view.events.len(), 2);
|
|
assert_eq!(full_view.events[0].author_id, local);
|
|
assert_eq!(full_view.events[1].author_id, participant);
|
|
|
|
let (responder_snapshot, revision, publication) = store
|
|
.local_responder_snapshot_at(NOW + 2)
|
|
.expect("local responder snapshot");
|
|
assert_eq!(responder_snapshot.revision, revision);
|
|
assert!(publication.is_none());
|
|
assert_eq!(
|
|
responder_snapshot
|
|
.events
|
|
.iter()
|
|
.map(|event| event.id)
|
|
.collect::<Vec<_>>(),
|
|
[event_nonce(1)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bulk_remote_clear_preserves_local_author_and_projects_a_local_only_view() {
|
|
let creator = peer(1);
|
|
let participant = peer(2);
|
|
let local = peer(3);
|
|
let generations = endpoint_generations(2);
|
|
let remote_call = CallId::new(creator, call_nonce(1));
|
|
let mut store = store(local);
|
|
store.observe_prepared_remote(prepare(
|
|
creator,
|
|
generations[0],
|
|
session(2),
|
|
snapshot(
|
|
1,
|
|
"Alice",
|
|
vec![create_event(creator, remote_call, 1, NOW, NOW + 60_000)],
|
|
),
|
|
));
|
|
let local_call = store
|
|
.publish_local_at(
|
|
create_intent(NOW + 60_000),
|
|
"Local".to_owned(),
|
|
NOW + 1,
|
|
Some(call_nonce(2)),
|
|
event_nonce(2),
|
|
)
|
|
.expect("local Create")
|
|
.call_id;
|
|
store
|
|
.publish_local_at(
|
|
intent(remote_call, CallToPlayLocalAction::Rsvp),
|
|
"Local".to_owned(),
|
|
NOW + 2,
|
|
None,
|
|
event_nonce(3),
|
|
)
|
|
.expect("local RSVP to the remote call");
|
|
store.observe_prepared_remote(prepare(
|
|
participant,
|
|
generations[1],
|
|
session(3),
|
|
snapshot(
|
|
1,
|
|
"Bob",
|
|
vec![action_event(
|
|
remote_call,
|
|
4,
|
|
NOW + 3,
|
|
CallToPlayAction::Rsvp,
|
|
)],
|
|
),
|
|
));
|
|
assert_eq!(store.remote_author_count(), 2);
|
|
assert_eq!(
|
|
store.view_at(NOW + 4).expect("combined view").events.len(),
|
|
4
|
|
);
|
|
|
|
let local_before = store.local.clone();
|
|
let projections_before = store.projection_count;
|
|
let (publication, diagnostic) = store.clear_remote_authors_and_project_at(Ok(NOW + 4));
|
|
|
|
assert_eq!(diagnostic, None);
|
|
assert_eq!(store.local, local_before);
|
|
assert_eq!(publication.local_revision, local_before.revision);
|
|
assert!(!publication.local_changed);
|
|
assert_eq!(store.remote_author_count(), 0);
|
|
assert!(store.remote_author_state(creator).is_none());
|
|
assert!(store.remote_author_state(participant).is_none());
|
|
assert_eq!(store.projection_count, projections_before + 1);
|
|
assert_eq!(publication.view.events.len(), 1);
|
|
assert_eq!(publication.view.events[0].call_id, local_call);
|
|
assert_eq!(publication.view.events[0].author_id, local);
|
|
assert_eq!(
|
|
store
|
|
.local
|
|
.events
|
|
.iter()
|
|
.map(|event| event.id)
|
|
.collect::<Vec<_>>(),
|
|
[event_nonce(2), event_nonce(3)],
|
|
"the root-gated view must not erase the local author slice"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bulk_remote_clear_falls_back_after_clock_or_prune_failure() {
|
|
let generation = endpoint_generations(1)[0];
|
|
let remote = peer(1);
|
|
let local = peer(2);
|
|
let other_remote = peer(3);
|
|
let remote_call = CallId::new(remote, call_nonce(1));
|
|
|
|
let mut clock_failure = store(local);
|
|
let local_call = clock_failure
|
|
.publish_local_at(
|
|
create_intent(NOW + 60_000),
|
|
"Local".to_owned(),
|
|
NOW,
|
|
Some(call_nonce(2)),
|
|
event_nonce(1),
|
|
)
|
|
.expect("local Create")
|
|
.call_id;
|
|
clock_failure.observe_prepared_remote(prepare(
|
|
remote,
|
|
generation,
|
|
session(2),
|
|
snapshot(
|
|
1,
|
|
"Remote",
|
|
vec![create_event(remote, remote_call, 2, NOW, NOW + 60_000)],
|
|
),
|
|
));
|
|
clock_failure.observe_prepared_remote(prepare(
|
|
other_remote,
|
|
generation,
|
|
session(3),
|
|
snapshot(0, "Other", Vec::new()),
|
|
));
|
|
assert_eq!(clock_failure.remote_author_count(), 2);
|
|
let local_before = clock_failure.local.clone();
|
|
let (publication, diagnostic) = clock_failure
|
|
.clear_remote_authors_and_project_at(Err(CallToPlayMutationError::ClockUnavailable));
|
|
assert_eq!(diagnostic, Some(CallToPlayMutationError::ClockUnavailable));
|
|
assert_eq!(clock_failure.local, local_before);
|
|
assert_eq!(clock_failure.remote_author_count(), 0);
|
|
assert_eq!(publication.local_revision, local_before.revision);
|
|
assert!(!publication.local_changed);
|
|
assert_eq!(publication.view.events.len(), 1);
|
|
assert_eq!(publication.view.events[0].call_id, local_call);
|
|
|
|
let deadline = NOW + 100;
|
|
let mut prune_failure = store(local);
|
|
prune_failure
|
|
.publish_local_at(
|
|
create_intent(deadline),
|
|
"Local".to_owned(),
|
|
NOW,
|
|
Some(call_nonce(3)),
|
|
event_nonce(3),
|
|
)
|
|
.expect("expiring local Create");
|
|
prune_failure.local.revision = u64::MAX;
|
|
prune_failure.observe_prepared_remote(prepare(
|
|
remote,
|
|
generation,
|
|
session(2),
|
|
snapshot(
|
|
1,
|
|
"Remote",
|
|
vec![create_event(remote, remote_call, 4, NOW, NOW + 60_000)],
|
|
),
|
|
));
|
|
prune_failure.observe_prepared_remote(prepare(
|
|
other_remote,
|
|
generation,
|
|
session(3),
|
|
snapshot(0, "Other", Vec::new()),
|
|
));
|
|
assert_eq!(prune_failure.remote_author_count(), 2);
|
|
let local_before = prune_failure.local.clone();
|
|
let (publication, diagnostic) = prune_failure
|
|
.clear_remote_authors_and_project_at(Ok(deadline + EXPIRED_RETENTION_MS + 1));
|
|
assert_eq!(diagnostic, Some(CallToPlayMutationError::RevisionExhausted));
|
|
assert_eq!(prune_failure.local, local_before);
|
|
assert_eq!(prune_failure.remote_author_count(), 0);
|
|
assert_eq!(publication.local_revision, u64::MAX);
|
|
assert!(!publication.local_changed);
|
|
assert!(publication.view.events.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn bulk_remote_clear_is_idempotent_after_one_normal_local_prune() {
|
|
let generation = endpoint_generations(1)[0];
|
|
let remote = peer(1);
|
|
let local = peer(2);
|
|
let deadline = NOW + 100;
|
|
let mut store = store(local);
|
|
store
|
|
.publish_local_at(
|
|
create_intent(deadline),
|
|
"Local".to_owned(),
|
|
NOW,
|
|
Some(call_nonce(1)),
|
|
event_nonce(1),
|
|
)
|
|
.expect("expiring local Create");
|
|
let remote_call = CallId::new(remote, call_nonce(2));
|
|
store.observe_prepared_remote(prepare(
|
|
remote,
|
|
generation,
|
|
session(2),
|
|
snapshot(
|
|
1,
|
|
"Remote",
|
|
vec![create_event(remote, remote_call, 2, NOW, NOW + 60_000)],
|
|
),
|
|
));
|
|
let clear_at = deadline + EXPIRED_RETENTION_MS + 1;
|
|
|
|
let (first, first_diagnostic) = store.clear_remote_authors_and_project_at(Ok(clear_at));
|
|
assert_eq!(first_diagnostic, None);
|
|
assert!(first.local_changed);
|
|
assert_eq!(first.local_revision, 2);
|
|
assert!(first.view.events.is_empty());
|
|
assert_eq!(store.remote_author_count(), 0);
|
|
let local_after_first = store.local.clone();
|
|
|
|
let (second, second_diagnostic) = store.clear_remote_authors_and_project_at(Ok(clear_at));
|
|
assert_eq!(second_diagnostic, None);
|
|
assert!(!second.local_changed);
|
|
assert_eq!(second.local_revision, 2);
|
|
assert_eq!(second.view, first.view);
|
|
assert_eq!(store.local, local_after_first);
|
|
assert_eq!(store.remote_author_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn prepared_publication_freezes_one_boundary_and_fails_before_remote_commit() {
|
|
let local = peer(1);
|
|
let deadline = NOW + 1_000;
|
|
let mut pruning_store = store(local);
|
|
pruning_store
|
|
.publish_local_at(
|
|
create_intent(deadline),
|
|
"Local".to_owned(),
|
|
NOW,
|
|
Some(call_nonce(1)),
|
|
event_nonce(1),
|
|
)
|
|
.expect("create");
|
|
|
|
let exact = pruning_store
|
|
.prepare_publication_at(deadline + EXPIRED_RETENTION_MS)
|
|
.expect("exact boundary preparation");
|
|
assert!(!exact.local_changed());
|
|
let publication = pruning_store.view_from_prepared(exact);
|
|
assert_eq!(publication.view.events.len(), 1);
|
|
assert_eq!(publication.local_revision, 1);
|
|
|
|
let expired = pruning_store
|
|
.prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1)
|
|
.expect("past-boundary preparation");
|
|
assert!(expired.local_changed());
|
|
assert_eq!(
|
|
pruning_store.local.revision, 1,
|
|
"preparation is transactional"
|
|
);
|
|
drop(expired);
|
|
assert_eq!(
|
|
pruning_store.local.revision, 1,
|
|
"dropping a token is a no-op"
|
|
);
|
|
assert_eq!(pruning_store.local.events.len(), 1);
|
|
let expired = pruning_store
|
|
.prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1)
|
|
.expect("repeat past-boundary preparation");
|
|
let publication = pruning_store.view_from_prepared(expired);
|
|
assert!(publication.view.events.is_empty());
|
|
assert_eq!(publication.local_revision, 2);
|
|
assert!(publication.local_changed);
|
|
|
|
let mut exhausted = store(local);
|
|
exhausted
|
|
.publish_local_at(
|
|
create_intent(deadline),
|
|
"Local".to_owned(),
|
|
NOW,
|
|
Some(call_nonce(2)),
|
|
event_nonce(2),
|
|
)
|
|
.expect("create");
|
|
exhausted.local.revision = u64::MAX;
|
|
let before = exhausted.local.clone();
|
|
assert!(matches!(
|
|
exhausted.prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1),
|
|
Err(CallToPlayMutationError::RevisionExhausted)
|
|
));
|
|
assert_eq!(exhausted.local, before);
|
|
}
|
|
|
|
#[test]
|
|
fn an_old_prepared_projection_cannot_overwrite_a_newer_timer_view() {
|
|
let author = peer(1);
|
|
let generation = endpoint_generations(1)[0];
|
|
let call_id = CallId::new(author, call_nonce(1));
|
|
let deadline = NOW + 100;
|
|
let mut store = store(peer(2));
|
|
store.observe_prepared_remote(prepare(
|
|
author,
|
|
generation,
|
|
session(2),
|
|
snapshot(
|
|
1,
|
|
"Alice",
|
|
vec![create_event(author, call_id, 1, NOW, deadline)],
|
|
),
|
|
));
|
|
|
|
let old = store
|
|
.prepare_publication_at(deadline + EXPIRED_RETENTION_MS)
|
|
.expect("old projection");
|
|
let newer = store
|
|
.publication_at(deadline + EXPIRED_RETENTION_MS + 1)
|
|
.expect("newer projection");
|
|
assert!(newer.view.events.is_empty());
|
|
let replayed = store.view_from_prepared(old);
|
|
assert!(replayed.view.events.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn add_time_recovers_during_the_five_minute_window() {
|
|
let local = peer(1);
|
|
let mut store = store(local);
|
|
let deadline = NOW + 100;
|
|
let created = store
|
|
.publish_local_at(
|
|
create_intent(deadline),
|
|
"Local".to_owned(),
|
|
NOW,
|
|
Some(call_nonce(1)),
|
|
event_nonce(1),
|
|
)
|
|
.expect("create");
|
|
let recovery_time = deadline + EXPIRED_RETENTION_MS;
|
|
store
|
|
.publish_local_at(
|
|
intent(
|
|
created.call_id,
|
|
CallToPlayLocalAction::AddTime {
|
|
deadline: recovery_time + 60_000,
|
|
},
|
|
),
|
|
"Local".to_owned(),
|
|
recovery_time,
|
|
None,
|
|
event_nonce(2),
|
|
)
|
|
.expect("AddTime at the exact recovery boundary should revive the call");
|
|
assert_eq!(
|
|
store
|
|
.local_snapshot_at(deadline + EXPIRED_RETENTION_MS + 1)
|
|
.expect("snapshot")
|
|
.events
|
|
.len(),
|
|
2
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn participant_slice_is_retained_hidden_and_creator_departure_hides_call() {
|
|
let local = peer(3);
|
|
let creator = peer(1);
|
|
let participant = peer(2);
|
|
let call_id = CallId::new(creator, call_nonce(1));
|
|
let generations = endpoint_generations(3);
|
|
let mut store = store(local);
|
|
|
|
let participant_event = action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp);
|
|
assert!(matches!(
|
|
store.observe_prepared_remote(prepare(
|
|
participant,
|
|
generations[0],
|
|
session(2),
|
|
snapshot(1, "Same name", vec![participant_event.clone()]),
|
|
)),
|
|
ObserveRemoteAuthorOutcome::Applied { .. }
|
|
));
|
|
assert!(store.view_at(NOW + 2).expect("view").events.is_empty());
|
|
assert!(store.remote_author_state(participant).is_some());
|
|
|
|
let root = create_event(creator, call_id, 1, NOW, NOW + 60_000);
|
|
store.observe_prepared_remote(prepare(
|
|
creator,
|
|
generations[1],
|
|
session(3),
|
|
snapshot(1, "Same name", vec![root.clone()]),
|
|
));
|
|
let view = store.view_at(NOW + 2).expect("view");
|
|
assert_eq!(view.events.len(), 2);
|
|
assert_eq!(view.events[0].author_id, creator);
|
|
assert_eq!(view.events[1].author_id, participant);
|
|
assert_eq!(view.events[0].author_name, view.events[1].author_name);
|
|
|
|
assert!(store.remove_remote_author_if_generation(participant, generations[0]));
|
|
let view = store.view_at(NOW + 2).expect("participant departure view");
|
|
assert_eq!(view.events.len(), 1);
|
|
assert_eq!(view.events[0].author_id, creator);
|
|
assert!(matches!(
|
|
store.observe_prepared_remote(prepare(
|
|
participant,
|
|
generations[0],
|
|
session(2),
|
|
snapshot(1, "Same name", vec![participant_event]),
|
|
)),
|
|
ObserveRemoteAuthorOutcome::Applied { .. }
|
|
));
|
|
|
|
assert!(!store.remove_remote_author_if_generation(creator, generations[0]));
|
|
assert!(store.remove_remote_author_if_generation(creator, generations[1]));
|
|
assert!(store.view_at(NOW + 2).expect("view").events.is_empty());
|
|
assert!(store.remote_author_state(participant).is_some());
|
|
}
|
|
|
|
#[test]
|
|
#[expect(
|
|
clippy::too_many_lines,
|
|
reason = "one state-transition matrix keeps its shared setup and assertions together"
|
|
)]
|
|
fn same_session_stale_and_invalid_preserve_and_rebind_but_new_invalid_clears() {
|
|
let local = peer(9);
|
|
let author = peer(1);
|
|
let call_id = CallId::new(author, call_nonce(1));
|
|
let generations = endpoint_generations(6);
|
|
let mut store = store(local);
|
|
let root = create_event(author, call_id, 1, NOW, NOW + 60_000);
|
|
|
|
assert_eq!(
|
|
store.observe_prepared_remote(prepare(
|
|
author,
|
|
generations[0],
|
|
session(2),
|
|
snapshot(5, "Alice", vec![root.clone()]),
|
|
)),
|
|
ObserveRemoteAuthorOutcome::Applied {
|
|
session_changed: true
|
|
}
|
|
);
|
|
assert_eq!(
|
|
store.observe_prepared_remote(prepare(
|
|
author,
|
|
generations[1],
|
|
session(2),
|
|
snapshot(4, "Lower", vec![root.clone()]),
|
|
)),
|
|
ObserveRemoteAuthorOutcome::IgnoredStale {
|
|
generation_rebound: true
|
|
}
|
|
);
|
|
assert_eq!(
|
|
store
|
|
.remote_author_state(author)
|
|
.expect("remote author should remain")
|
|
.revision,
|
|
5
|
|
);
|
|
|
|
assert_eq!(
|
|
store.observe_prepared_remote(prepare(
|
|
author,
|
|
generations[1],
|
|
session(2),
|
|
snapshot(5, "Alice", vec![root.clone()]),
|
|
)),
|
|
ObserveRemoteAuthorOutcome::Unchanged {
|
|
generation_rebound: false
|
|
}
|
|
);
|
|
|
|
assert!(matches!(
|
|
store.observe_prepared_remote(prepare(
|
|
author,
|
|
generations[2],
|
|
session(2),
|
|
snapshot(5, "Equal conflict", vec![root]),
|
|
)),
|
|
ObserveRemoteAuthorOutcome::EqualRevisionConflict {
|
|
generation_rebound: true
|
|
}
|
|
));
|
|
assert_eq!(
|
|
store.view_at(NOW).expect("view").events[0].author_name,
|
|
"Alice"
|
|
);
|
|
|
|
assert!(matches!(
|
|
store.observe_prepared_remote(prepare(
|
|
author,
|
|
generations[3],
|
|
session(2),
|
|
snapshot(6, " ", Vec::new()),
|
|
)),
|
|
ObserveRemoteAuthorOutcome::InvalidPreserved {
|
|
generation_rebound: true,
|
|
..
|
|
}
|
|
));
|
|
assert_eq!(
|
|
store
|
|
.remote_author_state(author)
|
|
.expect("preserved")
|
|
.endpoint_generation,
|
|
generations[3]
|
|
);
|
|
assert!(!store.remove_remote_author_if_generation(author, generations[2]));
|
|
|
|
assert!(matches!(
|
|
store.observe_prepared_remote(prepare(
|
|
author,
|
|
generations[4],
|
|
session(3),
|
|
snapshot(0, " ", Vec::new()),
|
|
)),
|
|
ObserveRemoteAuthorOutcome::InvalidCleared(_)
|
|
));
|
|
assert!(store.remote_author_state(author).is_none());
|
|
|
|
assert!(matches!(
|
|
store.observe_prepared_remote(prepare(
|
|
author,
|
|
generations[5],
|
|
session(3),
|
|
snapshot(0, "Restarted", Vec::new()),
|
|
)),
|
|
ObserveRemoteAuthorOutcome::Applied {
|
|
session_changed: true
|
|
}
|
|
));
|
|
assert_eq!(
|
|
store
|
|
.remote_author_state(author)
|
|
.expect("restarted remote author should exist")
|
|
.revision,
|
|
0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[expect(
|
|
clippy::too_many_lines,
|
|
reason = "one author-isolation matrix keeps its shared fixtures and assertions together"
|
|
)]
|
|
fn invalid_duplicates_order_authority_and_bytes_are_author_isolated() {
|
|
let generations = endpoint_generations(2);
|
|
let author = peer(1);
|
|
let other_creator = peer(2);
|
|
let own_call = CallId::new(author, call_nonce(1));
|
|
let other_call = CallId::new(other_creator, call_nonce(2));
|
|
|
|
let duplicate = action_event(other_call, 1, NOW, CallToPlayAction::Rsvp);
|
|
let prepared = prepare(
|
|
author,
|
|
generations[0],
|
|
session(2),
|
|
snapshot(1, "Alice", vec![duplicate.clone(), duplicate]),
|
|
);
|
|
assert!(matches!(
|
|
prepared.validation_error(),
|
|
Some(CallToPlayValidationError::DuplicateEventId(_))
|
|
));
|
|
|
|
let prepared = prepare(
|
|
author,
|
|
generations[0],
|
|
session(2),
|
|
snapshot(
|
|
1,
|
|
"Alice",
|
|
vec![
|
|
action_event(other_call, 1, NOW + 1, CallToPlayAction::Rsvp),
|
|
action_event(other_call, 2, NOW, CallToPlayAction::Leave),
|
|
],
|
|
),
|
|
);
|
|
assert_eq!(
|
|
prepared.validation_error(),
|
|
Some(&CallToPlayValidationError::NonMonotonicAuthorHistory)
|
|
);
|
|
|
|
let prepared = prepare(
|
|
author,
|
|
generations[0],
|
|
session(2),
|
|
snapshot(
|
|
1,
|
|
"Alice",
|
|
vec![create_event(other_creator, other_call, 1, NOW, NOW + 1_000)],
|
|
),
|
|
);
|
|
assert!(matches!(
|
|
prepared.validation_error(),
|
|
Some(CallToPlayValidationError::UnauthorizedAction { .. })
|
|
));
|
|
|
|
let overflow = prepare(
|
|
author,
|
|
generations[0],
|
|
session(2),
|
|
snapshot(
|
|
1,
|
|
"Alice",
|
|
vec![create_event(author, own_call, 9, NOW, i64::MAX)],
|
|
),
|
|
);
|
|
assert!(matches!(
|
|
overflow.validation_error(),
|
|
Some(CallToPlayValidationError::InvalidEvent {
|
|
reason: "timestamp overflows its retention boundary",
|
|
..
|
|
})
|
|
));
|
|
|
|
let mut oversized_events = Vec::with_capacity(MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR);
|
|
let text = "😀".repeat(250);
|
|
for id in 0..MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR as u128 {
|
|
oversized_events.push(action_event(
|
|
other_call,
|
|
id,
|
|
NOW + i64::try_from(id).expect("event index fits in i64"),
|
|
CallToPlayAction::SendMessage { text: text.clone() },
|
|
));
|
|
}
|
|
let oversized = prepare(
|
|
author,
|
|
generations[0],
|
|
session(2),
|
|
snapshot(1, "Alice", oversized_events),
|
|
);
|
|
assert!(matches!(
|
|
oversized.validation_error(),
|
|
Some(CallToPlayValidationError::Wire(
|
|
ControlValidationError::EncodedTooLarge { .. }
|
|
))
|
|
));
|
|
|
|
let mut store = store(peer(9));
|
|
let valid_root = create_event(author, own_call, 3, NOW, NOW + 60_000);
|
|
store.observe_prepared_remote(prepare(
|
|
author,
|
|
generations[0],
|
|
session(2),
|
|
snapshot(1, "Alice", vec![valid_root]),
|
|
));
|
|
assert!(matches!(
|
|
store.observe_prepared_remote(oversized),
|
|
ObserveRemoteAuthorOutcome::InvalidPreserved { .. }
|
|
));
|
|
assert_eq!(
|
|
store
|
|
.remote_author_state(author)
|
|
.expect("valid remote author should remain")
|
|
.revision,
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_expiry_hides_without_mutating_the_accepted_revision() {
|
|
let generation = endpoint_generations(1)[0];
|
|
let author = peer(1);
|
|
let call_id = CallId::new(author, call_nonce(1));
|
|
let deadline = NOW + 100;
|
|
let mut store = store(peer(2));
|
|
store.observe_prepared_remote(prepare(
|
|
author,
|
|
generation,
|
|
session(2),
|
|
snapshot(
|
|
7,
|
|
"Alice",
|
|
vec![create_event(author, call_id, 1, NOW, deadline)],
|
|
),
|
|
));
|
|
assert_eq!(
|
|
store
|
|
.view_at(deadline + EXPIRED_RETENTION_MS)
|
|
.expect("exact boundary")
|
|
.events
|
|
.len(),
|
|
1
|
|
);
|
|
assert!(
|
|
store
|
|
.view_at(deadline + EXPIRED_RETENTION_MS + 1)
|
|
.expect("expired view")
|
|
.events
|
|
.is_empty()
|
|
);
|
|
assert_eq!(
|
|
store
|
|
.remote_author_state(author)
|
|
.expect("expired remote author should remain cached")
|
|
.revision,
|
|
7
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_author_capacity_does_not_consume_local_capacity() {
|
|
let generation = endpoint_generations(1)[0];
|
|
let local = peer(200);
|
|
let mut store = store(local);
|
|
for seed in
|
|
1..u8::try_from(MAX_CALL_TO_PLAY_AUTHORS).expect("author limit fits in one byte")
|
|
{
|
|
assert!(matches!(
|
|
store.observe_prepared_remote(prepare(
|
|
peer(seed),
|
|
generation,
|
|
session(seed),
|
|
snapshot(0, "Peer", Vec::new()),
|
|
)),
|
|
ObserveRemoteAuthorOutcome::Applied { .. }
|
|
));
|
|
}
|
|
assert_eq!(
|
|
store.remote_author_count() + 1,
|
|
MAX_CALL_TO_PLAY_AUTHORS,
|
|
"the local author counts toward the total-author cap"
|
|
);
|
|
assert_eq!(
|
|
store.observe_prepared_remote(prepare(
|
|
peer(100),
|
|
generation,
|
|
session(100),
|
|
snapshot(0, "Extra", Vec::new()),
|
|
)),
|
|
ObserveRemoteAuthorOutcome::AtCapacity
|
|
);
|
|
|
|
let receipt = store
|
|
.publish_local_at(
|
|
create_intent(NOW + 60_000),
|
|
"Local".to_owned(),
|
|
NOW,
|
|
Some(call_nonce(1)),
|
|
event_nonce(1),
|
|
)
|
|
.expect("remote capacity must not block local publication");
|
|
assert_eq!(receipt.revision, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn full_view_is_deterministic_and_wholly_recomputed() {
|
|
let local = peer(3);
|
|
let creator = peer(1);
|
|
let participant = peer(2);
|
|
let call_id = CallId::new(creator, call_nonce(1));
|
|
let generations = endpoint_generations(2);
|
|
let mut store = store(local);
|
|
store.observe_prepared_remote(prepare(
|
|
participant,
|
|
generations[0],
|
|
session(2),
|
|
snapshot(
|
|
1,
|
|
"Bob",
|
|
vec![
|
|
action_event(call_id, 3, NOW + 2, CallToPlayAction::Leave),
|
|
action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp),
|
|
],
|
|
),
|
|
));
|
|
assert!(store.remote_author_state(participant).is_none());
|
|
|
|
store.observe_prepared_remote(prepare(
|
|
participant,
|
|
generations[0],
|
|
session(2),
|
|
snapshot(
|
|
2,
|
|
"Bob",
|
|
vec![
|
|
action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp),
|
|
action_event(call_id, 3, NOW + 2, CallToPlayAction::Leave),
|
|
],
|
|
),
|
|
));
|
|
store.observe_prepared_remote(prepare(
|
|
creator,
|
|
generations[1],
|
|
session(3),
|
|
snapshot(
|
|
1,
|
|
"Alice",
|
|
vec![create_event(creator, call_id, 1, NOW, NOW + 60_000)],
|
|
),
|
|
));
|
|
let first = store.view_at(NOW + 3).expect("view");
|
|
let second = store.view_at(NOW + 3).expect("view");
|
|
assert_eq!(first, second);
|
|
assert_eq!(
|
|
first
|
|
.events
|
|
.iter()
|
|
.map(|event| event.id)
|
|
.collect::<Vec<_>>(),
|
|
[event_nonce(1), event_nonce(2), event_nonce(3)]
|
|
);
|
|
|
|
assert!(matches!(
|
|
store.observe_prepared_remote(prepare(
|
|
participant,
|
|
generations[0],
|
|
session(2),
|
|
snapshot(3, "Bob", Vec::new()),
|
|
)),
|
|
ObserveRemoteAuthorOutcome::Applied {
|
|
session_changed: false
|
|
}
|
|
));
|
|
let replaced = store.view_at(NOW + 3).expect("replacement view");
|
|
assert_eq!(replaced.events.len(), 1);
|
|
assert_eq!(replaced.events[0].author_id, creator);
|
|
|
|
assert!(store.remove_remote_author_if_generation(participant, generations[0]));
|
|
let replaced = store.view_at(NOW + 3).expect("view");
|
|
assert_eq!(replaced.events.len(), 1);
|
|
assert_eq!(replaced.events[0].author_id, creator);
|
|
}
|
|
}
|