diff --git a/solitaire_engine/src/sync_plugin.rs b/solitaire_engine/src/sync_plugin.rs index 271a3d7..c1c7302 100644 --- a/solitaire_engine/src/sync_plugin.rs +++ b/solitaire_engine/src/sync_plugin.rs @@ -272,12 +272,21 @@ fn poll_pull_result( } } -/// Last-schedule system: starts a best-effort push of the current local state -/// on [`AppExit`] without blocking the Bevy main thread. +/// Upper bound on how long [`push_on_exit`] may block the closing app. +/// Long enough for one healthy round-trip; short enough that quitting +/// never feels hung when the server is unreachable. +const EXIT_PUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// Last-schedule system: pushes the current local state on [`AppExit`], +/// blocking the main thread for at most [`EXIT_PUSH_TIMEOUT`]. /// -/// The detached task may be cut short by process teardown, so local atomic -/// persistence remains the durable source of truth even if the final remote -/// push does not complete. +/// This deliberately blocks: the previous detached-task version was almost +/// always cut short by process teardown, so the final session's push +/// silently never happened (2026-07-06 review, finding M3). A bounded wait +/// during the app's final frame is invisible to the player and lets the +/// round-trip actually complete on a healthy network. On timeout or error +/// the push is skipped — local atomic persistence remains the durable +/// source of truth and the next launch's pull/push converges. fn push_on_exit( mut exit_events: MessageReader, provider: Res, @@ -292,16 +301,17 @@ fn push_on_exit( exit_events.clear(); let payload = build_payload(&stats.0, &achievements.0, &progress.0); - let provider = provider.0.clone(); - let rt = rt.0.clone(); - AsyncComputeTaskPool::get() - .spawn(async move { - match rt.block_on(provider.push(&payload)) { - Ok(_) | Err(SyncError::UnsupportedPlatform) => {} - Err(e) => warn!("sync push on exit failed: {e}"), - } - }) - .detach(); + let result = rt + .0 + .block_on(async { tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await }); + match result { + Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {} + Ok(Err(e)) => warn!("sync push on exit failed: {e}"), + Err(_) => warn!( + "sync push on exit timed out after {}s; will sync on next launch", + EXIT_PUSH_TIMEOUT.as_secs() + ), + } } /// Update-schedule system: on each `GameWonEvent` push the just-completed