crossmate

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

commit c247b9228803408d96904c20c96d6e69fcadbdd0
parent 8f0b4b96048707533c6bd3cfb343dd0f4a73754e
Author: Michael Camilleri <[email protected]>
Date:   Tue,  2 Jun 2026 10:54:29 +0900

Gate the engagement socket on foreground

Peer presence — the read lease (Player.readAt) — is foreground-only:
publishReadCursor refuses to advance an active lease while the app is
backgrounded, so a background CKSyncEngine wake can't resurrect a
departed peer's presence. The live engagement socket had no such gate.
reconcileEngagement is wired to inbound-record callbacks
(onRemotePlayerPresenceChanged / onRemoteEngagementChanged) that also
fire on background remote-notification wakes, and on each wake it would
re-dial the socket whenever a peer still held a future lease. The
transport is a .default URLSession WebSocket, which iOS aborts on
suspension, so the reconnect couldn't hold: the socket opened, died when
the background-exec window closed, and the next push re-dialled it — a
connect/abort/reconnect storm that could drain the device, churn the
network, and flicker the backgrounded peer's stale selection onto the
collaborator's grid on each brief connect. Cancelling the 30s reconnect
loop on .background is not enough; the notification-driven reconciles
bypass it.

reconcileEngagement now early-returns when the app is not foreground,
after the completed-game guard. It leaves any existing connection — and
the scheduled-end grace that rides out a transient .inactive — untouched
and simply refuses to escalate; the foreground .active path
re-reconciles via startEngagementIfPossible. An early return rather than
a forced teardown is deliberate: noteAppForeground(false) fires on both
.background and .inactive, so forcing hasPeer: false would kill a live
co-solving socket on an app-switcher peek or a banner. Teardown still
flows from socket abort, scheduleEngagementEnd and the completed guard.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 16++++++++++++++++
MCrossmate/Services/EngagementHost.swift | 7+++++++
2 files changed, 23 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1093,6 +1093,22 @@ final class AppServices { await engagementCoordinator.reconcile(gameID: gameID, creds: nil, hasPeer: false) return } + // A live socket is a foreground-only affair — the same rule + // `publishReadCursor` enforces for the read lease. Inbound presence / + // engagement changes also arrive on background CKSyncEngine wakes + // (`onRemotePlayerPresenceChanged` / `onRemoteEngagementChanged`), and a + // background reconcile used to re-dial the socket on every wake. That is + // futile: the `.default` URLSession WebSocket can't survive suspension, + // so it just storms connect → abort → reconnect until the next push. + // When not foreground, leave the current connection (and the + // scheduled-end grace that rides out a transient `.inactive`) untouched + // and refuse to escalate; the foreground `.active` path re-reconciles + // via `startEngagementIfPossible`. Teardown still flows from socket + // abort, `scheduleEngagementEnd`, and the completed-game guard above. + guard isAppForeground else { + syncMonitor.note("engagement: reconcile skipped for \(gameID.uuidString): backgrounded") + return + } let soonestLease = await Self.soonestPeerLease( persistence: persistence, gameID: gameID, diff --git a/Crossmate/Services/EngagementHost.swift b/Crossmate/Services/EngagementHost.swift @@ -16,6 +16,13 @@ final class EngagementHost: NSObject { private var sockets: [UUID: URLSessionWebSocketTask] = [:] private var engagementIDsByTask: [ObjectIdentifier: UUID] = [:] + // A `.default` URLSession cannot hold a socket open while the app is + // suspended — iOS aborts it (`Software caused connection abort`) on the way + // to the background. This is by design: the live channel is a foreground-only + // luxury, and the durable Moves/CloudKit path is the source of truth. Do NOT + // try to paper over the aborts with reconnect retries; a backgrounded + // reconnect just storms until the next suspension. The fix lives upstream — + // `reconcileEngagement` refuses to connect unless the app is foreground. private lazy var session = URLSession( configuration: .default, delegate: self,