fix(tauri): cancel peer work at native close boundary

The launcher previously began peer cancellation only after the frontend had
finished draining its pending invokes and the Tauri process emitted Exit. A
pending acknowledgement could therefore keep the close handler waiting while
the peer still owned the work needed to complete that acknowledgement.

Close admission and unrar cancellation now begin on the native main-window
CloseRequested event. A tracked application task signals the currently owned
peer runtime early, while the existing Exit handler still waits for all
admitted invokes, takes and joins the runtime, and drains background tasks.
An atomic latch keeps repeated close and exit notifications from scheduling
multiple shutdown requests, and a runtime that is being created remains owned
by its in-flight invoke until the final join boundary.

Test Plan:
- `just fmt` -- passed
- `just test` -- passed (796 Rust tests)
- `just frontend-test` -- passed (98 tests)
- `just clippy` -- passed
- `just build` -- passed
- `git diff --check` -- passed
- Native window-close timing was not available in the automated UI surface.
This commit is contained in:
2026-09-12 20:42:57 +02:00
parent 6dbba87c6c
commit 0dfacfa661
@@ -9,7 +9,7 @@ use std::{
Mutex,
OnceLock,
Weak,
atomic::{AtomicU64, Ordering},
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::{Duration, SystemTime, UNIX_EPOCH},
};
@@ -667,6 +667,7 @@ struct LanSpreadState {
game_transfer_status: Arc<RwLock<GameTransferStatusStore>>,
background_tasks: AppTaskScope,
app_invokes: AppInvokeScope,
application_shutdown_started: AtomicBool,
/// Cancellation controls for lexically owned unrar process workers.
/// Workers themselves synchronously join their child and pipe readers.
active_unrar_children: Arc<Mutex<UnrarChildRegistry>>,
@@ -4767,6 +4768,13 @@ pub fn run() {
.manage(LanSpreadState::default())
.manage(PeerEventTx(tx_peer_event))
.manage(UiStateTx(tx_ui_state))
.on_window_event(|window, event| {
if window.label() == "main"
&& matches!(event, tauri::WindowEvent::CloseRequested { .. })
{
begin_application_shutdown(window.app_handle());
}
})
.setup(move |app| {
let state_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&state_dir)?;
@@ -4823,17 +4831,12 @@ pub fn run() {
}
fn shutdown_application(app_handle: &AppHandle) {
begin_application_shutdown(app_handle);
let state = app_handle.state::<LanSpreadState>();
let peer_runtime = state.peer_runtime.clone();
let app_invokes = state.app_invokes.clone();
let background_tasks = state.background_tasks.clone();
// Close every invoke entry point before any shutdown mutation. Unrar is
// cancelled next; its lexical process worker settles before the peer
// runtime can report that the install task has stopped.
app_invokes.close_admission();
cancel_active_unrar_workers(app_handle);
tauri::async_runtime::block_on(async move {
// Once admission is closed, every earlier application invoke must
// return before the runtime can be taken. This both owns setup-process
@@ -4854,6 +4857,35 @@ fn shutdown_application(app_handle: &AppHandle) {
});
}
/// Starts application shutdown at the first native close boundary.
///
/// The frontend close handler still drains its own asynchronous ownership before
/// destroying the webview, while this synchronous boundary cancels peer work
/// early enough for pending frontend invokes to observe dropped acknowledgements
/// instead of waiting for the work they are acknowledging to finish naturally.
fn begin_application_shutdown(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>();
if state
.application_shutdown_started
.swap(true, Ordering::AcqRel)
{
return;
}
state.app_invokes.close_admission();
cancel_active_unrar_workers(app_handle);
// The runtime slot is async because startup and replacement are serialized
// with application invokes. Keep this request inside the application task
// scope so a concurrent startup cannot leave the early signal detached.
let peer_runtime = state.peer_runtime.clone();
state.background_tasks.spawn(async move {
if let Some(handle) = peer_runtime.read().await.as_ref() {
handle.shutdown();
}
});
}
async fn await_with_slow_warning<F>(future: F, warn_after: Duration, warning: &str) -> F::Output
where
F: std::future::Future,