GKMatch: GameKitServices crashes in GCKSessionReceiveDOOB on the second real-time match — the finished session is never reclaimed

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

  1. Two devices, two Game Center accounts.
  2. Form a two-player real-time GKMatch — GKMatchmaker.findMatch(for:) or an invitation, both reproduce. Wait for the peer to connect, call finishMatchmaking(for:).
  3. Play for ten seconds or so, so data really flows.
  4. End it: match.disconnect(), release the GKMatch.
  5. In the SAME process, form a SECOND real-time match.
  6. 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.

Follow-up after a night of experiments, in case it saves someone the same night. Short version: the GKMatch object itself is released fine; what leaks is held from the C side of GameKitServices, and nothing an app can do — public or private — releases it or stops the crash. Details below, all measured on the same iPhone 15 Pro / iOS 27.0 (24A435) pair of devices.

  1. THE GKMATCH IS DEALLOCATED. THE SESSION IS NOT.

A weak reference to the GKMatch reads nil within 3 seconds of disconnect(), whatever the call order (delegate nil'd before or after disconnect, Apple's sample order, no disconnect() at all, GKMatchmaker.cancel() afterwards). The gcksession.recvproc / sendproc thread pair stays. So there is no application-side retain of the match to look for.

  1. WHAT IS UNDER A GKMATCH ON iOS 27

Read by reflection on the device (class_copyIvarList on the live objects), because the macOS layout is different:

GKMatch._transport = GKCompositeTransport ._viceroyTransport = GKViceroyTransport ._connection = GKConnectionInternal (owns the C session and the CDXClient) .cdxClient = CDXClient -> .delegate = GKConnectionInternal ._eventDelegate = GKSessionInternal ._session = GKViceroySession -> ._session = GKSessionInternal ._relay = GKViceroyRelay ._fastSyncTransport = FastSyncTransport (new, Swift)

  1. WHAT SURVIVES, AND WHAT DOES NOT HELP

After disconnect(), sending disconnectFromAllPeers to the GKSessionInternal, stopHolePunchTimer / stopListeningOnSockets / invalidate to the CDXClient, preRelease to the GKConnectionInternal, then nil'ing every back-reference between them (session -> delegate/privateDelegate/dataReceiveHandler/connection, connection -> eventDelegate/cdxClient, cdxClient -> delegate_, transport -> session/connection/relay), cancelling the remaining dispatch sources and setting _stopHandlingEvents / _shutdown:

GKViceroyTransport: released GKConnectionInternal: ALIVE, retain count stable at 4 (+1 for the reading) CDXClient: ALIVE, retain count stable at 4 GKSessionInternal: ALIVE, retain count stable at 3 recvproc/sendproc pair: still running

Those owners are not ivars and not dispatch sources. They sit across the C boundary: the GCKSession holds its Objective-C contexts, and it would only be destroyed in their dealloc. GameKitServices exports 51 symbols and no GCKSession* function, so there is nothing to call.

  1. THE NETWORK SIDE IS NOT THE CHANNEL EITHER

GameKit does close the finished session's own sockets (:16402) at disconnect(). The one UDP socket it leaves open is the relay client's — one unconnected socket on an ephemeral port, through which the leaked CDXClient keeps punching its hole every 30 s. Replacing that descriptor in place with a UDP socket connected to the discard port (dup2, so the fd number stays taken) 15 s before the next search: match forms normally, and crashes identically.

(Do not close() it: the leaked client keeps sending on that fd number every 30 s, onto whatever socket gets it next — and a sweep that is not snapshotted at teardown time will close the next search's own relay socket, which then never connects.)

  1. THE TRIGGER IS THE C-LEVEL ICE RETRY, ON A FIXED CLOCK

The crash lands 14–21 s after the second match starts exchanging data, never earlier — and a second match that is quit before that never crashes (four in a row, no crash). In the unified log the sequence is: bind -> 0.3 s later "packet-from-unknown-session" on the new CDXClient -> "ICEStopConnectivityCheck() found no ICE check with call id" -> connected anyway -> ~14 s -> tellDelegate_didReceiveBand_RetryICE -> CFRetain(1). Switching off the transport health monitor on both peers at connection (stopMonitoringAll, GKTransportContext.healthMonitorEnabled = NO, _healthMonitor nil'd — all confirmed to land) changes nothing, so the retry is not the monitor's; it comes from gckSessionCheckPendingConnections itself (iDDsExpected=1 is logged unsatisfied both times).

  1. WHERE THAT LEAVES AN APP

One real-time match per process on iOS 27, detected at runtime by counting gcksession.recvproc threads (task_threads + pthread_getname_np) rather than by OS version, so it stops firing the day the leak is fixed. Everything above is in FB24789094.

If anyone from GameKit reads this: the leak is visible in any app that plays two real-time matches in one launch, and the crash needs the second one to live past its first ICE retry. Happy to run anything you want on these devices.

GKMatch: GameKitServices crashes in GCKSessionReceiveDOOB on the second real-time match — the finished session is never reclaimed
 
 
Q