Summary
The DistrictServer has no disconnect/timeout path. Clients are never timed out and per-account state is never pruned, so a long-running instance accumulates one entry per account id ever seen, forever. Three containers self-overwrite on new traffic but never shrink:
g_accounts — DistrictServer.cpp:58 (std::vector<Account*>, never erased)
g_transportStates — DistrictServer.cpp:74 (std::map<uint32_t, ClientTransportState> keyed by account id, written at L2018/2033, never erased)
g_selectedSpawnLocations — DistrictServer.cpp:5294 (std::map<uint32_t, SelectedSpawnLocation> keyed by account id, written at L12476, never erased)
This is the same bug class as the two second-player breakers already fixed locally (not yet merged):
- Channel sequences —
AllocateChannelSequence was keyed by channel index only, sharing one counter across concurrent players; the second client's actor channel opens carried the first client's sequence numbers and were dropped as duplicates. Fixed by keying (endpoint addr, port, channel).
possessed ASK gate — a process-global std::set<uint32_t> keyed by account id, never cleared; a reconnect of the same account had its GC2DS_ASK_DISTRICT_ENTER received but never answered (insert returned false), parking the client at "Entering district" forever. Fixed by keying (endpoint addr, port).
Both were "process-global state keyed by account id and never cleared." The containers below are the same shape, minus the functional breakage — today they only leak memory, but they are the obvious next place the pattern bites (e.g. a stale spawn selection applied to a brand-new session, or transport state that outlives the connection it describes).
Evidence
- No client timeout/removal code exists anywhere in
DistrictServer.cpp. Keepalives are server→client only (APB_KEEPALIVE_ACK_SECONDS); nothing tracks a client that stopped sending.
g_transportStates[account->GetId()] is written on every packet (RecordClientTransportPacket, L2018) and read for keepalive pacing (L2123) — the map itself is never erased.
g_selectedSpawnLocations[account->GetId()] is written on spawn-zone selection (L12476) and read by SendPawnAndPossess (L5327) — never erased, so a relaunched account inherits the previous session's spawn point.
g_accounts grows via AddOrUpdateAccount (L598 push_back) and is only ever looked up, never removed.
- The reconnect hook that does exist only clears a subset: phase-2/3/4 handoffs call
ResetGriStartupState + ResetPawnAckGatedSequenceState (L837/973/1027/1066), and JOIN erases g_controllerFeedbackStates (L13105). g_transportStates/g_selectedSpawnLocations/g_accounts are not covered.
Impact
Low today (bounded by distinct account ids; per-entry size is small — a few dozen bytes), but unbounded over an instance's lifetime. The real risk is semantic: stale state (spawn selection, transport ticks) describing a connection that no longer exists, applied to a future session of the same account id.
Suggested fix directions
- Last-recv-tick timeout loop —
ClientTransportState already stores LastReceiveTick (L79). Add a periodic sweep (or reuse the existing keepalive tick) that erases accounts whose LastReceiveTick is older than a configurable timeout (e.g. APB_CLIENT_TIMEOUT_SECONDS), calling the existing ResetGriStartupState/ResetPawnAckGatedSequenceState and erasing g_transportStates/g_accounts/g_selectedSpawnLocations entries for that account.
- Clear spawn selection on fresh AUTH —
ProcessAuthPacket (L13290) binds the endpoint and sets Authenticated; a fresh AUTH from a new endpoint for an existing account should clear g_selectedSpawnLocations[id] (or carry a per-connection flag) so a new session starts at the configured/default spawn rather than inheriting the old one.
- Optional: key
g_transportStates by endpoint (like the sequence fix) instead of account id, so reconnect naturally gets a fresh entry and the old one is dropped by the timeout sweep.
Repro
Run the DS for a few sessions, reconnect the same account from a new socket (relaunch the client), and observe that g_accounts/g_transportStates/g_selectedSpawnLocations retain entries for the old connection — there is no code path that removes them.
Summary
The DistrictServer has no disconnect/timeout path. Clients are never timed out and per-account state is never pruned, so a long-running instance accumulates one entry per account id ever seen, forever. Three containers self-overwrite on new traffic but never shrink:
g_accounts—DistrictServer.cpp:58(std::vector<Account*>, never erased)g_transportStates—DistrictServer.cpp:74(std::map<uint32_t, ClientTransportState>keyed by account id, written at L2018/2033, never erased)g_selectedSpawnLocations—DistrictServer.cpp:5294(std::map<uint32_t, SelectedSpawnLocation>keyed by account id, written at L12476, never erased)This is the same bug class as the two second-player breakers already fixed locally (not yet merged):
AllocateChannelSequencewas keyed by channel index only, sharing one counter across concurrent players; the second client's actor channel opens carried the first client's sequence numbers and were dropped as duplicates. Fixed by keying(endpoint addr, port, channel).possessedASK gate — a process-globalstd::set<uint32_t>keyed by account id, never cleared; a reconnect of the same account had itsGC2DS_ASK_DISTRICT_ENTERreceived but never answered (insert returned false), parking the client at "Entering district" forever. Fixed by keying(endpoint addr, port).Both were "process-global state keyed by account id and never cleared." The containers below are the same shape, minus the functional breakage — today they only leak memory, but they are the obvious next place the pattern bites (e.g. a stale spawn selection applied to a brand-new session, or transport state that outlives the connection it describes).
Evidence
DistrictServer.cpp. Keepalives are server→client only (APB_KEEPALIVE_ACK_SECONDS); nothing tracks a client that stopped sending.g_transportStates[account->GetId()]is written on every packet (RecordClientTransportPacket, L2018) and read for keepalive pacing (L2123) — the map itself is never erased.g_selectedSpawnLocations[account->GetId()]is written on spawn-zone selection (L12476) and read bySendPawnAndPossess(L5327) — never erased, so a relaunched account inherits the previous session's spawn point.g_accountsgrows viaAddOrUpdateAccount(L598push_back) and is only ever looked up, never removed.ResetGriStartupState+ResetPawnAckGatedSequenceState(L837/973/1027/1066), and JOIN erasesg_controllerFeedbackStates(L13105).g_transportStates/g_selectedSpawnLocations/g_accountsare not covered.Impact
Low today (bounded by distinct account ids; per-entry size is small — a few dozen bytes), but unbounded over an instance's lifetime. The real risk is semantic: stale state (spawn selection, transport ticks) describing a connection that no longer exists, applied to a future session of the same account id.
Suggested fix directions
ClientTransportStatealready storesLastReceiveTick(L79). Add a periodic sweep (or reuse the existing keepalive tick) that erases accounts whoseLastReceiveTickis older than a configurable timeout (e.g.APB_CLIENT_TIMEOUT_SECONDS), calling the existingResetGriStartupState/ResetPawnAckGatedSequenceStateand erasingg_transportStates/g_accounts/g_selectedSpawnLocationsentries for that account.ProcessAuthPacket(L13290) binds the endpoint and setsAuthenticated; a fresh AUTH from a new endpoint for an existing account should clearg_selectedSpawnLocations[id](or carry a per-connection flag) so a new session starts at the configured/default spawn rather than inheriting the old one.g_transportStatesby endpoint (like the sequence fix) instead of account id, so reconnect naturally gets a fresh entry and the old one is dropped by the timeout sweep.Repro
Run the DS for a few sessions, reconnect the same account from a new socket (relaunch the client), and observe that
g_accounts/g_transportStates/g_selectedSpawnLocationsretain entries for the old connection — there is no code path that removes them.