fix(peer): coalesce full UI view publications

Keep one queued and one replaceable pending snapshot for remote-library and Call-to-Play views while preserving lifecycle-event FIFO delivery. Generation barriers and fenced drains prevent stale nonempty views from crossing disable or acknowledgement boundaries.

Test Plan:
- just test
- just clippy
- focused burst, lifecycle ordering, fence, repeated-barrier, and stale-view tests
- independent ordering review
- git diff --check
This commit is contained in:
ddidderr committed 2026-09-12 13:06:10 +02:00
1 parent 470e119515
commit 37420e26ed
17 files changed
+828 -251

No files matched your search

@@ -12,13 +12,14 @@ use futures::{StreamExt as _, stream::FuturesUnordered};
use lanspread_mdns::{LANSPREAD_SERVICE_TYPE, MdnsBrowser, MdnsService, MdnsServicePoll};
use lanspread_proto::{PROTOCOL_VERSION, PeerEndpoint, PeerId};
use tokio::sync::{
mpsc::{self, UnboundedSender},
mpsc,
oneshot,
};
use tokio_util::sync::CancellationToken;
use crate::{
PeerEvent,
PeerEventSender,
context::NetworkServiceCtx,
events,
services::{
@@ -188,7 +189,7 @@ impl Drop for DiscoveryWorker {
/// Runs the peer discovery service using mDNS.
#[allow(clippy::too_many_lines)]
pub async fn run_peer_discovery(
tx_notify_ui: UnboundedSender<PeerEvent>,
tx_notify_ui: PeerEventSender,
ctx: NetworkServiceCtx,
) -> eyre::Result<()> {
log::info!("Starting peer discovery task");
@@ -3,10 +3,8 @@
use std::{sync::Arc, time::Duration};
use futures::{StreamExt as _, stream};
use tokio::sync::mpsc::UnboundedSender;
use crate::{
PeerEvent,
PeerEventSender,
config::{PEER_PING_IDLE_SECS, PEER_PING_INTERVAL_SECS, peer_stale_timeout},
content_quarantine::ContentQuarantine,
context::{NetworkServiceCtx, OperationKind},
@@ -22,7 +20,7 @@ const MAX_CONCURRENT_PINGS: usize = 8;
/// uses `last_revision_check`; inbound and content traffic only affect
/// `last_seen`, which remains the stale-pruning clock.
pub async fn run_ping_service(
tx_notify_ui: UnboundedSender<PeerEvent>,
tx_notify_ui: PeerEventSender,
ctx: NetworkServiceCtx,
) -> eyre::Result<()> {
log::info!(
@@ -14,13 +14,13 @@ use std::{
use futures::FutureExt;
use tokio::{
sync::{RwLock, mpsc::UnboundedSender},
sync::RwLock,
task::{JoinError, JoinSet},
time::{Instant, MissedTickBehavior},
};
use crate::{
PeerEvent,
PeerEventSender,
config::{LOCAL_GAME_FALLBACK_SCAN_SECS, LOCAL_GAME_POLL_INTERVAL_SECS},
context::Ctx,
game_paths::{is_download_protected_root_name, is_ignored_games_root_name},
@@ -67,10 +67,7 @@ struct RescanGate {
}
/// Monitors the local game directory for changes.
pub async fn run_local_game_monitor(
tx_notify_ui: UnboundedSender<PeerEvent>,
ctx: Ctx,
) -> eyre::Result<()> {
pub async fn run_local_game_monitor(tx_notify_ui: PeerEventSender, ctx: Ctx) -> eyre::Result<()> {
log::info!("Starting polling-based local game directory monitor");
let mut snapshot = initial_poll_snapshot(&ctx).await;
@@ -171,7 +168,7 @@ async fn initial_poll_snapshot(ctx: &Ctx) -> Option<PollSnapshot> {
async fn poll_local_game_changes(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
tx_notify_ui: &PeerEventSender,
gate: &RescanGate,
rescans: &mut JoinSet<()>,
previous: &mut Option<PollSnapshot>,
@@ -328,7 +325,7 @@ fn changed_game_ids(previous: &PollSnapshot, current: &PollSnapshot) -> BTreeSet
async fn queue_changed_games(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
tx_notify_ui: &PeerEventSender,
gate: &RescanGate,
rescans: &mut JoinSet<()>,
changed_ids: BTreeSet<String>,
@@ -354,7 +351,7 @@ async fn queue_changed_games(
async fn queue_rescan(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
tx_notify_ui: &PeerEventSender,
gate: &RescanGate,
rescans: &mut JoinSet<()>,
id: String,
@@ -376,12 +373,7 @@ async fn queue_rescan(
});
}
async fn run_gated_rescan(
ctx: Ctx,
tx_notify_ui: UnboundedSender<PeerEvent>,
gate: RescanGate,
id: String,
) {
async fn run_gated_rescan(ctx: Ctx, tx_notify_ui: PeerEventSender, gate: RescanGate, id: String) {
loop {
gate.pending.write().await.remove(&id);
@@ -418,7 +410,7 @@ async fn run_gated_rescan(
gate.running.write().await.remove(&id);
}
async fn run_fallback_scan(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
async fn run_fallback_scan(ctx: &Ctx, tx_notify_ui: &PeerEventSender) {
let _admission = ctx.operation_admission.lock().await;
let game_dir = ctx.game_dir.read().await.clone();
let catalog = ctx.catalog.catalog();
@@ -445,11 +437,12 @@ mod tests {
};
use lanspread_db::content_manifest::CatalogBundle;
use tokio::sync::{RwLock, mpsc};
use tokio::sync::RwLock;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use super::*;
use crate::{
PeerEvent,
UnpackFuture,
Unpacker,
context::OperationKind,
@@ -512,9 +505,7 @@ mod tests {
panic!("injected monitor loop panic");
}
async fn recv_local_update(
rx: &mut mpsc::UnboundedReceiver<PeerEvent>,
) -> Vec<lanspread_db::db::Game> {
async fn recv_local_update(rx: &mut crate::PeerEventReceiver) -> Vec<lanspread_db::db::Game> {
let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("local update event should arrive")
@@ -604,7 +595,7 @@ mod tests {
.insert("game".to_string(), OperationKind::Downloading);
let rescan_gate = RescanGate::default();
let mut rescans = JoinSet::new();
let (tx, mut rx) = mpsc::unbounded_channel();
let (tx, mut rx) = crate::peer_event_channel();
queue_changed_games(
&ctx,
@@ -635,7 +626,7 @@ mod tests {
);
let rescan_gate = RescanGate::default();
let mut rescans = JoinSet::new();
let (tx, mut rx) = mpsc::unbounded_channel();
let (tx, mut rx) = crate::peer_event_channel();
let mut state = Some(
capture_poll_snapshot(&ctx)
.await
@@ -669,7 +660,7 @@ mod tests {
);
let gate = RescanGate::default();
let mut rescans = JoinSet::new();
let (tx, mut rx) = mpsc::unbounded_channel();
let (tx, mut rx) = crate::peer_event_channel();
let library_guard = ctx.local_library.write().await;
queue_rescan(&ctx, &tx, &gate, &mut rescans, "game".to_string()).await;
@@ -707,7 +698,7 @@ mod tests {
);
let gate = RescanGate::default();
let mut rescans = JoinSet::new();
let (tx, mut rx) = mpsc::unbounded_channel();
let (tx, mut rx) = crate::peer_event_channel();
let admission = ctx.operation_admission.lock().await;
queue_rescan(&ctx, &tx, &gate, &mut rescans, "game".to_string()).await;
@@ -751,7 +742,7 @@ mod tests {
);
let gate = RescanGate::default();
let mut rescans = JoinSet::new();
let (tx, mut rx) = mpsc::unbounded_channel();
let (tx, mut rx) = crate::peer_event_channel();
let admission = ctx.operation_admission.lock().await;
queue_rescan(&ctx, &tx, &gate, &mut rescans, "game".to_string()).await;
@@ -792,7 +783,7 @@ mod tests {
let temp = TempDir::new("lanspread-local-monitor-structured-shutdown");
let ctx = test_ctx(temp.path().to_path_buf(), empty_catalog_bundle());
let monitor_ctx = ctx.clone();
let (tx, _rx) = mpsc::unbounded_channel();
let (tx, _rx) = crate::peer_event_channel();
let monitor = tokio::spawn(run_local_game_monitor(tx, monitor_ctx));
tokio::task::yield_now().await;
@@ -813,7 +804,7 @@ mod tests {
temp.path().to_path_buf(),
catalog_bundle([("game", "20250101")]),
);
let (tx, mut rx) = mpsc::unbounded_channel();
let (tx, mut rx) = crate::peer_event_channel();
run_fallback_scan(&ctx, &tx).await;
@@ -837,7 +828,7 @@ mod tests {
temp.path().to_path_buf(),
catalog_bundle([("game", "20250101")]),
);
let (tx, mut rx) = mpsc::unbounded_channel();
let (tx, mut rx) = crate::peer_event_channel();
run_fallback_scan(&ctx, &tx).await;
@@ -10,12 +10,13 @@ use lanspread_proto::{
PeerStateSnapshot,
RuntimeSessionId,
};
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use crate::{
CallToPlayView,
PeerEvent,
PeerEventSender,
call_to_play::{
CallToPlayPublication,
CallToPlayStore,
@@ -45,7 +46,7 @@ use crate::{
pub(crate) struct RemoteStateCtx {
local_peer_id: PeerId,
peer_game_db: Arc<RwLock<PeerGameDB>>,
tx_notify_ui: UnboundedSender<PeerEvent>,
tx_notify_ui: PeerEventSender,
call_to_play: Arc<RwLock<CallToPlayStore>>,
quic: QuicConnector,
cancellation: CancellationToken,
@@ -56,7 +57,7 @@ impl RemoteStateCtx {
#[must_use]
pub(crate) fn from_network(
ctx: &NetworkServiceCtx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
tx_notify_ui: &PeerEventSender,
) -> Self {
Self {
local_peer_id: ctx.peer_id,
@@ -310,7 +311,7 @@ fn log_call_to_play_outcome(peer_id: PeerId, outcome: &ObserveRemoteAuthorOutcom
fn enqueue_commit_transition(
db: &PeerGameDB,
call_to_play: &mut CallToPlayStore,
tx_notify_ui: &UnboundedSender<PeerEvent>,
tx_notify_ui: &PeerEventSender,
state_sync: &StateSyncHandle,
publication: PreparedCallToPlayPublication,
endpoint: PeerEndpoint,
@@ -351,7 +352,7 @@ fn enqueue_commit_transition(
fn enqueue_final_views(
db: &PeerGameDB,
call_to_play: &mut CallToPlayStore,
tx_notify_ui: &UnboundedSender<PeerEvent>,
tx_notify_ui: &PeerEventSender,
state_sync: &StateSyncHandle,
publication: PreparedCallToPlayPublication,
) {
@@ -368,11 +369,11 @@ fn enqueue_final_views(
}
/// Clears every remote projection after one network generation has fully
/// drained, then queues the authoritative empty replacement views while the
/// drained, then queues the authoritative replacement views while the
/// database-to-Call-to-Play lock order is still held.
pub(crate) async fn clear_remote_state_and_publish(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
tx_notify_ui: &PeerEventSender,
) {
let mut db = ctx.peer_game_db.write().await;
let mut call_to_play = ctx.call_to_play.write().await;
@@ -383,11 +384,14 @@ pub(crate) async fn clear_remote_state_and_publish(
events::send(tx_notify_ui, PeerEvent::PeerLost(endpoint));
}
events::send(tx_notify_ui, PeerEvent::PeerCountUpdated(0));
events::send(
tx_notify_ui,
PeerEvent::RemoteLibraryView(events::remote_library_view(&db)),
);
enqueue_call_to_play_publication(publication, tx_notify_ui, &ctx.state_sync, true);
ctx.state_sync
.publish_call_to_play_revision(publication.local_revision);
if let Err(error) = tx_notify_ui.publish_view_generation(
events::remote_library_view(&db),
CallToPlayView::from(publication.view),
) {
log::error!("Failed to publish cleared peer view generation: {error}");
}
if let Some(error) = preparation_error {
log::warn!(
@@ -398,7 +402,7 @@ pub(crate) async fn clear_remote_state_and_publish(
fn enqueue_local_prune_if_changed(
call_to_play: &mut CallToPlayStore,
tx_notify_ui: &UnboundedSender<PeerEvent>,
tx_notify_ui: &PeerEventSender,
state_sync: &StateSyncHandle,
publication: PreparedCallToPlayPublication,
) {
@@ -411,7 +415,7 @@ fn enqueue_local_prune_if_changed(
fn enqueue_call_to_play_publication(
publication: CallToPlayPublication,
tx_notify_ui: &UnboundedSender<PeerEvent>,
tx_notify_ui: &PeerEventSender,
state_sync: &StateSyncHandle,
force_view: bool,
) {
@@ -8,7 +8,7 @@ use tokio::sync::{Mutex, mpsc, watch};
use tokio_util::sync::CancellationToken;
use crate::{
PeerEvent,
PeerEventSender,
context::NetworkServiceCtx,
network::{send_call_to_play_changed, send_library_changed},
peer_db::PeerRevisionSnapshot,
@@ -170,7 +170,7 @@ type FanoutFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
/// Runs the bounded pull scheduler and local hint fanout in one lexical scope.
pub(crate) async fn run_state_sync(
ctx: NetworkServiceCtx,
tx_notify_ui: tokio::sync::mpsc::UnboundedSender<PeerEvent>,
tx_notify_ui: PeerEventSender,
cancellation: CancellationToken,
) -> eyre::Result<()> {
let mut pinned_rx = ctx.state_sync.inbox.pinned_rx.lock().await;
@@ -504,7 +504,7 @@ mod tests {
CatalogContentManifestBody,
CatalogExtractedEntry,
};
use tokio::sync::{RwLock, mpsc};
use tokio::sync::RwLock;
use tokio_util::task::TaskTracker;
use super::*;
@@ -611,7 +611,7 @@ mod tests {
root: &Path,
manifest: &CatalogContentManifest,
provider: Arc<CountingStreamInstallProvider>,
) -> (PeerCtx, mpsc::UnboundedReceiver<crate::PeerEvent>) {
) -> (PeerCtx, crate::PeerEventReceiver) {
let catalog = Arc::new(
CatalogBundle::from_manifests([manifest.clone()])
.expect("test catalog should be complete"),
@@ -643,7 +643,7 @@ mod tests {
availability: Availability::Ready,
},
);
let (tx, rx) = mpsc::unbounded_channel();
let (tx, rx) = crate::peer_event_channel();
(ctx.to_peer_ctx(tx, CancellationToken::new()), rx)
}