fix(call-to-play): acknowledge live replication
Raise the wire protocol to version 7 and add explicit Call to Play delivery outcomes. Live requests now wait for an application acknowledgement, allowing the sender to distinguish applied, duplicate, obsolete, incomplete, and rejected updates instead of treating a successful write as acceptance. Remove source-IP equality from actor verification. The receiver now requires the envelope peer ID to be present in its known roster and requires every live event actor to match that envelope. This matches the cooperative-LAN trust model without misrepresenting the shared TLS identity as per-peer authentication. Transport failures, malformed responses, NeedHandshake, and NeedHistory each trigger one asynchronous Hello/HelloAck resync. Rejections are logged without retry, and local publication remains independent of remote availability. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed - `git diff --cached --check` -- passed
This commit is contained in:
5 files changed
+254
-57
No files matched your search
@@ -4,7 +4,7 @@ use std::net::SocketAddr;
|
||||
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use lanspread_db::db::{Game, GameFileDescription};
|
||||
use lanspread_proto::{LibraryDelta, Message, Request, Response};
|
||||
use lanspread_proto::{CallToPlayAck, LibraryDelta, Message, Request, Response};
|
||||
use s2n_quic::stream::{BidirectionalStream, SendStream};
|
||||
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
|
||||
|
||||
@@ -94,8 +94,8 @@ async fn dispatch_request(
|
||||
peer_id,
|
||||
events: incoming,
|
||||
} => {
|
||||
handle_call_to_play_events(ctx, remote_addr, &peer_id, incoming).await;
|
||||
framed_tx
|
||||
let ack = handle_call_to_play_events(ctx, &peer_id, incoming).await;
|
||||
send_response(framed_tx, Response::CallToPlayAck(ack), "CallToPlayAck").await
|
||||
}
|
||||
Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await,
|
||||
Request::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await,
|
||||
@@ -123,31 +123,35 @@ async fn dispatch_request(
|
||||
|
||||
async fn handle_call_to_play_events(
|
||||
ctx: &PeerCtx,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
peer_id: &str,
|
||||
incoming: Vec<lanspread_proto::CallToPlayEvent>,
|
||||
) {
|
||||
) -> CallToPlayAck {
|
||||
let peer_id = peer_id.to_string();
|
||||
let sender_matches = if let Some(remote_addr) = remote_addr {
|
||||
ctx.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.peer_addr(&peer_id)
|
||||
.is_some_and(|listen_addr| listen_addr.ip() == remote_addr.ip())
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !sender_matches {
|
||||
log::warn!("Ignoring Call to Play events from unverified peer {peer_id}");
|
||||
return;
|
||||
if ctx.peer_game_db.read().await.peer_addr(&peer_id).is_none() {
|
||||
log::debug!("Requesting a handshake before accepting Call to Play events from {peer_id}");
|
||||
return CallToPlayAck::NeedHandshake;
|
||||
}
|
||||
if incoming.iter().any(|event| event.actor_id != peer_id) {
|
||||
log::warn!("Ignoring Call to Play events with an actor that does not match {peer_id}");
|
||||
return;
|
||||
let reason = format!("event actor does not match envelope peer {peer_id}");
|
||||
log::warn!("Rejecting Call to Play events: {reason}");
|
||||
return CallToPlayAck::Rejected { reason };
|
||||
}
|
||||
|
||||
match ctx.call_to_play.write().await.merge_batch(incoming) {
|
||||
Ok(merged) => {
|
||||
let ack = if merged.needs_history() {
|
||||
CallToPlayAck::NeedHistory
|
||||
} else if !merged.applied.is_empty() {
|
||||
CallToPlayAck::Applied
|
||||
} else if merged.obsolete > 0 {
|
||||
CallToPlayAck::Obsolete
|
||||
} else if merged.duplicates > 0 {
|
||||
CallToPlayAck::Duplicate
|
||||
} else {
|
||||
CallToPlayAck::Rejected {
|
||||
reason: "empty Call to Play event batch".to_string(),
|
||||
}
|
||||
};
|
||||
if merged.needs_history() {
|
||||
log::warn!(
|
||||
"Ignoring Call to Play actions without history from {peer_id}: {}",
|
||||
@@ -160,9 +164,13 @@ async fn handle_call_to_play_events(
|
||||
crate::PeerEvent::CallToPlayEvents(merged.applied),
|
||||
);
|
||||
}
|
||||
ack
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Rejecting Call to Play events from {peer_id}: {err}");
|
||||
CallToPlayAck::Rejected {
|
||||
reason: err.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,6 +511,7 @@ mod tests {
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
@@ -548,6 +557,29 @@ mod tests {
|
||||
.to_peer_ctx(tx_notify_ui)
|
||||
}
|
||||
|
||||
fn call_to_play_event(actor_id: &str, action: CallToPlayAction) -> CallToPlayEvent {
|
||||
CallToPlayEvent {
|
||||
id: "event-1".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
actor_id: actor_id.to_string(),
|
||||
actor_name: "Alice".to_string(),
|
||||
at: 8_000_000_000_000,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
fn call_to_play_create(actor_id: &str) -> CallToPlayEvent {
|
||||
call_to_play_event(
|
||||
actor_id,
|
||||
CallToPlayAction::Create {
|
||||
game_id: "game".to_string(),
|
||||
max_players: 4,
|
||||
scheduled_for: None,
|
||||
deadline: 8_000_000_060_000,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_relative_paths_are_never_transferable() {
|
||||
assert!(path_points_inside_local("game", "game/local/save.dat"));
|
||||
@@ -570,6 +602,84 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn known_peer_id_accepts_live_events_without_transport_ip_matching() {
|
||||
let temp = TempDir::new("lanspread-call-to-play-known-peer");
|
||||
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
|
||||
ctx.peer_game_db.write().await.upsert_peer(
|
||||
"peer-alice".to_string(),
|
||||
SocketAddr::from(([10, 66, 0, 2], 40000)),
|
||||
);
|
||||
|
||||
let ack =
|
||||
handle_call_to_play_events(&ctx, "peer-alice", vec![call_to_play_create("peer-alice")])
|
||||
.await;
|
||||
|
||||
assert_eq!(ack, CallToPlayAck::Applied);
|
||||
assert_eq!(ctx.call_to_play.write().await.snapshot().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_peer_and_mismatched_actor_receive_explicit_acks() {
|
||||
let temp = TempDir::new("lanspread-call-to-play-identity");
|
||||
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
|
||||
|
||||
assert_eq!(
|
||||
handle_call_to_play_events(
|
||||
&ctx,
|
||||
"peer-alice",
|
||||
vec![call_to_play_create("peer-alice")],
|
||||
)
|
||||
.await,
|
||||
CallToPlayAck::NeedHandshake
|
||||
);
|
||||
|
||||
ctx.peer_game_db.write().await.upsert_peer(
|
||||
"peer-alice".to_string(),
|
||||
SocketAddr::from(([10, 66, 0, 2], 40000)),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_call_to_play_events(
|
||||
&ctx,
|
||||
"peer-alice",
|
||||
vec![call_to_play_create("peer-mallory")],
|
||||
)
|
||||
.await,
|
||||
CallToPlayAck::Rejected { reason }
|
||||
if reason.contains("does not match envelope peer")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_event_ack_reports_missing_history_and_duplicates() {
|
||||
let temp = TempDir::new("lanspread-call-to-play-outcomes");
|
||||
let ctx = test_ctx(temp.path().to_path_buf(), GameCatalog::empty());
|
||||
ctx.peer_game_db.write().await.upsert_peer(
|
||||
"peer-alice".to_string(),
|
||||
SocketAddr::from(([10, 66, 0, 2], 40000)),
|
||||
);
|
||||
let orphan = call_to_play_event(
|
||||
"peer-alice",
|
||||
CallToPlayAction::AddTime {
|
||||
deadline: 8_000_000_600_000,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
handle_call_to_play_events(&ctx, "peer-alice", vec![orphan]).await,
|
||||
CallToPlayAck::NeedHistory
|
||||
);
|
||||
|
||||
let create = call_to_play_create("peer-alice");
|
||||
assert_eq!(
|
||||
handle_call_to_play_events(&ctx, "peer-alice", vec![create.clone()]).await,
|
||||
CallToPlayAck::Applied
|
||||
);
|
||||
assert_eq!(
|
||||
handle_call_to_play_events(&ctx, "peer-alice", vec![create]).await,
|
||||
CallToPlayAck::Duplicate
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_game_response_respects_serve_gates() {
|
||||
let temp = TempDir::new("lanspread-stream");
|
||||
|
||||
Reference in new issue
Block a user