refactor(peer): split local library and operation UI events
Replace the `a9f9845` local-update dedup cache with explicit peer event semantics. Local scans now emit `LocalLibraryChanged` when the library changes, while operation mutations emit `ActiveOperationsChanged` from the mutation path. Tauri keeps joining those facts into the existing `games-list-updated` payload, so the frontend contract stays stable. This removes the cache/invalidation coupling between scan emission and operation state. The remaining forced local snapshot is explicit: accepted game directory changes can refresh the UI for an equivalent new path without sending a peer library delta. Operation guard cleanup and liveness cancellation now publish the same active operation snapshot as normal command-handler transitions. The peer CLI JSONL events follow the same split with `local-library-changed` and `active-operations-changed`. Test Plan: - `just fmt` - `CARGO_BUILD_RUSTC_WRAPPER= just test` - `CARGO_BUILD_RUSTC_WRAPPER= just clippy` - `git diff --check` Refs: CLEAN_CODE_PLAN_1.md
This commit is contained in:
@@ -8,10 +8,10 @@ use std::{
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameDB;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use crate::{PeerEvent, Unpacker, library::LocalLibraryState, peer_db::PeerGameDB};
|
||||
use crate::{PeerEvent, Unpacker, events, library::LocalLibraryState, peer_db::PeerGameDB};
|
||||
|
||||
/// Mutating filesystem operation currently in flight for a game root.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -124,6 +124,7 @@ pub(crate) struct OperationGuard {
|
||||
id: String,
|
||||
active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
clears_download: bool,
|
||||
armed: bool,
|
||||
}
|
||||
@@ -132,11 +133,13 @@ impl OperationGuard {
|
||||
pub(crate) fn new(
|
||||
id: String,
|
||||
active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
active_operations,
|
||||
active_downloads: Arc::new(RwLock::new(HashMap::new())),
|
||||
tx_notify_ui,
|
||||
clears_download: false,
|
||||
armed: true,
|
||||
}
|
||||
@@ -146,11 +149,13 @@ impl OperationGuard {
|
||||
id: String,
|
||||
active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
active_operations,
|
||||
active_downloads,
|
||||
tx_notify_ui,
|
||||
clears_download: true,
|
||||
armed: true,
|
||||
}
|
||||
@@ -173,13 +178,19 @@ impl Drop for OperationGuard {
|
||||
);
|
||||
|
||||
if let Ok(mut guard) = self.active_operations.try_write() {
|
||||
guard.remove(&id);
|
||||
if guard.remove(&id).is_some() {
|
||||
events::send_active_operations_snapshot(&self.tx_notify_ui, &guard);
|
||||
}
|
||||
} else if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
let active_operations = self.active_operations.clone();
|
||||
let tx_notify_ui = self.tx_notify_ui.clone();
|
||||
handle.spawn({
|
||||
let id = id.clone();
|
||||
async move {
|
||||
active_operations.write().await.remove(&id);
|
||||
let mut active_operations = active_operations.write().await;
|
||||
if active_operations.remove(&id).is_some() {
|
||||
events::send_active_operations_snapshot(&tx_notify_ui, &active_operations);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -210,10 +221,11 @@ impl Drop for OperationGuard {
|
||||
mod tests {
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::{OperationGuard, OperationKind};
|
||||
use crate::{ActiveOperation, ActiveOperationKind, PeerEvent};
|
||||
|
||||
type OperationTracking = (
|
||||
Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
@@ -253,18 +265,34 @@ mod tests {
|
||||
(active_operations, active_downloads, cancel)
|
||||
}
|
||||
|
||||
async fn recv_active_operations(
|
||||
rx: &mut mpsc::UnboundedReceiver<PeerEvent>,
|
||||
) -> Vec<ActiveOperation> {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.expect("active operation event should arrive")
|
||||
.expect("event channel should remain open");
|
||||
let PeerEvent::ActiveOperationsChanged { active_operations } = event else {
|
||||
panic!("expected ActiveOperationsChanged");
|
||||
};
|
||||
active_operations
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn operation_guard_cleans_tracking_when_not_disarmed() {
|
||||
let id = "game-complete";
|
||||
let (active_operations, active_downloads, _) = tracked_download_state(id);
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
drop(OperationGuard::download(
|
||||
id.to_string(),
|
||||
active_operations.clone(),
|
||||
active_downloads.clone(),
|
||||
tx,
|
||||
));
|
||||
|
||||
wait_for_tracking_clear(id, &active_operations, &active_downloads).await;
|
||||
assert!(recv_active_operations(&mut rx).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -272,25 +300,30 @@ mod tests {
|
||||
let id = "game-cancelled";
|
||||
let (active_operations, active_downloads, cancel) = tracked_download_state(id);
|
||||
cancel.cancel();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
drop(OperationGuard::download(
|
||||
id.to_string(),
|
||||
active_operations.clone(),
|
||||
active_downloads.clone(),
|
||||
tx,
|
||||
));
|
||||
|
||||
wait_for_tracking_clear(id, &active_operations, &active_downloads).await;
|
||||
assert!(recv_active_operations(&mut rx).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disarmed_operation_guard_does_not_clean_tracking() {
|
||||
let id = "game-finished";
|
||||
let (active_operations, active_downloads, _) = tracked_download_state(id);
|
||||
let (tx, _rx) = mpsc::unbounded_channel();
|
||||
|
||||
OperationGuard::download(
|
||||
id.to_string(),
|
||||
active_operations.clone(),
|
||||
active_downloads.clone(),
|
||||
tx,
|
||||
)
|
||||
.disarm();
|
||||
|
||||
@@ -303,13 +336,19 @@ mod tests {
|
||||
let id = "game-aborted";
|
||||
let (active_operations, active_downloads, _) = tracked_download_state(id);
|
||||
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
let handle = tokio::spawn({
|
||||
let active_operations = active_operations.clone();
|
||||
let active_downloads = active_downloads.clone();
|
||||
let tx = tx.clone();
|
||||
async move {
|
||||
let _guard =
|
||||
OperationGuard::download(id.to_string(), active_operations, active_downloads);
|
||||
let _guard = OperationGuard::download(
|
||||
id.to_string(),
|
||||
active_operations,
|
||||
active_downloads,
|
||||
tx,
|
||||
);
|
||||
let _ = ready_tx.send(());
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
@@ -320,5 +359,34 @@ mod tests {
|
||||
let _ = handle.await;
|
||||
|
||||
wait_for_tracking_clear(id, &active_operations, &active_downloads).await;
|
||||
assert_eq!(
|
||||
recv_active_operations(&mut rx).await,
|
||||
Vec::<ActiveOperation>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn operation_guard_cleanup_snapshot_keeps_other_operations() {
|
||||
let active_operations = Arc::new(RwLock::new(HashMap::from([
|
||||
("aborted".to_string(), OperationKind::Downloading),
|
||||
("other".to_string(), OperationKind::Installing),
|
||||
])));
|
||||
let active_downloads = Arc::new(RwLock::new(HashMap::new()));
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
drop(OperationGuard::download(
|
||||
"aborted".to_string(),
|
||||
active_operations,
|
||||
active_downloads,
|
||||
tx,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
recv_active_operations(&mut rx).await,
|
||||
vec![ActiveOperation {
|
||||
id: "other".to_string(),
|
||||
operation: ActiveOperationKind::Installing,
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user