fix(engine): bounded blocking sync push on exit
Test / test (pull_request) Successful in 24m6s

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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-06 20:03:41 -07:00
parent d0c1db6c1d
commit d1264a7797
+24 -14
View File
@@ -272,12 +272,21 @@ fn poll_pull_result(
} }
} }
/// Last-schedule system: starts a best-effort push of the current local state /// Upper bound on how long [`push_on_exit`] may block the closing app.
/// on [`AppExit`] without blocking the Bevy main thread. /// 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 /// This deliberately blocks: the previous detached-task version was almost
/// persistence remains the durable source of truth even if the final remote /// always cut short by process teardown, so the final session's push
/// push does not complete. /// 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( fn push_on_exit(
mut exit_events: MessageReader<AppExit>, mut exit_events: MessageReader<AppExit>,
provider: Res<SyncProviderResource>, provider: Res<SyncProviderResource>,
@@ -292,16 +301,17 @@ fn push_on_exit(
exit_events.clear(); exit_events.clear();
let payload = build_payload(&stats.0, &achievements.0, &progress.0); let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let provider = provider.0.clone(); let result = rt
let rt = rt.0.clone(); .0
AsyncComputeTaskPool::get() .block_on(async { tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await });
.spawn(async move { match result {
match rt.block_on(provider.push(&payload)) { Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {}
Ok(_) | Err(SyncError::UnsupportedPlatform) => {} Ok(Err(e)) => warn!("sync push on exit failed: {e}"),
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()
),
} }
})
.detach();
} }
/// Update-schedule system: on each `GameWonEvent` push the just-completed /// Update-schedule system: on each `GameWonEvent` push the just-completed