"Unclean host exit poisons the Steam P2P transport between two peers"
▸ SYMPTOM
Two peers who previously played together both get the literal "Connection timed out." on every join path in both directions, while a third peer who never connected to either joins fine. The state survives full game restarts on both sides then self-heals after several minutes.
The fingerprint that separates this from an ordinary bug: the failure is symmetric (neither peer can reach the other), direction-independent, restart-resistant, and self-healing on a minutes-scale timer — pointing at transport-layer state, not game logic.
▸ CAUSE
The C# layer of the engine never closes a per-pair Steam P2P session. The InternalClose method on the Steam lobby connection is empty, and Dispose only flips an internal ChannelState flag. No CloseSessionWithUser call exists anywhere in the C# engine source. Session accept and close live in native code.
Consequence: a host process killed without a graceful disband (crash, task-kill, hard editor stop) leaves poisoned pairwise transport state between the two SteamIDs. Only Steam-side expiry clears it (observed on a minutes scale).
▸ FIX
Because game code cannot force the P2P session closed, the only levers are convergence and messaging:
-
Route every exit path to a graceful disband/kick sweep wherever code still runs. Quit-to-menu tears the game scene down while networking is still active (inside the engine's
DisconnectScope), so a graceful goodbye is possible there. See quit-teardown-networking-still-active. -
On each join attempt, re-query a fresh lobby and avoid the last-failed lobby id, so recovery is automatic once Steam expires the poisoned pair.
-
Surface a player-facing message — "host not reachable right now, try again in a few minutes" — instead of a silent timeout.
▸ WHY IT WORKS
A graceful disband lets the native layer close the P2P session cleanly, preventing the poisoned state from forming. The fresh-query retry sidesteps an already-poisoned pair by allowing the next attempt to establish a new session once Steam has expired the stale one. The player-facing message sets expectations for a class of failure that genuinely does require waiting.
The broader pattern: any transport that maintains pairwise session state needs an explicit close on every exit path — relying on process termination to clean up is exactly the gap this engine layer has.
- Published.