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.
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.
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)
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.
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.)
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).
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.
Topic:
Graphics & Games
SubTopic:
GameKit
Tags: