From d1264a77970a51687cdd24d1f0913619da864582 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 6 Jul 2026 20:03:41 -0700 Subject: [PATCH] fix(engine): bounded blocking sync push on exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exit push spawned a detached task on AppExit, which process teardown almost always killed before the network round-trip completed — the final session's sync silently never happened (2026-07-06 review, finding M3). Local persistence meant no data loss, but stats stayed unsynced until the next launch. push_on_exit now blocks the closing app's final frame for at most EXIT_PUSH_TIMEOUT (2s) via tokio::time::timeout: long enough for one healthy round-trip, short enough that quitting never feels hung when the server is unreachable. Timeout and errors are logged and skipped — the next launch's pull/push converges as before. Closes review finding M3. Co-Authored-By: Claude Fable 5 --- solitaire_engine/src/sync_plugin.rs | 40 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 15 deletions(-) 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 -- 2.47.3