We ship a two-player real-time GameKit game. On iOS 27 the app crashes inside GameKitServices on the second real-time match of any app session — nine times out of nine yesterday. Filed as FB24789094 (and FB24788999 for a separate reinvitation problem). Posting the measurements here because the unified log makes the mechanism visible, and because everything I tried at the app level failed — maybe somebody has the missing piece.
THE CRASH
Main thread, no application frame anywhere on the stack:
CFRetain + 52
GCKSessionReceiveDOOB + 1632
-[GKSessionInternal receiveDOOB:fromPeer:inSession:context:] + 320
-[GKSessionInternal(_private) tellDelegate_didReceiveBand_RetryICE:] + 212
__NSThreadPerformPerform + 264
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__
EXC_BAD_ACCESS (SIGBUS), EXC_ARM_DA_ALIGN at 0x9 (0xa in one report). In every report the faulting address is exactly x0 + 8, and x0 is 1 or 2 — CFRetain is handed a small integer, not a pointer. A use-after-free would fault on a large plausible-looking address; 1 and 2 are not addresses at all. Address Sanitizer agrees: no heap error is reported before the fault, only __asan::ReportDeadlySignal directly above the same three GameKitServices frames. It reads like type confusion in the RetryICE band path.
Not the SDK: a build made with Xcode 26.6 against iphoneos26.5 crashes identically — same stack, same registers. The peer's OS does not matter either (iPadOS 17.7.11, iOS 26.6.1 and iOS 27.0 all seen on the other side, and in one case an older build of our own app). Only the crashing device is always on iOS 27.0 (24A435), an iPhone 15 Pro.
RECIPE
Two devices, two Game Center accounts.
Form a two-player real-time GKMatch — GKMatchmaker.findMatch(for:) or an invitation, both reproduce. Wait for the peer to connect, call finishMatchmaking(for:).
Play for ten seconds or so, so data really flows.
End it: match.disconnect(), release the GKMatch.
In the SAME process, form a SECOND real-time match.
Play. The crash lands roughly ten to thirty seconds after the second match starts exchanging data.
A first match in a freshly launched process has never crashed for us. Out of eleven app sessions that reached a second match, two were force quit by hand during other tests and the remaining nine all crashed here.
THE SESSION IS NEVER RECLAIMED
GameKit runs one "com.apple.gamekitservices.gcksession.recvproc" / "sendproc" thread pair per live real-time session, in the app's own process. You can count them from inside the app, which turns out to be the only way to see what is going on:
var threads: thread_act_array_t?
var count: mach_msg_type_number_t = 0
task_threads(mach_task_self_, &threads, &count)
// then pthread_from_mach_thread_np(threads[i]) + pthread_getname_np, and compare the name
Measured across one app session:
after Game Center authentication, before any match ......... 0
first match live ........................................... 1
right after match.disconnect() returns ..................... 1 <- not reclaimed
before the second match is adopted ......................... 2
right after the second match's disconnect() ................ 2
Every crash report shows two of those pairs alive, although the app holds exactly one GKMatch at a time and releases the finished one the instant disconnect() returns. The same leak is visible on iOS 26.6.1 (an iPhone 11 Pro leaks identically) — it just does not crash there.
WHAT THE LEFTOVER SESSION DOES
Each match gets its own CDXClient. The first match's is never torn down, and keeps poking its hole on a 30-second keep-alive, on the same local port (:16402) the second match then binds:
15:28:05.117 <CDXClient: 0x155872e40> requesting-hole-punch <- match 1
15:28:35.289 <CDXClient: 0x155872e40> requesting-hole-punch <- match 1, +30s, match already over
15:28:50.197 <CDXClient: 0x12d4bda40> requesting-hole-punch <- match 2
15:29:05.287 <CDXClient: 0x155872e40> requesting-hole-punch <- match 1 again, 4s before the crash
GCKSessionCreate:6425 globalscopelaunch appears once per process; the second match only does 6509 globalscoperequest and reuses the global scope. During the second match's ICE setup a packet arrives belonging to no live session:
-[CDXClient handleFDEvent]:1066 packet-from-unknown-session
-[CDXClient handleFDEvent]:1067 Incoming packet from unknown session. SID = ...
immediately followed by
[ERROR] ICEStopConnectivityCheck:2763 ICEStopConnectivityCheck() found no ICE check with call id (...)
[ERROR] gckSessionCheckPendingConnections:1545 iICEChecksLeft=0, iUnconnectedNodeCount=0, iDDsExpected=1
Those lines appear exactly three times in a 28-minute window, all three in the two processes that went on to crash, never during a first match. About fourteen seconds later the RetryICE band is delivered and CFRetain is called on the garbage.
WHAT DID NOT WORK
Releasing the GKMatch immediately after disconnect(). The session threads stay.
Waiting. Gaps of 17, 19, 22, 25, 30 and 45 seconds between the two matches all crashed.
Tearing the match down cleanly. I suspected that calling disconnect() while match.players was still non-empty was the trigger — five out of five such teardowns were followed by a crash. So I made the quitting side announce its departure, wait for match(_:player:didChange:) to report the peer gone, and only then disconnect. It works (disconnect now runs with players == 0) and it changes nothing: the count still reads 1 afterwards, and the next match still crashes. Worth saying explicitly, because the correlation was strong enough to look causal.
Skipping finishMatchmaking(for:). No effect.
The only thing that reliably avoids it is allowing a single real-time match per app launch, which is a poor thing to ship.
QUESTIONS
Is there a supported way to make GameKit release a finished GKMatch's session inside the process? A GKMatch that never connected (peer wait timed out, disconnect() on an empty match) appears to leave the same residue, so it is not about how the match ended.
And has anyone else seen GCKSessionReceiveDOOB / tellDelegate_didReceiveBand_RetryICE? I could not find a single mention of these symbols anywhere.
1
1
261