commit d42141b1dad973acead5d6cd098dd920914bc4e4
parent 260636be81914633d537b6115993377b812c79ee
Author: Michael Camilleri <[email protected]>
Date: Fri, 29 May 2026 11:00:18 +0900
Improve success likelihood of session-end push
The scheduleSessionEndPush function defers the pause summary with a bare
Task.sleep over a 120s grace window. That sleep only advances while the
process is running, so a backgrounded app that iOS suspends before the
window elapses never fires the push at all.
This commit holds a UIApplication background-execution assertion for the
lifetime of each game's grace timer, so the timer keeps running after
the app backgrounds. iOS grants only a limited budget — typically well
under the full grace window — so the assertion's expiration handler
fires the pause early (best effort) rather than letting suspension drop
it; the early fire is noted as 'firing early (background expiring)'. A
single fireSessionEndPush path now serves both the normal timer
completion and the expiration handler, with the pending-task entry
doubling as a 'not yet fired' flag so the two callers are idempotent.
cancelPendingSessionEndPush and the new endSessionEndBackgroundTask
release the assertion on every exit — resume-within-grace, normal fire,
and expiry — so no assertion leaks.
The full 120s grace still holds whenever the device keeps the app alive;
only a genuine early reclaim shortens it, and shortening beats dropping.
Delivery remains best-effort: the publish rides URLSession.shared, which
suspension can still cut mid-flight — a background URLSession would be
needed to close that last gap.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
1 file changed, 47 insertions(+), 2 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -1,6 +1,7 @@
import CloudKit
import CoreData
import Foundation
+import UIKit
import UserNotifications
@MainActor
@@ -64,6 +65,12 @@ final class AppServices {
/// only a sustained absence does.
static let sessionEndGrace: TimeInterval = 120
private var pendingSessionEndTasks: [UUID: Task<Void, Never>] = [:]
+ /// Background-execution assertions keeping the matching grace timer alive
+ /// after the app is backgrounded. iOS grants only a limited budget (often
+ /// well under `sessionEndGrace`), so the assertion's expiration handler
+ /// fires the pause early rather than letting suspension drop it. Keyed by
+ /// game so a per-game timer owns exactly one assertion.
+ private var sessionEndBackgroundTasks: [UUID: UIBackgroundTaskIdentifier] = [:]
let shareController: ShareController
let friendController: FriendController
let cursorStore: GameCursorStore
@@ -692,21 +699,59 @@ final class AppServices {
/// Player during the grace window."
func scheduleSessionEndPush(gameID: UUID, after seconds: TimeInterval) {
let pauseStart = Date()
- pendingSessionEndTasks[gameID]?.cancel()
+ cancelPendingSessionEndPush(gameID: gameID)
+ // Hold a background-execution assertion so the grace timer keeps
+ // running once the app is backgrounded. If iOS is about to reclaim
+ // us before the timer elapses, the expiration handler fires the
+ // pause early (best effort) instead of letting suspension drop it.
+ sessionEndBackgroundTasks[gameID] = UIApplication.shared.beginBackgroundTask(
+ withName: "session-end-\(gameID.uuidString)"
+ ) { [weak self] in
+ self?.fireSessionEndPush(gameID: gameID, pauseStart: pauseStart, expedited: true)
+ }
pendingSessionEndTasks[gameID] = Task { [weak self] in
try? await Task.sleep(for: .seconds(seconds))
guard !Task.isCancelled else { return }
+ self?.fireSessionEndPush(gameID: gameID, pauseStart: pauseStart, expedited: false)
+ }
+ }
+
+ /// Single fire path for the deferred session-end push, shared by the grace
+ /// timer and the background-assertion expiration handler. The pending-task
+ /// entry doubles as a "not yet fired" flag, so this is idempotent: whichever
+ /// caller wins removes it, and the loser falls through to releasing the
+ /// assertion only. `expedited` marks the early fire forced by an imminent
+ /// suspension, purely for diagnostics.
+ private func fireSessionEndPush(gameID: UUID, pauseStart: Date, expedited: Bool) {
+ guard let task = pendingSessionEndTasks.removeValue(forKey: gameID) else {
+ endSessionEndBackgroundTask(gameID: gameID)
+ return
+ }
+ task.cancel()
+ if expedited {
+ syncMonitor.note("push(pause): firing early (background expiring)")
+ }
+ Task { [weak self] in
guard let self else { return }
- self.pendingSessionEndTasks[gameID] = nil
await self.publishSessionEndPush(gameID: gameID, pauseStart: pauseStart)
+ self.endSessionEndBackgroundTask(gameID: gameID)
}
}
+ /// Releases the background-execution assertion for `gameID`, if one is
+ /// held. Safe to call repeatedly — a missing entry is a no-op.
+ private func endSessionEndBackgroundTask(gameID: UUID) {
+ guard let id = sessionEndBackgroundTasks.removeValue(forKey: gameID),
+ id != .invalid else { return }
+ UIApplication.shared.endBackgroundTask(id)
+ }
+
/// Cancel any pending scheduled session-end push for `gameID`. Returns
/// `true` if a pending task was dropped, i.e. the caller is inside the
/// grace window and should suppress the matching session-begin push.
@discardableResult
func cancelPendingSessionEndPush(gameID: UUID) -> Bool {
+ endSessionEndBackgroundTask(gameID: gameID)
guard let task = pendingSessionEndTasks.removeValue(forKey: gameID) else {
return false
}