crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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:
MCrossmate/Services/AppServices.swift | 49+++++++++++++++++++++++++++++++++++++++++++++++--
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 }