Network session internals

CNA snapshot c1c316b9  ·  Development › Module internals  ·  source links pinned to c1c316b9

✓

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, the two harnesses, the CMake gates and CNA's own Gamer Services documents at the snapshot; the 33 test files (530 test-macro definitions at c1c316b9) were located, none executed. CNA records its own Linux qualification of the module (524 passed, 2026-10-02) and of the companion server's session, relay and migration CTests on loopback and network namespaces, with its NAT-isolation gates skipped, and its Apple campaign records the module's networking cases passing on one Apple M4 (304 in the Metal Debug tree) after a SystemLink discovery fix and an online host-migration fix; none of that was repeated here. Host migration and relayed sessions across real wide-area networks, simulated-loss scheduling, voice through real devices, Emscripten behaviour and Windows socket behaviour remain unverified.

modules/net/ contains the XNA-shaped session, gamer, packet and discovery APIs plus CNA's ENet implementation of them. NetworkSession is the public state machine and ENet is the session protocol, carried two ways: SystemLink sessions use direct ENet over UDP on the local network and work without any service, while PlayerMatch and Ranked sessions exist only when a CNA account service is configured (see the GamerServices page): the service's session directory creates, finds and joins them, and every datagram travels through the service's authenticated WebSocket relay, with no peer-to-peer path and no NAT traversal. Local and LocalWithLeaderboards stay in-process. This page is for maintainers who need to know how a caller-owned session, its gamer objects, the ENet host and peer maps, the relay binding, the discovery advertisement and the queued events stay mutually valid from Create to Dispose, and where the pump, the wire messages and the tests sit. The user view is Tutorial 97 and the Net row of XNA compatibility; CNA's own record of the online design and its limits is docs/gamer-services-server.md.

Build and type policy

modules/CMakeLists.txt enters gamer-services and net only under CNA_ENABLE_NET, which defaults to ON in the root file and is switched off by several presets (see the GamerServices page). modules/net/CMakeLists.txt builds a STATIC library CNA_Net (alias CNA::Net, 39 sources and 30 public headers at this snapshot) and links CNA_GamerServices and the target enet publicly, plus the Sharp Runtime components Core.Base, IO, Collections.Core, Runtime and Threading. Two private edges serve online play: libcurl 7.86 or newer for the secure WebSocket relay (defining CNA_SERVICE_TLS_TRANSPORT; required on desktop, optional on Android, never linked under Emscripten), and libopus 1.3 or newer for voice, controlled by the cache variable CNA_ENABLE_VOICE (AUTO by default, ON fails the configure without libopus, OFF; the pkg-config probe is not asked in any cross-compiled configuration (CMAKE_CROSSCOMPILING, through cmake/HostPkgConfig.cmake), so a cross build carries no voice, ON fails there, and NetworkGamer.HasVoice stays false). ENet is vendored in the CNA tree (third_party/enet) and added by cmake/ThirdPartyENet.cmake with EXCLUDE_FROM_ALL; the root file notes that sanitiser instrumentation stays outside the vendored SDL and ENet sub-builds. Neither library is in the CNA umbrella; a game links CNA_GamerServices and CNA_Net itself, and every session is created for SignedInGamer objects that come from the GamerServices layer. In a CNA_ENABLE_NET=OFF build cmake/UnitTests.cmake removes the Net and GamerServices test sources from the aggregate CnaTests, so a green NET=OFF run says nothing about the transport.

The policy lives in two places. ENetBackend::RealNetworkingEnabled returns true only for SystemLink, which is the only type that hosts a direct ENet socket. The online types take a second route: NetworkSession::ServiceLocalGamers runs first for every online Create, Find and Join, and throws GamerServicesNotAvailableException (“Configure and initialize CNA Gamer Services before online sessions.”) unless the dispatcher is initialised with a configured service, and again (“A local gamer is not authorized for CNA online sessions.”) for a local profile, a guest or an account without the online-session privilege. JoinInvited needs an invitation the player accepted through the Guide, which only a service delivers. An API name is therefore evidence of an online path only in a deployment that configured a service.

Session typeCreateFindTransport
SystemLinkAccepted with signed-in local profiles or accounts; starts an ENet host and registers it for discoveryBroadcast plus loopback UDP query with measured round trip and bandwidth (native); empty on EmscriptenDirect ENet over UDP on the LAN
LocalAccepted; no socketThrows ArgumentExceptionNone. PacketSend is a no-op in Update, so packets sent through a Local session are not delivered, not even to its own gamers
LocalWithLeaderboardsAccepted, in-process like Local; accounts' leaderboard rows are committed to the service at EndGameReturns an empty collectionNone
PlayerMatch, RankedWith a service: queued to the session directory, accounts only; without one: GamerServicesNotAvailableExceptionService directory pages (at most 256 results); the same refusal without a serviceENet datagrams, game and voice, through the authenticated WSS relay /cna/relay/v1; no direct path. Ranked admits no join in progress
caller owns NetworkSession* returned by Create / Join / JoinInvited, and every Begin* result
  -> session owns the LocalNetworkGamer objects it creates          [ownedGamers_, unique_ptr registry]
  -> ENetBackend::SessionState owns every SystemLink REMOTE NetworkGamer [OwnedRemoteGamers; AddRemoteGamer never takes ownership]
  -> OnlineSessionBinding (online_) owns the remote gamers of an online session and its realtime engine
       -> ServiceENetSession: directory lease, relay-carried ENet, handshake, roster   [owner-thread work]
       -> RelayTransport: one worker thread bridging loopback UDP routes to the WSS relay
  -> the four public GamerCollection views borrow all of them
  -> ENetBackend Sessions() map: per-session ENetHostHandle, peers, wire-id maps, queues
  -> ENetHostHandle owns the ENetHost; ENetDiscoveryService holds a process-static UDP socket
       (+ an upstream bandwidth responder thread while hosting)

one live session per process:  static activeAction_ / activeSession_; every Begin* refuses if either is set

NetworkSession::Update   [caller thread; nothing else raises a session event]
  SystemLink -> ENetBackend::PumpSession -> service(0): connect / receive / disconnect -> handshake, app-data
                  -> direct-peer round-trip times -> release delayed deliveries -> publish property changes
             -> ENetDiscoveryService::Poll (answer queries)
  online     -> online_->pump(): engine observations -> queued gamer, host, state and packet events
  both       -> voice (when built with Opus) -> drain the NetworkEvent queue -> delivery / public events

Two invariants shape everything below. First, one session per process: the action and session pointers are class statics, and every BeginCreate, BeginFind, BeginJoin and BeginJoinInvited throws InvalidOperationException while either is non-null. A game cannot browse for sessions while it is hosting one, and tests that need two peers use two processes. Second, one owner thread for everything public: the header of ENetBackend.hpp states that the backend and its process-wide session map are single-threaded by contract, and every public event is raised on the thread that calls NetworkSession::Update. The module is no longer free of threads, though: the relay transport runs one worker per online session, a hosting SystemLink session runs a small responder thread that times bandwidth probes, and the service backend's queue runs on the GamerServices worker. None of them touches an XNA object or raises a session event: the relay worker and the service queue hand their results to the owner thread through locked, bounded state, and the bandwidth responder only answers probe datagrams on its own socket. See the thread and callback map for the process-wide picture.

Construction, handshake and caller ownership

NetworkSession::Create and Join return raw pointers documented as caller-owned. Every overload is a Begin*/End* pair: the synchronous wrapper holds the Begin* result in a std::unique_ptr, because results are caller-owned, and calls End*. BeginCreate validates its arguments (maxLocalGamers 1–4 (the explicit-gamer-list overload has no such argument and uses XNA's local limit of four), maxGamers 2–MaxSupportedGamers, which is 31, and privateGamerSlots 0–maxGamers), refuses a second live action or session, and allocates a NetworkSessionAction stored in activeAction_. What happens next depends on the type. For Local, LocalWithLeaderboards and SystemLink the action is marked complete at construction and its callback is invoked once, right after activeAction_ is installed; InvokeActiveActionCallback keeps ownership until Begin* returns, so a callback that calls the matching End* or throws cannot free the object Begin* is about to return. For PlayerMatch and Ranked, QueueOnlineSession starts an OnlineSessionOperation on the GamerServices backend and returns a pending result whose completion, and callback, arrive at a later GamerServicesDispatcher::Update. Every End* goes through NetworkSessionAction::PrepareEnd, which rejects a foreign or already-ended result, then pumps the dispatcher until the action completes or a 30-second deadline passes (TimeoutException). The offline types still complete without that pump, which is why the out-of-process hang tests in the test section pass for them. EndCreate builds a local or SystemLink session inside a try (leaving activeAction_ null even when the constructor throws) and returns activeSession_; for an online action CompleteOnlineSession takes the established realtime engine, checks that every local gamer is still signed in, builds the session from the directory snapshot, attaches an OnlineSessionBinding and runs one Update so events queued before the caller could subscribe are delivered.

The constructor gathers the local gamers. With an explicit std::vector<SignedInGamer*> it uses those. Without one it walks Gamer::getSignedInGamersProperty() up to maxLocalGamers and takes each non-guest gamer, that is the signed-in local profiles and accounts; an explicit list that names a gamer twice joins that gamer once. If the resulting list is empty (no profile or account signed in yet, which is the offline default until the player signs in through the Guide) host_ = localGamers_[0] throws from inside EndCreate; the cleanup path keeps the process usable (test FailedCreateDoesNotPermanentlyStrandActiveAction). Each gamer becomes a heap LocalNetworkGamer, is stored in ownedGamers_ and added by pointer to the public local and all-gamer collections. It receives a provisional id from a monotonic counter (never derived from a live collection's size, so remove-then-add cannot collide), an IsHost flag from the factory that built the session, and one shared NetworkMachine for all gamers of this machine; the first local gamer becomes the placeholder Host. A host starts in Lobby with AllowHostMigration and AllowJoinInProgress false. GamerJoined gets a replay hook: whenever a handler subscribes, it is invoked immediately for every gamer already in AllGamers, which is why a late subscriber is caught up (GamerJoinedReplaysImmediatelyOnSubscriptionForConstructionTimeGamers, GamerJoinedReplaysForALateSubscriber); GameStarted has the same kind of hook for a handler added while the session is Playing. Later joins are different: AddRemoteGamer and AddLocalGamer enqueue a GamerJoin event delivered from Update. Reading the two paths together, a handler subscribed after a remote gamer has been added but before the next Update would receive that gamer once from the replay and once from the queued event; no test covers that window, so treat it as a caution rather than a proven behaviour. No extra Update call is needed to see the construction-time gamers.

For a SystemLink session the constructor ends by calling ENetBackend::StartHosting. That creates an RAII ENetHostHandle (31 peers, 2 channels: control and application data on channel 0, voice on channel 1; an OS-assigned ephemeral port on native hosts, a fixed port 61191 on Emscripten), reads back the bound port and registers the session with discovery before committing the state to the process map. If UDP registration throws, the half-created host unwinds through the handle's destructor and the map never learns of it (StartHostingRollsBackCleanlyOnDiscoveryRegistrationFailure). ENetLibrary::EnsureInitialized guards enet_initialize with a function-local static; no enet_deinitialize call exists. Note that every SystemLink session starts hosting in its constructor, including one created by Join; a joining session therefore owns an accepting ENet host and is registered for discovery as well, which the REMED-NET-001 comment in the backend acknowledges (“even a client role peer owns a real accepting ENetHost here”). An online session never calls StartHosting: its ENet traffic belongs to the realtime engine that OnlineSessionBinding owns, bound to loopback routes that RelayTransport forwards over the authenticated relay.

Join

BeginJoin refuses a listing whose search collection was disposed, then branches on the listing's type. An online listing carries the directory snapshot and the searching gamers, and is joined through QueueOnlineSession like an online Create. A SystemLink listing hands its connect address and port to EndJoin, which constructs the joining session with isHost = false and provisional limits, then calls ENetBackend::ConnectToHost (start hosting if needed, then enet_host_connect and remember the peer as HostPeer); the real MaxGamers, private slots and join-in-progress setting arrive with the host's welcome. On native builds it then pumps Update on the same thread, sleeping one millisecond per loop, until the host pointer stops being the construction-time placeholder (the server welcome arrived), the session state becomes Ended, or five seconds pass. Expiry disposes and deletes the half-joined session and throws NetworkSessionJoinException with SessionNotFound; any other exception during that phase is wrapped with its cause in the same type. On Emscripten that wait loop is compiled out, so Join returns before the handshake and the caller must pump Update itself; that path was not traced against a browser. BeginJoinInvited validates maxLocalGamers and the single-session rule, then requires a configured service, an accepted invitation and the accepting gamer still signed in (that gamer must join), and queues the online join; EndJoinInvited completes it, and a consumed invitation cannot be joined again. NetworkSession::InviteAccepted is raised when a player accepts an invitation (or joins a friend's game) in the Guide; a handler added later is told once, while the invitation still exists. A successful Create or Join does not start background session logic: everything game-visible after that depends on the caller invoking NetworkSession::Update.

Handshake and host-only messages

A client sends ClientHello (its local gamertags) when ENet reports the connect to its HostPeer. The host answers with ServerWelcome: the wire ids it assigned to the client's gamers, a snapshot of the current roster (with each gamer's real host flag), the current NetworkSessionProperties, and follows it with a SessionSettingsBroadcast carrying the maximum gamers, private slots and join-in-progress setting, which replace the joiner's provisional values. It then adds the new gamers as remote gamers of its own session, so GamerJoined is raised for them only after the welcome was sent, and announces them to already-connected peers with GamerJoinBroadcast. Wire ids are single bytes assigned by whichever side hosts the connection; ids reclaimed from departed peers are reused before the counter advances, which stops the byte from wrapping after 256 cumulative joins. The client's HandleServerWelcome overwrites the provisional local ids with the assigned ones, creates remote gamers for the roster, and establishes the welcomed host as Host silently (no HostChanged for an initial join). Every gamer the transport creates goes into OwnedRemoteGamers. The receive path applies these rules, each with a test: a host in Playing with join-in-progress disallowed disconnects a new ClientHello peer; a duplicate ClientHello from an already-handshaked peer disconnects it; the host-authoritative messages (ServerWelcome and the gamer join and leave, state, property, settings, machine-roster and statistics broadcasts; a client also takes readiness only from its host) are accepted only from the peer this side connected out to, and otherwise the sender is logged and disconnected (HostRejectsForgedServerWelcomeFromNonHostPeer and its siblings); and a payload that fails to decode is dropped inside a try block so a single truncated packet cannot throw out of Update (HostSurvivesTruncatedClientHelloAndContinuesFunctioningAfterward). Later message tags carry lobby readiness, AddLocalGamer requests from a client, network statistics and voice frames. The full field layout of each message and compatibility between builds are not established by this page.

One Update and one application packet

NetworkSession::Update throws ObjectDisposedException on a disposed session (and InvalidOperationException when called from inside a final leaderboard-write handler). For SystemLink it calls ENetBackend::PumpSession and then ENetDiscoveryService::Poll. PumpSession services ENet with a zero timeout in a loop, dispatching connect, receive and disconnect; a received packet is wrapped in a guard that destroys it after handling. It then copies ENet's round-trip time into NetworkGamer::RoundtripTime for the gamers of directly connected peers (the host's view of its clients only; every other gamer stays at zero), releases any delayed deliveries whose release time has passed, and, on the transport host, compares the session properties with the last published snapshot and broadcasts a change (the get-only XNA property is a mutable collection, so polling is the only way to notice edits). For an online session it calls online_->pump(), which converts the realtime engine's observations (joins, departures, data, voice, readiness, host change, failure) into the same queued events. Voice is updated next when the build carries Opus. The session then drains its NetworkEvent queue: a PacketSend whose target is a local gamer is enqueued into that gamer's packet queue with the sender remapped; a remote target goes to ENetBackend::SendAppData, or to the online binding's send; gamer joins and leaves, host changes and state changes raise the public events on the thread that called Update. The transition to Playing opens the leaderboard write scope of every signed-in account in an online session, or in a LocalWithLeaderboards session when a service is configured, and the transition back to Lobby (or a session lost while playing) commits those drafts to the service in one transaction; WriteUnarbitratedLeaderboard, WriteArbitratedLeaderboard and WriteTrueSkill are raised only by that service flush, so offline and SystemLink sessions never raise them. Neither the queue nor the backend's process map is safe for concurrent use, so treat Create, Update, Dispose and gameplay sends as a single-owner execution model; the relay worker of an online session never touches either.

A packet leaves in two steps. LocalNetworkGamer::SendData does not touch the transport: every overload only appends a PacketSend event to the session's queue (the broadcast overloads add one event per gamer in AllGamers, which includes the sender and any other local gamers; the recipient overloads add one). The bytes reach ENet during the next Update. In that loop the transport branch is gated on RealNetworkingEnabled or an online binding, which is why LocalNetworkGamerTest.SendDataThenReceiveDataRoundtrip, despite its name, asserts that no data becomes available on a Local session. ReceiveData dequeues one packet per call and reports the sender by pointer identity against the session's gamers, falling back to the previous gamers for a sender that has already left. The byte-array overload checks the offset first and throws ArgumentException for a packet that does not fit, without taking it off the queue; the overload that takes a PacketReader resizes the reader to the packet and returns its size, as XNA does (it returns 0 and empties the reader only when no packet is queued, the case ReceiveDataIntoPacketReaderReturnsZero pins).

Before a client handshake has assigned wire ids, or before a target gamer is known, SendAppData cannot address the packet. The backend queues such sends in a bounded per-session vector (64 entries; when full it evicts the oldest) and delivers them, in order for each sender and target pair, when HandleClientHello, HandleServerWelcome or HandleGamerJoinBroadcast makes both ends resolvable. Every entry that turns out never to be deliverable increments a process-wide counter exposed as GetDroppedAppDataCount: overflow eviction, purge when a named gamer leaves or disconnects, and the whole queue being cleared by host migration or by the client's own session ending. Because SendData itself only queues, this path is reached when a send is drained before its ends are wired (for example after ConnectToHost without waiting, on Emscripten where EndJoin does not wait, or for a gamer introduced later), not after a native Join that already waited for the welcome. Preserve this state transition when changing Join or SendData.

Delivery options map onto ENet flags in NetPacketCodec::SendDataOptionsToEnetFlags: None is unsequenced, InOrder is a plain sequenced unreliable send, and Reliable, ReliableInOrder and Chat are all reliable (ENet has no reliable-unordered primitive; SendDataOptions is a plain enumeration, so XNA's flag combinations are not expressible). The host is created with two channels; every game and control send in ENetBackend.cpp uses channel 0, and voice frames travel unreliably on channel 1 so they never wait behind reliable traffic. The topology is a star: a client sends everything to the host, which relays to the peer that owns the target's wire id and never echoes a packet back to its origin. NetPacketCodec and NetDiscoveryProtocol are two different wire contracts (session and application messages on a connected channel, versus a LAN query and announcement on a raw UDP socket, protocol version 1); a codec change needs tests with malformed and truncated bytes and a real two-process exchange, not only an in-process send.

Host migration and simulated conditions

The source contains both features; this page records only where they sit. AllowHostMigration is off by default. The SystemLink mechanism follows; in an online session the service elects the machine with the lowest remaining gamer id instead; CNA's operator document records that a departed host is noticed at once, a crashed one after the relay's grace period, and that clients without a successor end the session with HostEndedSession; until the new host has admitted a client, ServiceENetSession holds that client's application data for it in order (at most 128 messages and MaxRelayWaitingBytes, 4 MiB, else LIMIT_EXCEEDED) and sends it on the host's welcome. When a client loses its connection to the host and the flag is set, AttemptHostMigration lets every survivor compute the same successor, the lowest remaining wire id, from its cached roster. The successor promotes itself (marks its local gamers as host, re-registers for discovery, establishes itself as host with HostChanged); the others discard their roster, reset the wire-id maps and pending sends, rediscover the new host through ENetDiscoveryService::FindSessions (up to three attempts, matching by gamertag) and reconnect. There is no wire message for it (tag 0x05 is reserved and unused), it is best effort on the LAN, and it returns false, ending the session, when disabled or when no reachable host is found. SimulatedLatency and SimulatedPacketLoss are applied in HandleAppData to application data addressed to one of this session's own local gamers only: session-management traffic and a host's relay hop for two other peers are untouched; a loss of exactly 0 or 1 never consults the random generator, and delayed packets wait in a per-session queue until PumpSession observes the release time. Tests exist for all of it in ENetBackendTests.cpp (the tie-break cases run in one process; HostMigrationPromotesOneSurvivorAndTheOtherReconnectsAcrossRealProcesses uses three). Online migration is exercised by the companion server's service_cna_session_migration and host-crash CTests in that repository. Real multi-machine migration behaviour, the statistical behaviour of loss, and every Emscripten path remain unverified by this page.

Discovery is a synchronous, host-specific service

This section concerns SystemLink only; online sessions are found through the service directory. ENetDiscoveryService holds file-static state: one UDP socket, a single registeredHost_ pointer with its connect port, and a temporary result vector that exists only while FindSessions runs. On native builds the socket is bound to port 61190 with SO_REUSEADDR, broadcast and non-blocking options, so several processes on one machine can each bind it (on Apple platforms the socket also sets SO_REUSEPORT, which Darwin requires for that, and FindSessions sends from a temporary socket of its own to the global broadcast address, to every up interface's broadcast address and to loopback while it keeps draining the shared socket; the Linux path is unchanged); which of them receives a given datagram is up to the operating system, and that is why results are de-duplicated by connect port. FindSessions returns an empty list at once for any type but SystemLink; otherwise it sends the query by broadcast and by loopback, then polls on the calling thread for a 150 ms window, so a single NetworkSession::Find blocks for roughly that long (the work happens in EndFind). Each reply becomes an AvailableNetworkSession whose quality of service carries the measured delay between the query and that reply and both bandwidth directions, measured with short probe trains: a hosting session runs an UpstreamResponder with its own socket and thread, so a querier's train is timed as it arrives rather than at the host's next frame. ReplyToQuery answers only when the querying filter equals the registered host's session type, and announces the host gamertag (the signed-in profile's name for a local host gamer), the connect port, the session properties and slot counts; open private slots are reported from the host's private-slot setting, because nothing tracks private occupancy. NetworkSession::Update calls Poll so a hosting session keeps answering.

Registration and unregistration are explicit, and an unregistered host must not stay advertised. Because the state holds one host pointer, a process that hosted more than one session would advertise only the last registered one; the single-session rule keeps that from arising today. A joined SystemLink session hosts and registers as well (see above), so it can answer discovery queries itself. Malformed datagrams are dropped, and a guard object resets the result pointer even when a decode throws, so no dangling result sink survives (MalformedAnnounceDuringSearchIsIgnoredAndDoesNotLeaveADanglingResultsPointer). Emscripten has no raw UDP: the same functions are empty stubs and FindSessions returns nothing, so a browser build compiling proves nothing about LAN discovery. Windows behaviour of the shared-port bind is not established by the Linux two-process tests; the source comments say so themselves.

Teardown order and error boundaries

NetworkSession::Dispose is idempotent and runs in a fixed order. A session disposed while Playing first offers its accounts' final leaderboard writes; ReleaseSessionResources then destroys the voice state (it holds gamer pointers and a capture session), abandons any uncommitted service leaderboard drafts without invoking user callbacks, and clears each local gamer's receive queue; for SystemLink it calls ENetBackend::TeardownSession, which sends a disconnect to every known peer (and to the host peer), flushes so the packets actually leave, erases the per-session state (destroying the ENetHostHandle and freeing OwnedRemoteGamers) and unregisters discovery; an online binding disconnects the relay and leaves the directory membership while its remote gamers still exist; it then destroys ownedGamers_, clears all four public gamer collections, nulls the host and the static active-session pointer and marks the session disposed. The destructor falls back to Dispose when a caller used delete directly; without that, the process-static session pointer would dangle and, because every later Begin* refuses while it is set, one mismanaged delete would block session creation for the rest of the process (DeletingWithoutDisposeStillAllowsCreatingANewSession, DeletingWithoutDisposeTearsDownRealTransport). Reordering gamer destruction before transport teardown would leave the wire-id maps holding freed gamer pointers. The early return and the clearing of the views are both load-bearing: repeating Dispose once exposed a use-after-free, and the views can otherwise keep pointers into just-freed gamers (DisposeCalledTwiceDirectlyIsSafeAndIdempotent, DisposeClearsPreviousGamersSoNoDanglingPointerIsObservable).

Errors surface as typed XNA exceptions at the session layer (argument validation, InvalidOperationException for state, ObjectDisposedException), as NetworkSessionJoinException for a failed join, as std::runtime_error from ENet initialisation, host creation, unresolvable addresses and UDP socket creation, and, for online sessions, as the XNA exception family that throwOnlineEndFailure maps a service or relay failure to (NetworkSessionJoinException for a full, missing or unjoinable session, GamerPrivilegeException, NetworkNotAvailableException, GameUpdateRequiredException, otherwise NetworkException), never as raw network text. ENetHostHandle::Send returns void and silently drops a packet whose allocation or enet_peer_send fails, so not every transport failure can be inferred from the event stream. A departed gamer is moved to PreviousGamers (capped at MaxPreviousGamers, 100, oldest evicted) and the departure raises GamerLeft, or a session-wide SessionEnded when the departing gamer is local. A human debugging “packet never arrives” should check, in order: the type is SystemLink or an online type with a configured service; Update runs on both processes; the handshake finished and wire ids exist; the pre-handshake queue and drop counter; codec decode; local-versus-remote target routing; the receive queue. For an online session add the relay transport's status and its dropped and refused counters. No receiving thread raises events, so the owner's Update is always the place to start.

Change-driven evidence and source route

CnaNetTests is the focused target when networking is enabled. It is an EXCLUDE_FROM_ALL executable built from the module's 33 test files (529 test-macro definitions by a line-anchored count; not executed for this page) and linked against CNA_Net; the same files feed CnaTests. cmake --preset unit configures with networking off and therefore has neither. The suites:

  • NetworkSessionTests (69 macros): construction, state, factories, events, replay hooks, dispose and delete-without-dispose, disposal idempotency, no action leak, and callback re-entrancy. NetworkSessionTypePolicyTests (11): the in-process types never bind a port and the online types are refused without a service. LocalNetworkGamerTests, NetworkGamerMachineTests, AvailableNetworkSessionTests, NetworkSessionPropertiesTests, PacketReaderWriterTests and the enum, event-argument and exception suites cover the value types.
  • ENetBackendTests.cpp (60): handshake, delivery and relay, the pre-handshake queue and every drop case, gamer leave and session end, migration bookkeeping, state and property broadcasts, forged-message rejection, wire-id reuse, prompt disconnect on dispose, ownership of remote gamers, measured round-trip time, the latency and loss cases, voice and machine rosters. It shares one session per process, so it uses a raw ENet host to stand in for the second machine.
  • ENetDiscoveryServiceTests (13), ENetHostHandleTests (10), ENetLibraryTests (2), NetPacketCodecTests (31), NetDiscoveryProtocolTests (14) and TrafficRateTests (2) cover discovery, the RAII handle, initialisation, both wire formats and the traffic counters.
  • The online path is tested against fakes, without a server: OnlineNetworkSessionTest (19), OnlineSessionPreparationTest (18), OnlineInvitationTest (7), OnlineLeaderboardTest (8), ServiceENetSessionTest (30), ServiceGamePacketPolicyTest (18), ServiceSessionPumpTest (13), RelayEnetPolicyTests (5), NetworkSessionServiceSearchTests (13), ServiceRosterTests (7), RelayProtocolTests (3), RelayMessageAssemblerTests (8), RelayTransportTests (5, including the libcurl capability rule on synthetic version data) and BrowserServiceTransportTests (5). End-to-end service sessions, invitations, relay, host migration and host-crash cases are CTests of the separate cna-gamer-services-server repository, run there against a real server process.
  • TwoProcessLoopbackTest.cpp (8): genuine separate OS processes running net_two_process_harness.cpp as host, client, migration host and migration survivor, plus voice in both directions (skipped without libopus), AddLocalGamer on a client, guests, the local-gamer limit and gamers a Find was given, and the discovery-failure rollback. GamerServicesDispatcherHangRegressionTest (4) spawns the dispatcher harness described on the GamerServices page.

The process-spawning tests use POSIX posix_spawn, so UnitTests.cmake filters their sources out of CnaTests on Windows, Emscripten, Android and iOS, and Harnesses.cmake does not build the two-process harness on the first three. On those hosts the cases do not exist; they are not skipped at run time and must not be counted as passing.

Recipes. For a lifetime change, repeat Create and Dispose under AddressSanitizer in addition to the transport tests. For a wire change, test peers built from the same revision and, if compatibility is claimed, the previous one, with malformed and truncated bytes. For a discovery change, test real multi-process bind, announce and find, and the firewall and socket behaviour of the native host. For an online change, extend the fake-backed Online* and Service* suites first and then run the companion server's session and relay CTests against the same revision; do not count a namespace or loopback run as Internet evidence. For a pre-handshake or migration change, extend the existing drop-counter cases rather than adding timing-dependent ones; the tests inject a clock and a seeded generator through SetClockForTesting and SeedPacketLossRngForTesting.

Source-reading route

  1. NetworkSession.cpp: read the constructor, Dispose, Update, Create/EndCreate, QueueOnlineSession/CompleteOnlineSession and Join/EndJoin first, and draw the raw and owned pointers and the event queue.
  2. ENetBackend.cpp with ENetHostHandle.cpp and ENetLibrary.cpp: trace the session map, handshake, packet queue, peer destruction and RAII native ownership.
  3. ENetDiscoveryService.cpp: map the process-static socket, host and result state, the upstream responder and the host-specific branches before editing Find.
  4. OnlineSessionBinding.cpp, ServiceENetSession.cpp and RelayTransport.cpp: how a directory lease and the relay become the same ENet session, and where worker observations cross to the owner thread.
  5. LocalNetworkGamer.cpp, NetPacketCodec.cpp and NetDiscoveryProtocol.cpp: the send and receive queues and the two wire contracts.
  6. ENetBackendTests.cpp and TwoProcessLoopbackTest.cpp: separate in-process state-machine evidence from genuine OS-process transport evidence.

What this page does not establish

This tour reconstructs ownership, the pump, discovery, join and teardown. It does not fully reconstruct host migration under real network conditions, the scheduling behaviour of simulated loss and latency, every field of every packet, compatibility between protocol versions, the relay protocol in detail, or the Emscripten paths (where CNA refuses the service, the relay and SystemLink discovery and compiles voice out); those need separate source and test audits. CNA's own records add that online sessions were qualified on one Linux host over loopback and network namespaces and, in the Apple campaign, on one Apple M4 over loopback with a WebSocket-capable libcurl (a build whose libcurl lacks ws/wss refuses the relay with RELAY_SECURE_WEBSOCKET_UNAVAILABLE), that relayed traffic carries TCP head-of-line latency, that online search results report no measured quality of service, and that a public Internet deployment of the server is not independently qualified. Nothing here was executed. The module list is in the module index, ownership across the tree in the ownership map, and the Maintainer Handbook has the change routes. Evidence level: everything above was checked by reading the TARGET sources at c1c316b9.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.