commit 6e662a5c0923b389fabf99139cc4846c42345e06
parent 7cde5cb2ff5f2bde944c630da92f48d4ca9e5f52
Author: Michael Camilleri <[email protected]>
Date: Tue, 26 May 2026 02:44:31 +0900
Deduplicate ping handling before dispatch
This commit moves Ping record-name deduplication to the shared dispatch path so
pings surfaced by both the push fast path and CKSyncEngine are handled once
before being routed to invite, friend, engagement, or notification handlers.
This also removes the older persistent notification-specific ping dedup state
(since fetched Ping records now use one consistent in-memory claim path) and
removes debugging-related engagement views.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
9 files changed, 31 insertions(+), 318 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -22,7 +22,6 @@
1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BDD06460A76D4AF31077732 /* InputMonitor.swift */; };
1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */; };
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; };
- 1F6644A367B3B8DD1F9CEB7B /* EngagementDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D854041D0A7EF28B5F730D91 /* EngagementDebugView.swift */; };
267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */; };
2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */; };
2B03A1A36AB55495ED0E8684 /* HardwareKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */; };
@@ -244,7 +243,6 @@
CFC4FF046BF772646B5CA73F /* Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = "<group>"; };
D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; };
D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; };
- D854041D0A7EF28B5F730D91 /* EngagementDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementDebugView.swift; sourceTree = "<group>"; };
D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; };
DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; };
@@ -412,7 +410,6 @@
F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */,
E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */,
434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */,
- D854041D0A7EF28B5F730D91 /* EngagementDebugView.swift */,
F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */,
EE3412F437AABD2988B6976D /* FriendPickerView.swift */,
38DDAD9D6470A894C3FD6F90 /* GameListView.swift */,
@@ -656,7 +653,6 @@
1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */,
978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */,
CABF8BFAA30B9F26C482FAB9 /* EngagementCoordinator.swift in Sources */,
- 1F6644A367B3B8DD1F9CEB7B /* EngagementDebugView.swift in Sources */,
267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */,
A7FA870D794CA00F7F3F05D2 /* EngagementHostEnvironment.swift in Sources */,
06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -25,14 +25,10 @@ struct CrossmateApp: App {
.environment(services.syncMonitor)
.environment(services.eventLog)
.environment(\.syncEngine, services.syncEngine)
- .environment(\.engagementHost, services.engagementHost)
.environment(\.engagementStatus, services.engagementStatus)
.environment(\.offerEngagement, { gameID in
await services.offerEngagement(gameID: gameID)
})
- .environment(\.sendEngagementTestMessage, { gameID in
- await services.sendEngagementTestMessage(gameID: gameID)
- })
.environment(services.nytAuth)
.environment(\.nytPuzzleFetcher, services.nytFetcher)
.environment(\.resetDatabase, {
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -114,6 +114,9 @@ final class AppServices {
private var isHandlingSharedRemoteNotification = false
private var isFresheningPrivateGameList = false
private var isFresheningSharedGameList = false
+ private var claimedPingRecordNames: Set<String> = []
+ private var claimedPingRecordNameOrder: [String] = []
+ private let claimedPingRecordNameCap = 200
/// Wall-clock timestamp of the last successful game-list freshen per
/// scope, used to suppress redundant polls when no inbound push has
/// arrived since. Pushes own freshness; the freshen (zone discovery +
@@ -633,11 +636,6 @@ final class AppServices {
await engagementCoordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
}
- func sendEngagementTestMessage(gameID: UUID) async {
- let text = "Engagement test from \(RecordSerializer.localDeviceID.prefix(8))"
- await engagementCoordinator.sendDebugMessage(gameID: gameID, text: text)
- }
-
func endEngagement(gameID: UUID) async {
cancelScheduledEngagementEnd(gameID: gameID)
syncMonitor.note("engagement: ending for \(gameID.uuidString)")
@@ -1627,6 +1625,7 @@ final class AppServices {
}
private func presentPings(_ pings: [Ping]) async {
+ let pings = claimPingsForHandling(pings)
guard !pings.isEmpty else { return }
// Cancel any pending end-of-session summary that a stronger signal
// (the author solved or gave up) is about to supersede. Runs before
@@ -1680,17 +1679,6 @@ final class AppServices {
guard consume else { return }
await syncEngine.deletePing(recordName: ping.recordName, gameID: ping.gameID)
}
- // The push fast path and the CKSyncEngine catch-up both surface
- // the same Ping records, so dedup by record name. We do this
- // check first and short-circuit; every other path below ends by
- // recording the name via the defer.
- if NotificationState.wasShown(pingRecordName: ping.recordName) {
- syncMonitor.note("ping(\(ping.kind.rawValue)): already-shown record \(ping.recordName)")
- await consumeIfDirected()
- continue
- }
- defer { NotificationState.recordShown(pingRecordName: ping.recordName) }
-
if NotificationState.isSuppressed(gameID: ping.gameID) {
syncMonitor.note("ping(\(ping.kind.rawValue)): suppressed — puzzle is active for \(ping.gameID.uuidString)")
await consumeIfDirected()
@@ -1721,6 +1709,26 @@ final class AppServices {
}
}
+ private func claimPingsForHandling(_ pings: [Ping]) -> [Ping] {
+ var unclaimed: [Ping] = []
+ for ping in pings {
+ guard claimedPingRecordNames.insert(ping.recordName).inserted else {
+ syncMonitor.note("ping(\(ping.kind.rawValue)): already-handled record \(ping.recordName)")
+ continue
+ }
+ claimedPingRecordNameOrder.append(ping.recordName)
+ unclaimed.append(ping)
+ }
+ if claimedPingRecordNameOrder.count > claimedPingRecordNameCap {
+ let overflow = claimedPingRecordNameOrder.count - claimedPingRecordNameCap
+ for recordName in claimedPingRecordNameOrder.prefix(overflow) {
+ claimedPingRecordNames.remove(recordName)
+ }
+ claimedPingRecordNameOrder.removeFirst(overflow)
+ }
+ return unclaimed
+ }
+
private func canPresentNotifications() async -> Bool {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
diff --git a/Crossmate/Services/EngagementHostEnvironment.swift b/Crossmate/Services/EngagementHostEnvironment.swift
@@ -18,10 +18,6 @@ final class EngagementStatus {
}
}
-private struct EngagementHostKey: EnvironmentKey {
- static let defaultValue: EngagementHost? = nil
-}
-
private struct EngagementStatusKey: EnvironmentKey {
static let defaultValue: EngagementStatus? = nil
}
@@ -30,16 +26,7 @@ private struct OfferEngagementKey: EnvironmentKey {
static let defaultValue: (@Sendable (UUID) async -> Void)? = nil
}
-private struct SendEngagementTestMessageKey: EnvironmentKey {
- static let defaultValue: (@Sendable (UUID) async -> Void)? = nil
-}
-
extension EnvironmentValues {
- var engagementHost: EngagementHost? {
- get { self[EngagementHostKey.self] }
- set { self[EngagementHostKey.self] = newValue }
- }
-
var engagementStatus: EngagementStatus? {
get { self[EngagementStatusKey.self] }
set { self[EngagementStatusKey.self] = newValue }
@@ -49,9 +36,4 @@ extension EnvironmentValues {
get { self[OfferEngagementKey.self] }
set { self[OfferEngagementKey.self] = newValue }
}
-
- var sendEngagementTestMessage: (@Sendable (UUID) async -> Void)? {
- get { self[SendEngagementTestMessageKey.self] }
- set { self[SendEngagementTestMessageKey.self] = newValue }
- }
}
diff --git a/Crossmate/Sync/EngagementCoordinator.swift b/Crossmate/Sync/EngagementCoordinator.swift
@@ -262,12 +262,6 @@ actor EngagementCoordinator {
await log("engagement: ignored hail \(ping.recordName), missing local author")
return
}
- if let timestamp = Self.eventTimestamp(from: ping.recordName),
- now().timeIntervalSince(timestamp) > hailMaxAge {
- await deletePing(ping.recordName, ping.gameID)
- await log("engagement: deleted stale hail \(ping.recordName)")
- return
- }
guard ping.authorID != localAuthorID || ping.deviceID != localDeviceID else {
await log("engagement: ignored own hail \(ping.recordName)")
return
@@ -284,6 +278,12 @@ actor EngagementCoordinator {
await log("engagement: ignored malformed hail \(ping.recordName)")
return
}
+ if let timestamp = Self.eventTimestamp(from: ping.recordName),
+ now().timeIntervalSince(timestamp) > hailMaxAge {
+ await deletePing(ping.recordName, ping.gameID)
+ await log("engagement: deleted stale hail \(ping.recordName)")
+ return
+ }
guard room.expiresAt > now() else {
await deletePing(ping.recordName, ping.gameID)
await log("engagement: deleted expired hail \(ping.recordName)")
diff --git a/Crossmate/Views/EngagementDebugView.swift b/Crossmate/Views/EngagementDebugView.swift
@@ -1,225 +0,0 @@
-import Foundation
-import SwiftUI
-import UIKit
-
-struct EngagementDebugView: View {
- @Environment(\.engagementHost) private var host
-
- @State private var engagementID = UUID()
- @State private var authorID = "debug-author"
- @State private var deviceID = RecordSerializer.localDeviceID
- @State private var roomJSON = ""
- @State private var outboundMessage = "hello from Crossmate"
- @State private var events: [String] = []
- @State private var isBusy = false
-
- private let encoder: JSONEncoder = {
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
- return encoder
- }()
-
- var body: some View {
- List {
- Section("Engagement ID") {
- Text(engagementID.uuidString)
- .font(.caption.monospaced())
- .textSelection(.enabled)
-
- Button("New Engagement ID") {
- host?.disconnect(engagementID: engagementID)
- engagementID = UUID()
- appendEvent("New engagement ID")
- }
- .disabled(isBusy)
- }
-
- Section("Identity") {
- TextField("Author ID", text: $authorID)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
- TextField("Device ID", text: $deviceID)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
- }
-
- Section("Room") {
- debugTextEditor(text: $roomJSON, minHeight: 150)
-
- Button("Create Room") {
- createRoom()
- }
- .disabled(isBusy)
-
- Button("Paste Room") {
- roomJSON = UIPasteboard.general.string ?? ""
- }
-
- Button("Copy Room") {
- UIPasteboard.general.string = roomJSON
- }
- .disabled(roomJSON.isEmpty)
-
- Button("Connect") {
- Task { await connect() }
- }
- .disabled(isBusy || host == nil || roomJSON.isEmpty || authorID.isEmpty || deviceID.isEmpty)
- }
-
- Section("Socket") {
- TextField("Message", text: $outboundMessage)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
-
- Button("Send Message") {
- Task { await sendMessage() }
- }
- .disabled(host == nil || outboundMessage.isEmpty)
-
- Button("Disconnect") {
- host?.disconnect(engagementID: engagementID)
- appendEvent("Disconnect requested")
- }
- .disabled(host == nil)
- }
-
- Section("Events") {
- if events.isEmpty {
- Text("No engagement events yet.")
- .foregroundStyle(.secondary)
- } else {
- ForEach(events.indices.reversed(), id: \.self) { index in
- Text(events[index])
- .font(.caption.monospaced())
- .textSelection(.enabled)
- }
- }
- }
- }
- .navigationTitle("Engagement Socket Test")
- .navigationBarTitleDisplayMode(.inline)
- .onAppear {
- host?.onEvent = { event in
- handle(event)
- }
- }
- .onDisappear {
- host?.disconnect(engagementID: engagementID)
- }
- }
-
- @ViewBuilder
- private func debugTextEditor(text: Binding<String>, minHeight: CGFloat) -> some View {
- TextEditor(text: text)
- .font(.caption.monospaced())
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
- .frame(minHeight: minHeight)
- }
-
- private func createRoom() {
- do {
- let room = try EngagementRoomCredentials.fresh()
- let data = try encoder.encode(room)
- guard let string = String(data: data, encoding: .utf8) else {
- throw EngagementDebugError.invalidUTF8
- }
- roomJSON = string
- appendEvent("Created room \(room.roomID.uuidString)")
- } catch {
- appendEvent("Create room failed: \(error.localizedDescription)")
- }
- }
-
- private func connect() async {
- guard let host else { return }
- await run("connect") {
- let room = try decodeRoom(roomJSON)
- try await host.connect(
- engagementID: engagementID,
- room: room,
- authorID: authorID,
- deviceID: deviceID
- )
- }
- }
-
- private func sendMessage() async {
- guard let data = outboundMessage.data(using: .utf8) else { return }
- do {
- try await host?.send(engagementID: engagementID, message: data)
- appendEvent("Sent message: \(outboundMessage)")
- } catch {
- appendEvent("Send failed: \(error.localizedDescription)")
- }
- }
-
- private func run(_ label: String, operation: () async throws -> Void) async {
- guard !isBusy else { return }
- isBusy = true
- appendEvent("Starting \(label)")
- defer { isBusy = false }
-
- do {
- try await operation()
- appendEvent("Finished \(label)")
- } catch {
- appendEvent("\(label) failed: \(error.localizedDescription)")
- }
- }
-
- private func handle(_ event: EngagementHost.Event) {
- switch event {
- case .channelOpen(let id):
- guard id == engagementID else { return }
- appendEvent("Channel open")
- case .channelMessage(let id, let message):
- guard id == engagementID else { return }
- let text = String(data: message, encoding: .utf8)
- ?? message.base64EncodedString()
- appendEvent("Received message: \(text)")
- case .channelClose(let id):
- guard id == engagementID else { return }
- appendEvent("Channel closed")
- case .diagnostic(let id, let message):
- guard id == nil || id == engagementID else { return }
- appendEvent("Diagnostic: \(message)")
- case .error(let id, let message):
- guard id == nil || id == engagementID else { return }
- appendEvent("Error: \(message)")
- }
- }
-
- private func decodeRoom(_ string: String) throws -> EngagementRoomCredentials {
- guard let data = string.data(using: .utf8) else {
- throw EngagementDebugError.invalidUTF8
- }
- return try JSONDecoder().decode(EngagementRoomCredentials.self, from: data)
- }
-
- private func appendEvent(_ message: String) {
- let time = Self.eventTimeFormatter.string(from: Date())
- events.append("\(time) \(message)")
- if events.count > 100 {
- events.removeFirst(events.count - 100)
- }
- }
-
- private static let eventTimeFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.dateStyle = .none
- formatter.timeStyle = .medium
- return formatter
- }()
-}
-
-private enum EngagementDebugError: LocalizedError {
- case invalidUTF8
-
- var errorDescription: String? {
- switch self {
- case .invalidUTF8:
- "The room could not be encoded as UTF-8."
- }
- }
-}
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -764,7 +764,6 @@ private struct PuzzleToolbarModifier: ViewModifier {
@Binding var isShowingShareSheet: Bool
@Environment(PlayerPreferences.self) private var preferences
@Environment(\.offerEngagement) private var offerEngagement
- @Environment(\.sendEngagementTestMessage) private var sendEngagementTestMessage
@AppStorage("debugMode") private var debugMode = false
func body(content: Content) -> some View {
@@ -828,11 +827,6 @@ private struct PuzzleToolbarModifier: ViewModifier {
Task { await offerEngagement?(session.mutator.gameID) }
}
.disabled(offerEngagement == nil)
-
- Button("Send Engagement Test Message") {
- Task { await sendEngagementTestMessage?(session.mutator.gameID) }
- }
- .disabled(sendEngagementTestMessage == nil)
}
}
diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift
@@ -47,9 +47,6 @@ struct SettingsView: View {
NavigationLink("Record Editor") {
RecordEditorView()
}
- NavigationLink("Engagement Socket Test") {
- EngagementDebugView()
- }
Button("Reset Database", role: .destructive) {
showResetConfirmation = true
diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift
@@ -18,14 +18,12 @@ enum NotificationState {
/// How long after a shown session notification subsequent session
/// notifications for the same game are suppressed. Explicit
- /// join/win/check/reveal pings bypass this game-level dedup and are
- /// deduped by Ping record name instead.
+ /// join/win/check/reveal pings bypass this game-level dedup.
static let dedupWindow: TimeInterval = 20 * 60
private static let activeKey = "notif.activePuzzleID"
private static let shownPrefix = "notif.shownByGame."
private static let legacyShownKey = "notif.shownByGame"
- private static let shownPingNamesKey = "notif.shownPingNames"
private static let localActiveUntilKey = "notif.localActiveUntil"
/// Grace window after the user leaves a puzzle during which the game is
@@ -35,12 +33,6 @@ enum NotificationState {
/// user already watched arrive as unseen (and can re-notify for them).
static let leaveGraceWindow: TimeInterval = 15
- /// Maximum number of recently-presented ping record names retained for
- /// dedup. FIFO; older entries are evicted as new ones come in. 200 covers
- /// the worst-case overlap between the push fast path and the eventual
- /// CKSyncEngine catch-up many times over.
- static let shownPingNamesCap = 200
-
/// `UserDefaults` itself isn't `Sendable` under strict concurrency, but its
/// methods are thread-safe in practice. Vouch for that here so the testing
/// override can flow through a `TaskLocal`.
@@ -197,33 +189,6 @@ enum NotificationState {
isActive(gameID: gameID, now: now)
}
- /// True if a notification for this specific Ping record name has already
- /// been presented. Used to keep the push fast path and the eventual
- /// CKSyncEngine catch-up from double-notifying for the same ping.
- static func wasShown(pingRecordName name: String) -> Bool {
- shownPingNames().contains(name)
- }
-
- /// Records that a notification for this Ping record name was presented.
- /// Maintains FIFO order; evicts the oldest entries once `shownPingNamesCap`
- /// is exceeded.
- static func recordShown(pingRecordName name: String) {
- guard let defaults else { return }
- var names = shownPingNames()
- if let existing = names.firstIndex(of: name) {
- names.remove(at: existing)
- }
- names.append(name)
- if names.count > shownPingNamesCap {
- names.removeFirst(names.count - shownPingNamesCap)
- }
- defaults.set(names, forKey: shownPingNamesKey)
- }
-
- private static func shownPingNames() -> [String] {
- defaults?.stringArray(forKey: shownPingNamesKey) ?? []
- }
-
private static let legacyLeasePurgeKey = "migration.legacyLeasePurge.v1"
private static let legacyInvitePurgeKey = "migration.legacyInvitePurge.v1"
private static let staleHailPurgeKey = "migration.staleHailPurge.v1"