commit 5fd79e83b1d92a6fb76b8fcd0d6d0325b5a0d618
parent c45ae331fc4230d879801ec8f082a6296c3885b3
Author: Michael Camilleri <[email protected]>
Date: Mon, 25 May 2026 22:48:39 +0900
Keep engagement connections alive briefly after grid exit
Leaving the puzzle grid immediately tore down a live WebRTC engagement, which
made it hard to inspect diagnostics or briefly navigate away without breaking
live play. This commit replaces the immediate grid-disappear teardown with a
cancellable delayed teardown owned by AppServices. Reopening or restarting an
engagement for the same puzzle cancels the pending end, while explicit
engagement endings still close the connection immediately.
This also enables the deployed Worker on workers.dev and adds close diagnostics
from the WebRTC host, logging whether a close came from teardown, data channel,
connection state or ICE state along with the relevant peer/channel states.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
6 files changed, 73 insertions(+), 6 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -595,8 +595,8 @@ private struct PuzzleDisplayView: View {
let movesUpdater = services.movesUpdater
let syncEngine = services.syncEngine
let id = gameID
+ services.scheduleEngagementEnd(gameID: id)
Task {
- await services.endEngagement(gameID: id)
await movesUpdater.flush()
// Mirror the open-burst pattern: the clear-cursor and
// close-lease enqueues both target the same Player record,
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -23,6 +23,8 @@ final class AppServices {
private static let readLeaseDuration: TimeInterval = 10 * 60
private static let readLeaseRefreshFloor: TimeInterval = 5 * 60
+ private static let engagementTeardownDelaySeconds = 120
+ private static let engagementTeardownDelay: Duration = .seconds(engagementTeardownDelaySeconds)
enum FreshenReason {
case appeared
@@ -126,6 +128,7 @@ final class AppServices {
private var fresheningPuzzleGridKeys: Set<String> = []
private var isGameListVisible = false
private var latestLocalSelections: [UUID: PlayerSelection] = [:]
+ private var scheduledEngagementEndTasks: [UUID: Task<Void, Never>] = [:]
init() {
let preferences = PlayerPreferences()
@@ -607,6 +610,7 @@ final class AppServices {
}
func offerEngagement(gameID: UUID) async {
+ cancelScheduledEngagementEnd(gameID: gameID)
guard preferences.isICloudSyncEnabled else {
syncMonitor.note("engagement: manual offer skipped, iCloud sync is disabled")
return
@@ -623,6 +627,7 @@ final class AppServices {
}
func startEngagementIfPossible(gameID: UUID) async {
+ cancelScheduledEngagementEnd(gameID: gameID)
guard preferences.isICloudSyncEnabled else { return }
guard await ensureICloudSyncStarted() else { return }
await engagementCoordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
@@ -634,6 +639,7 @@ final class AppServices {
}
func endEngagement(gameID: UUID) async {
+ cancelScheduledEngagementEnd(gameID: gameID)
syncMonitor.note("engagement: ending for \(gameID.uuidString)")
engagementStatus.setLive(false, gameID: gameID)
latestLocalSelections[gameID] = nil
@@ -641,6 +647,35 @@ final class AppServices {
await engagementCoordinator.teardown(gameID: gameID)
}
+ func scheduleEngagementEnd(gameID: UUID) {
+ cancelScheduledEngagementEnd(gameID: gameID)
+ guard engagementStatus.isLive(gameID: gameID) else { return }
+ syncMonitor.note(
+ "engagement: scheduled ending for \(gameID.uuidString) " +
+ "in \(Self.engagementTeardownDelaySeconds)s"
+ )
+ scheduledEngagementEndTasks[gameID] = Task { [weak self] in
+ do {
+ try await Task.sleep(for: Self.engagementTeardownDelay)
+ } catch {
+ return
+ }
+ await self?.finishScheduledEngagementEnd(gameID: gameID)
+ }
+ }
+
+ func cancelScheduledEngagementEnd(gameID: UUID) {
+ guard let task = scheduledEngagementEndTasks.removeValue(forKey: gameID) else { return }
+ task.cancel()
+ syncMonitor.note("engagement: cancelled scheduled ending for \(gameID.uuidString)")
+ }
+
+ private func finishScheduledEngagementEnd(gameID: UUID) async {
+ guard scheduledEngagementEndTasks.removeValue(forKey: gameID) != nil else { return }
+ syncMonitor.note("engagement: scheduled ending fired for \(gameID.uuidString)")
+ await endEngagement(gameID: gameID)
+ }
+
func noteLocalSelection(_ selection: PlayerSelection, gameID: UUID) async {
latestLocalSelections[gameID] = selection
guard engagementStatus.isLive(gameID: gameID),
@@ -687,6 +722,10 @@ final class AppServices {
self.engagementStore.clear(gameID: gameID)
await self.startEngagementIfPossible(gameID: gameID)
}
+ case .diagnostic(let engagementID, let message):
+ syncMonitor.note(
+ "engagement: diagnostic \(engagementID?.uuidString ?? "unknown"): \(message)"
+ )
case .error(let engagementID, let message):
syncMonitor.note("engagement: error \(engagementID?.uuidString ?? "unknown"): \(message)")
}
diff --git a/Crossmate/Services/EngagementHost.html b/Crossmate/Services/EngagementHost.html
@@ -49,14 +49,14 @@
if (pc.connectionState === "failed" ||
pc.connectionState === "disconnected" ||
pc.connectionState === "closed") {
- postClose(engagementID);
+ postClose(engagementID, "connectionState");
}
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === "failed" ||
pc.iceConnectionState === "disconnected" ||
pc.iceConnectionState === "closed") {
- postClose(engagementID);
+ postClose(engagementID, "iceConnectionState");
}
};
return peer;
@@ -71,7 +71,7 @@
peer.channel = channel;
channel.binaryType = "arraybuffer";
channel.onopen = () => post({ type: "onChannelOpen", engagementID });
- channel.onclose = () => postClose(engagementID);
+ channel.onclose = () => postClose(engagementID, "dataChannel");
channel.onerror = (event) => postError(engagementID, event.error || "Data channel error");
channel.onmessage = async (event) => {
try {
@@ -141,7 +141,7 @@
return bytes;
}
- function postClose(engagementID) {
+ function postClose(engagementID, reason) {
if (closedEngagementIDs.has(engagementID)) {
return;
}
@@ -150,9 +150,26 @@
if (peer) {
peer.closed = true;
}
+ post({
+ type: "onDiagnostic",
+ engagementID,
+ message: closeDiagnosticMessage(peer, reason)
+ });
post({ type: "onChannelClose", engagementID });
}
+ function closeDiagnosticMessage(peer, reason) {
+ const pc = peer ? peer.pc : null;
+ const channel = peer ? peer.channel : null;
+ return [
+ "close reason=" + (reason || "unknown"),
+ "connection=" + (pc ? pc.connectionState : "missing"),
+ "ice=" + (pc ? pc.iceConnectionState : "missing"),
+ "gathering=" + (pc ? pc.iceGatheringState : "missing"),
+ "channel=" + (channel ? channel.readyState : "missing")
+ ].join(" ");
+ }
+
async function createOffer(engagementID, _signal, iceServers) {
try {
const peer = createPeer(engagementID, iceServers);
@@ -212,7 +229,7 @@
if (!peer) {
return true;
}
- postClose(engagementID);
+ postClose(engagementID, "teardown");
if (peer.channel) {
peer.channel.close();
}
diff --git a/Crossmate/Services/EngagementHost.swift b/Crossmate/Services/EngagementHost.swift
@@ -10,6 +10,7 @@ final class EngagementHost: NSObject {
case channelOpen(engagementID: UUID)
case channelMessage(engagementID: UUID, message: Data)
case channelClose(engagementID: UUID)
+ case diagnostic(engagementID: UUID?, message: String)
case error(engagementID: UUID?, message: String)
}
@@ -252,6 +253,12 @@ extension EngagementHost: WKScriptMessageHandler {
case "onChannelClose":
guard let engagementID else { return }
noteChannelClose(engagementID)
+ case "onDiagnostic":
+ let diagnosticMessage = (body["message"] as? String) ?? "Unknown engagement host diagnostic"
+ onEvent?(.diagnostic(
+ engagementID: engagementID,
+ message: diagnosticMessage
+ ))
case "onError":
let errorMessage = (body["message"] as? String) ?? "Unknown engagement host error"
if shouldSuppressError(engagementID: engagementID, message: errorMessage) {
diff --git a/Crossmate/Views/EngagementDebugView.swift b/Crossmate/Views/EngagementDebugView.swift
@@ -188,6 +188,9 @@ struct EngagementDebugView: View {
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)")
diff --git a/Worker/wrangler.toml b/Worker/wrangler.toml
@@ -1,6 +1,7 @@
name = "crossmate-turn"
main = "turn-credentials.js"
compatibility_date = "2026-05-25"
+workers_dev = true
[vars]
TURN_TTL_SECONDS = "3600"