Network sessions and the SystemLink protocol
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit c1c316b9; unless a sentence says otherwise, nothing here was built or executed. Checked by reading modules/net, modules/gamer-services, the process harnesses and their tests, docs/gamer-services-server.md and docs/gamer-services-known-limitations.md at c1c316b9. Nothing was built or executed, and the code fragments were not compiled for this page. Multi-machine discovery and migration, real Internet play, Windows sockets and every Emscripten path remain unverified; the macOS results quoted come from CNA's Apple campaign record.
CNA's Microsoft::Xna::Framework::Net has two real transports and two in-process lifecycles. NetworkSessionType::SystemLink puts ENet datagrams directly on the local network through the vendored ENet library and works without any service; PlayerMatch and Ranked carry the same ENet datagrams through the authenticated relay of a configured CNA service; Local and LocalWithLeaderboards keep XNA's lifecycle on one machine without a socket. This page gives the exact session semantics a game can rely on, the online path, the SystemLink wire and discovery protocols, the trust model, delivery guarantees, host migration and the evidence behind each. It complements Tutorial 97, which teaches the task, and the network session internals, which trace ownership and the pump for maintainers.
What each session type does
An object that only watches session state cannot tell a real session from a simulated one, which is why this distinction comes first. The LAN transport gate is ENetBackend::RealNetworkingEnabled, true only for SystemLink (ENetBackend.cpp); NetworkSession.cpp sends the two matchmaking types to the CNA service, and refuses them when no service is configured.
NetworkSessionType | Create | Find | Data delivery |
|---|---|---|---|
SystemLink | Starts an ENet host and advertises it for LAN discovery; works offline with signed-in local profiles | Real UDP query with measured quality of service (native); empty on Emscripten | Real, direct on the LAN: handshake, relay through the host, voice, disconnect handling, simulated latency and loss, optional host migration |
Local | Synthetic session; no socket | Throws ArgumentException (a local session is never discovered) | None: packets sent on it never arrive, not even at the sending machine's own gamers |
LocalWithLeaderboards | Synthetic, exactly like Local | Returns an empty collection | None. For a signed-in account it scopes leaderboard writes: rows written while playing are committed to the service's boards at EndGame; a local profile's rows go to the offline store |
PlayerMatch, Ranked | Registers the session in the CNA service's directory; without a configured service and signed-in accounts, throws GamerServicesNotAvailableException | Searches the service's directory (same refusal) | Real, native only: every datagram, game data and voice, through the service's authenticated relay |
What a synthetic session keeps is the lifecycle: gamers, StartGame/EndGame, state transitions and their events all work with no socket bound, and Find for LocalWithLeaderboards returns an empty collection at once instead of waiting for a discovery window. NetworkSessionTypePolicyTests.cpp pins both halves: for the two creatable synthetic types, no bound port and no data available after SendData plus Update; for the two matchmaking types without a service, the refusals, including that a refused call leaves no pending action behind. JoinInvited joins the session of an invitation a player accepted in the Guide, which needs the service; without one it throws GamerServicesNotAvailableException, and without an accepted invitation InvalidOperationException. The static InviteAccepted event is raised when a player accepts an invitation, or joins a friend's or party member's game from the Guide (see online sessions).
Creating, owning and pumping a session
Who owns what
Create and Join return a raw NetworkSession* that the caller owns; Find returns an AvailableNetworkSessionCollection by value; JoinInvited returns a caller-owned session like Join. Dispose() tears down the ENet host, disconnects peers, unregisters discovery and frees every gamer object the session and its transport created, but it does not delete the session object. The caller performs both steps; if a caller deletes without disposing, the destructor disposes first, because a dangling process-wide session pointer would otherwise block every later Create. A custom deleter makes the pairing automatic:
struct SessionDeleter {
void operator()(NetworkSession* s) const { s->Dispose(); delete s; }
};
using SessionPtr = std::unique_ptr<NetworkSession, SessionDeleter>;
SessionPtr session(NetworkSession::Create(NetworkSessionType::SystemLink, 1, 8));
session->GamerJoined += [](System::Object*, const GamerJoinedEventArgs& e) {
// Runs now for every gamer already in AllGamers, later from Update() for new joins.
NetworkGamer* gamer = e.getGamerProperty();
};
// Once per game update; nothing else pumps the transport or raises events.
session->Update();
This and the packet example below show the call shapes; they were not compiled for this page. Gamers created by the session are owned by it (LocalNetworkGamer objects in the session, remote gamers in the transport's per-session state); the four gamer collections are non-owning views. Subscribing to GamerJoined replays the handler synchronously for every gamer already in AllGamers, which is how the construction-time local gamers are reported without an extra Update; gamers who join later arrive through the event queue that Update drains.
Argument limits and the gamer count
BeginCreate validates maxLocalGamers in 1–4 (the explicit gamer-list overload has none), maxGamers in 2–MaxSupportedGamers (31) and privateGamerSlots in 0–maxGamers, throwing ArgumentOutOfRangeException. The requested maxGamers is forwarded to the session, so a session created with 8 reports 8, and AddRemoteGamer refuses a gamer beyond it with InvalidOperationException (“Session is full!”). Over the wire that refusal is weak: when the host handles a ClientHello it has already sent the joining client its ServerWelcome and registered its wire ids before AddRemoteGamer throws, and the receive path's catch-all drops the exception, so a join beyond the limit is not turned away with NetworkSessionJoinError::SessionFull (the SystemLink path never produces that value: CNA-BUG-267) and no test joins a full SystemLink session (read from the source; not executed). A joining machine's session is constructed with MaxSupportedGamers and four private slots, values the ported FNA code marks as placeholders; the host then sends its settings right after the welcome, and again whenever they change, so a client reports the host's MaxGamers, PrivateGamerSlots, AllowJoinInProgress and AllowHostMigration. MaxPreviousGamers is 100; older departed gamers are evicted first.
One operation, one session, per process
Every Create, Find, Join and JoinInvited overload is a Begin*/End* pair, and all of them share two class statics: the in-flight action and the live session. Any Begin* throws InvalidOperationException while either is set, so only one operation can be outstanding and only one session can exist in the process: a game cannot browse for sessions while it hosts one, and tests that need two peers use two processes. The IAsyncResult* a Begin* returns is caller-owned: keep it through End*, then delete it (the synchronous wrappers do this themselves). End* may be called once per result; a second call throws InvalidOperationException, and a result from another operation ArgumentException.
How the result completes depends on the type. For Local, LocalWithLeaderboards and SystemLink the action is complete before Begin* returns, reports CompletedSynchronously as true, and its callback runs once right after it is installed; the real work happens inside End*: EndCreate constructs the session and starts hosting, EndFind performs the discovery search, EndJoin connects. For PlayerMatch and Ranked the request is queued to the service backend, the result reports CompletedSynchronously as false, and it completes (signalling its wait handle and running its callback) only during a GamerServicesDispatcher::Update on the game's thread. An End* called before that pumps the dispatcher itself and gives up after 30 seconds with TimeoutException, so the synchronous wrappers neither hang nor need another thread; deleting a pending online result cancels it. The dispatcher side of this contract is described in the GamerServices contract.
Join is blocking on native hosts
BeginJoin takes the session type, address and port from the AvailableNetworkSession it is given. On native builds EndJoin connects and then pumps Update on the calling thread, sleeping 1 ms per iteration, until the server welcome has replaced the construction-time host placeholder, the session has ended, or five seconds have passed; a timeout disposes and deletes the half-joined session and throws NetworkSessionJoinException with SessionNotFound, and any other failure is wrapped in the same exception type with its cause. XNA documents Join as blocking, and returning earlier let a faithful caller send its first packet to itself. On Emscripten that wait is compiled out: Join returns before the handshake and the game must keep calling Update.
NetworkSessionJoinError declares three values, SessionNotFound, SessionNotJoinable and SessionFull. On the online path the service's answer maps onto all three. On the SystemLink path only the first is ever produced: it is what EndJoin reports when the handshake does not complete, and the default of NetworkSessionJoinException, which the catch-all path also uses. A full SystemLink session never answers SessionFull (CNA-BUG-267), and a refused SystemLink join (a host that is playing with AllowJoinInProgress false disconnects the hello) cannot be told from an absent host by the error value. Read from the sources at c1c316b9; not executed.
Online sessions through the CNA service
PlayerMatch and Ranked sessions need a configured CNA service (see the GamerServices contract) and signed-in accounts. Every listed local gamer must be an account with the online-session privilege: local profiles and guests are refused with GamerServicesNotAvailableException, and so is any call while no service is configured or gamer services are not initialised. A browser build has no service transport and refuses both types, and an Android build without libcurl refuses them the same way. A native build whose libcurl has no WebSocket support (Apple's system libcurl 8.7.1, or any libcurl older than 7.86) refuses the relay before any I/O with RELAY_SECURE_WEBSOCKET_UNAVAILABLE.
- Directory.
Createregisters the session in the service's session directory,Findpages through it (up to 256 matches, filtered by the eight session properties and by open public slots for the searching gamers) andJoinjoins a listing. Session properties are a fixed set of eight nullable integers, the same shape as on SystemLink.Rankedsessions do not admit joins while a game is in progress. - Transport. Every datagram of an online session, game data and voice alike, is an ENet datagram carried through the service's authenticated WebSocket relay (
/cna/relay/v1, TLS like the control endpoint). There is no direct peer path and no NAT traversal (no STUN or ICE), so traffic always passes through the server and inherits TCP's head-of-line latency under loss. The relay tickets bind the authenticated local gamers, title, session and machine, and the server injects the source identity of every frame. - Invitations and parties. A player invites from the Guide (gamer cards, Friends, Party pages) or the game calls
Guide::ShowGameInvite; the invitee accepts in the Guide,InviteAcceptedis raised (replayed once to the first subscriber while the invitation still exists) and the game callsJoinInvited. Invitations are CNA service identifiers with a 15-minute lifetime, not Xbox tokens.LocalNetworkGamer::SendPartyInvitesinvites the rest of the player's party to the current online session. - Host migration. With
AllowHostMigrationset by the host, the service hands the session to the machine with the lowest remaining gamer id when the host leaves, or when its relay connection does not come back within 20 seconds; every machine sees the old host's gamers leave and thenHostChanged. A client waits up to 30 seconds for that decision before the session ends withHostEndedSession. Until the new host has admitted a client, application data that client sends to it is held in order (at most 128 messages and 4 MiB; more fails withLIMIT_EXCEEDED) and sent when the host's welcome arrives; a failed session discards it. The election rule is CNA's policy; XNA does not say how Xbox LIVE chose. - More gamers and machines.
AddLocalGamerasks the service to add a signed-in account to this machine's group; the gamer arrives withGamerJoinedat a laterUpdate. The host can remove a machine withNetworkMachine::RemoveFromSession. - Leaderboards. Writes made while playing are committed for the accounts at the host's
EndGame, which is also whereWriteUnarbitratedLeaderboard,WriteArbitratedLeaderboardandWriteTrueSkillare raised; aRankedrow needs a strict majority of the reporting machines. - Quality of service. Search results report
QualityOfService::IsAvailableasfalse: before a join there is no path through the relay to measure. Joined sessions measureRoundtripTimeand the session's bytes per second.
CNA records the online path as tested over loopback and in Linux network namespaces with the server's two-process harnesses; seven cases that need Linux network namespaces were skipped in CNA's final Linux run (2 October 2026). On one Apple M4, CNA's Apple campaign records the server's CNA-driven tests passing over loopback with a WebSocket-capable libcurl (Homebrew curl), except those namespace-isolated variants. Nothing establishes play across the public Internet, on Windows, or in a browser. No public service instance is evidenced; a game that wants online play needs an operator to run cna-gamer-services-server.
Topology and gamer identity
SystemLink is a star. Every client talks only to the host; application data addressed to another client is relayed by the host to the peer that owns the target's wire id, and never echoed back to its origin. Wire ids are single bytes assigned by the host during the handshake, and ids freed by departed peers are reused before the counter advances, so the byte cannot wrap after 256 cumulative joins. Because the host assigns them, NetworkGamer::Id and IsHost agree across machines; the FNA code CNA ported answers a fixed 0 and true for every gamer. A local gamer's id is provisional until the handshake overwrites it, and it comes from a monotonic counter rather than a collection size, so a remove-then-add cannot produce a duplicate. NetworkGamer::RoundtripTime starts from ENet's per-peer measurement, which exists only for directly connected peers: the host measures its clients, and about once a second sends each client its round trips to the gamers on other client machines, so a client reports its own round trip to the host plus the host's to that gamer.
The session wire protocol
Session and application messages travel over ENet on a connected channel; NetPacketCodec.hpp defines the leading tag byte:
| Tag | Message | Sent by | Carries |
|---|---|---|---|
0x01 | ClientHello | Client, on connect | Its local gamertags |
0x02 | ServerWelcome | Host | Assigned wire ids, a roster snapshot with each gamer's host flag, the session properties |
0x03 | GamerJoinBroadcast | Host | New roster entries for already-connected clients |
0x04 | GamerLeaveBroadcast | Host | Wire ids that left |
0x05 | (reserved) | Nobody | Reserved for a host-change broadcast that was never implemented; migration works without it |
0x06 | StateChangeBroadcast | Host | A StartGame/EndGame state transition |
0x07 | SessionPropertiesBroadcast | Host | A complete replacement snapshot of NetworkSessionProperties |
0x08 | GamerReadyBroadcast | Client for its own gamers; host for everyone | Lobby readiness (IsReady) |
0x09 | SessionSettingsBroadcast | Host | MaxGamers, private slots, AllowJoinInProgress, AllowHostMigration |
0x0A | MachineRosterBroadcast | Host | Which machine each gamer is on (NetworkGamer::Machine) |
0x0B | AddLocalGamerRequest | Client | A local gamer added with AddLocalGamer, for the host to admit |
0x0C | NetworkStatsBroadcast | Host | The host's round trips to the gamers on client machines |
0x0D | VoiceData | Any peer | One gamer's voice frame for one remote gamer, routed like AppData |
0x10 | AppData | Any peer | Sender and target wire ids, the SendDataOptions, the payload |
Encoding reuses PacketWriter/PacketReader, the same BinaryWriter/BinaryReader wrappers games use. Every list count on this wire is one byte, and EncodeCount throws rather than wrapping when a list would exceed 255 entries; MaxSupportedGamers keeps rosters far below that. The host is created with 31 peers and two channels: session and application messages use channel 0, and voice travels unreliably on channel 1 so it never waits behind reliable traffic. Session properties are published by polling: on each pump the host compares the properties with the last snapshot it broadcast (the get-only XNA property is a mutable collection, so edits cannot be observed any other way) and broadcasts a full snapshot when they differ.
A send issued before the handshake has given both its sender and its target a wire id cannot be addressed yet. The backend queues such sends per session (at most 64; the 65th evicts the oldest) and flushes them in order once a ClientHello, ServerWelcome or GamerJoinBroadcast resolves both ends. Entries naming a gamer who leaves or disconnects are purged, and host migration or the end of a client's session clears the queue; every entry that can never be delivered increments a process-wide counter, GetDroppedAppDataCount, which tests and diagnostics read but NetworkSession does not expose.
LAN discovery
ENet has no discovery, so ENetDiscoveryService.cpp runs a separate raw-UDP protocol (NetDiscoveryProtocol.hpp) on the well-known port 61190. A searching client sends a Query (tag 0x01: protocol version, session-type filter) by broadcast and, as a same-machine fallback, by unicast to 127.0.0.1, then polls on the calling thread for a 150 ms window and de-duplicates the Announce replies (tag 0x02) by connect port. On Apple platforms the query is also sent to every up interface's own broadcast address, because Darwin refuses the global broadcast address, and from a socket of the search's own, so that a host on the same Mac cannot capture the replies, while the shared port keeps answering other machines. Tags 0x03 to 0x06 carry the bandwidth probes described below. A hosting session answers during its own Update when the query's filter matches its type, with its connect port, gamer counts, open private and public slots, host gamertag and advertised properties. The game session itself uses an OS-assigned ENet port on native hosts; discovery is what tells a client which one.
Each reply's QualityOfService carries the measured time between sending the query and receiving that reply as both its average and minimum round-trip time (one exchange, so the two are equal). Bandwidth is measured with probe trains in both directions: right after its Announce the host sends a back-to-back train whose spacing at the searcher gives BytesPerSecondDownstream, and it invites the searcher to send a train to a responder on its own socket and thread, which times the arrivals and reports BytesPerSecondUpstream back. A measured result reports IsAvailable as true.
Parsing untrusted datagrams
Discovery datagrams come from any device on the LAN, so the decoder assumes hostile input. The protocol-version byte is compared with kDiscoveryProtocolVersion (1) before anything else is parsed, so a future format is refused cleanly instead of being misread. Advertised properties travel as sparse (index, value) pairs, and each index must lie in [0, kMaxPropertyIndex), which is 256: a negative index would reach the vector with a huge unsigned offset, and an index near the int32 maximum would drive about two billion Add calls from one crafted packet. A malformed datagram is dropped, and a guard resets the search's result pointer even when decoding throws.
The trust model
There is no authentication or encryption on either wire. The protocol's defence is structural, and its stated threat is a modified client speaking the easily inferred wire format, which needs no man-in-the-middle position:
- Host-only messages are accepted only from the host.
ServerWelcome,GamerJoinBroadcast,GamerLeaveBroadcast,StateChangeBroadcast,SessionPropertiesBroadcast,SessionSettingsBroadcast,MachineRosterBroadcastandNetworkStatsBroadcastare honoured only when they arrive from the peer this side itself connected to; a host never connects out, so it rejects all eight, and only a host admits anAddLocalGamerRequest. A forged one is logged as a protocol event and the sender is disconnected. Without this, any peer could kick gamers, inject fake ones, corrupt wire-id assignment or force a state change. Every machine needs the check, because even a client owns a real accepting ENet host. - A repeated hello disconnects the peer. A peer that completed its handshake and sends
ClientHelloagain would otherwise create a fresh batch of gamer objects each time. - Join-in-progress is enforced. A host in
PlayingwithAllowJoinInProgressfalse disconnects a new client's hello. - Malformed packets cannot throw out of
Update. A payload that fails to decode is dropped inside a guard.
Each rule has a test in ENetBackendTests.cpp. None of this authenticates a peer: a LAN session is exactly as trustworthy as the machines on the LAN. Online sessions add authentication at the relay (only the session's authenticated machines are routed, with the source identity injected by the server) and the same membership checks on the host, but the host is still trusted to relay game payloads: this is membership validation, not protection against a malicious host or modified client.
Delivery guarantees
XNA defines five SendDataOptions; ENet can express three behaviours, and NetPacketCodec::SendDataOptionsToEnetFlags maps them:
| Option | ENet flag | Guarantee a CNA peer actually gets |
|---|---|---|
None | ENET_PACKET_FLAG_UNSEQUENCED | Best effort, may arrive out of order |
InOrder | none (plain send) | Best effort, sequenced: late packets are dropped rather than delivered out of order |
Reliable | ENET_PACKET_FLAG_RELIABLE | Reliable and ordered |
ReliableInOrder | ENET_PACKET_FLAG_RELIABLE | Reliable and ordered |
Chat | ENET_PACKET_FLAG_RELIABLE | Reliable and ordered (a CNA policy choice; FNA never delivered chat) |
ENet's reliable delivery is always ordered, and reproducing “reliable but unordered” would need another protocol layer, so Reliable receives the stronger ordered guarantee. Code written against XNA's weaker promise still works; code that relies on the ordering is relying on CNA, not on XNA. Two more rules shape what arrives. LocalNetworkGamer::SendData never touches the network: it only queues one event per recipient, and the bytes leave during the next Update. The broadcast overloads address every gamer in AllGamers, including the sender, so a receiver must check sender->getIsLocalProperty(). A reused PacketWriter sends only the bytes before its current position and is then rewound, so a short packet written after a long one does not carry stale trailing bytes.
Packet shapes that surprise
- Colours are four bytes both ways.
PacketWriter::Write(Color)writes R, G, B and A as bytes, andPacketReader::ReadColor()reads four bytes back; the doc comments in the two headers still describe a different layout. ReceiveDatareturns the packet size, as in XNA. ThePacketReaderoverload resizes the reader to the next packet, fills and rewinds it and returns its length (0, with an emptied reader, when nothing is queued). The byte-vector overload checks the offset first and refuses a packet that does not fit withArgumentExceptionwithout taking it off the queue, so a caller can retry with a larger buffer.- Session properties are a fixed set of eight.
NetworkSessionPropertiesalways holds eight nullable integers;Add,Insert,Remove,RemoveAtandClearthrowNotSupportedException, an index outside 0–7 throwsArgumentOutOfRangeException, and on a live session only the host may write a slot (InvalidOperationExceptionotherwise). Search results carry read-only snapshots.
PacketWriter writer;
writer.Write(tint); // four bytes
me->SendData(writer, SendDataOptions::ReliableInOrder);
PacketReader reader;
NetworkGamer* sender = nullptr;
while (me->getIsDataAvailableProperty()) {
me->ReceiveData(reader, sender); // returns the packet length
if (sender != nullptr && !sender->getIsLocalProperty()) {
Color received = reader.ReadColor(); // four bytes
}
}
const NetworkSessionProperties& props = session->getSessionPropertiesProperty();
std::optional<int> mode = props[0]; // slots 0-7; throws if out of range
Host migration
This section describes SystemLink; online sessions migrate through the service, as described above. AllowHostMigration defaults to false; losing the host then ends the session, which is the reference behaviour. With it set, every survivor computes the same successor from the roster it already holds, the lowest remaining wire id, so no election message is needed. Both roles begin the same way: every remote gamer is removed (queuing GamerLeft for each), all wire-id state is reset and queued sends are dropped. The successor then marks its local gamers as host, registers its already-bound ENet host for discovery and raises HostChanged. Every other survivor rediscovers the new host through the normal LAN search, matching by the cached gamertag, and performs an ordinary hello/welcome reconnect. The search is tried three times with its 150 ms window, because when several processes share the discovery port the operating system decides which socket receives a datagram. If nobody reachable is found the session ends as if migration were off.
Consequences for a game: migration is a reconnect, not a hand-over. Remote gamers come back as new NetworkGamer objects through GamerJoined, so pointers held as map keys must be re-keyed; sockets are not preserved; the departed objects stay alive in PreviousGamers until the session is torn down, so earlier event arguments do not dangle. Two hosts with the same gamertag on one LAN are ambiguous to the rediscovery step.
Simulated latency and packet loss
SimulatedLatency and SimulatedPacketLoss are stored-but-inert properties in FNA; in CNA they act, as test controls. Both apply only to application data arriving for one of this session's own local gamers: session-management messages and a host's relay hop between two other peers are never delayed or dropped. Loss is a per-packet probability; exactly 0 and exactly 1 never consult the random generator, which tests can seed. Latency parks packets in a per-session queue released when a later Update observes the due time, so delivery granularity is the frame rate. Neither affects the discovery-time QualityOfService measurement.
What SystemLink still does not do
Voice and machine removal do work on SystemLink: voice is routed automatically (Opus at 16 kHz in 20 ms frames, sent unreliably to each remote machine allowed to hear it, the host relaying; the machine's microphone belongs to Player One's gamer), and the host can remove a machine with NetworkMachine::RemoveFromSession. Voice needs libopus at build time (CNA_ENABLE_VOICE; never in a cross-compiled build such as Emscripten, Android or iOS) and CNA_VOICE=0 turns it off; it is tested in software, and a real microphone and speaker were not physically heard on CNA's test machine. What remains absent:
- Invitations. Invitations,
JoinInvitedandSendPartyInvitesbelong to online sessions;SendPartyInvitesoutside one throwsInvalidOperationException.IsPrivateSlotkeeps itsfalsedefault on SystemLink. - Leaderboard commit events.
WriteArbitratedLeaderboard,WriteUnarbitratedLeaderboardandWriteTrueSkillare raised only in an online session's service commit, never on SystemLink. - Internet play. Discovery is LAN broadcast and peers connect directly; there is no relay or NAT traversal on SystemLink. Play across the Internet is what
PlayerMatchandRankedprovide through the service relay.
SystemLink on the web
A browser page has no raw UDP and cannot accept connections. Under Emscripten the discovery functions are empty and Find returns nothing, so an empty result on the web means “cannot search”, not “no hosts yet”. Hosting requests the fixed port 61191, because Emscripten's socket layer does not report an OS-assigned port back; that is only meaningful for a Node.js-run relay or server build, since a browser tab cannot listen. A connecting web client rebuilds its transport state as an outbound-only client, and Join returns before the handshake (see above). Online sessions are refused in a browser build (BROWSER_SERVICE_TRANSPORT_UNAVAILABLE for the service, BROWSER_RELAY_TRANSPORT_UNAVAILABLE for the relay) and voice is not compiled there: browser multiplayer is outside CNA's current scope by the owner's decision. CNA's own networking demos are excluded from Emscripten builds. None of the Emscripten paths is covered by a browser test at the snapshot; they are source-level statements.
Lifetime hardening, historically
The session lifecycle carries a sequence of fixed defects, each of which left a guard in the current code. They are listed because the guards are easy to undo by accident:
Dispose()ran twice and walked gamer pointers the first call had freed, a use-after-free that AddressSanitizer observed while ordinary unit runs passed. It is now idempotent (DisposeCalledTwiceDirectlyIsSafeAndIdempotent).- After disposal, raw pointers stayed visible through the gamer collections and
Host;Disposenow clears all four views and nulls the host (DisposeClearsPreviousGamersSoNoDanglingPointerIsObservable). - Gamer objects the session created were never destroyed; the session and the transport now own them in registries separate from the non-owning views.
- Completion callbacks were stored and never invoked, including when a callback re-entrantly replaced the action; the callback now runs once, after the action is installed, and
Begin*returns the action it created rather than a stale null.End*does not delete the action:Begin*keeps ownership until it returns and then hands the result to the caller, so a callback that calls the matchingEnd*from inside itself frees nothing under its own feet (fixed in GS-007e2b;NetworkSessionTest.BeginCreateCallbackCanReentrantlyCallEndCreatepins it). End*cleared the static action without anyone deleting it, leaking one object per operation; the result is now caller-owned and tests count live actions.- Local gamer ids derived from a collection size collided after a remove and an add; ids now come from a monotonic counter.
- Application data sent before the handshake was silently dropped, and the first queueing fix did not purge entries when peers left; the bounded, purged queue above replaced both.
- A disposed gamer-collection enumerator's
MoveNext()dereferenced a null collection althoughCurrentguarded the same state; it now throwsNullReferenceExceptioninstead.
The lesson the sequence teaches is about evidence: several of these survived green unit suites and were found only under sanitizers or by integration, so a lifetime change here needs a create/dispose run under AddressSanitizer, not only the unit tests.
Evidence and its limits
Focused tests exist for the codec and discovery formats (including malformed and truncated input), the type policy, ownership and disposal, callbacks, id allocation, queue eviction and purging, forged-message rejection, simulated loss and latency with an injected clock and seeded generator, and the preserved compatibility quirks; the suites are listed on the internals page. Cross-process transport evidence comes from TwoProcessLoopbackTest.cpp, which uses posix_spawn to start real host and client processes of net_two_process_harness.cpp, hands the host's bound port to the client through a pipe rather than through discovery (two processes sharing the discovery port is fragile), and exchanges application data under a 20-second outer watchdog; a third case runs a host and two survivors to exercise migration across real processes. The online path is covered by OnlineNetworkSessionTest, the relay and service-roster suites in the same directory and, against a real server, by the server repository's two-process harness cases (session, invitation, restart, migration, host crash, added gamer, relay). CNA's own audit record reports the Net suite at 524 passed, 0 failed, 0 skipped on Linux (2026-10-02), and CNA's Apple campaign records the two-process test (8 of 8) and the ENet suites (81 of 81) passing on one Apple M4 after a macOS discovery fix. These tests are present and were not executed for this page.
What that establishes is SystemLink delivery between processes on the kinds of host the tests run on (Linux, and one Apple M4 in CNA's Apple campaign), and the online lifecycle over loopback and Linux network namespaces. It does not establish discovery between separate machines, behaviour on Windows (the process-spawning tests are compiled out there, and on Emscripten, Android and iOS, so they do not exist rather than being skipped), anything in a browser, play across the public Internet, voice through real devices, or compatibility between peers built from different revisions. Treat multi-machine migration as best effort.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- Tutorial 97: networking and multiplayer basics · XNA compatibility: Audio, Net and Storage
- Tests and validation
- Test architecture and change recipes
- Reference
- Public headers: net
- Deep dives
- GamerServices behaviour contract · Avatars