diff --git a/src/SiegeFX.Core/Net/MpSession.cs b/src/SiegeFX.Core/Net/MpSession.cs index 383d8530..79fa9c58 100644 --- a/src/SiegeFX.Core/Net/MpSession.cs +++ b/src/SiegeFX.Core/Net/MpSession.cs @@ -290,7 +290,7 @@ public void SendPvpHit(int target, float damage) public MpSession(ISessionTransport transport, bool isHost, string localName) { _t = transport; IsHost = isHost; _localName = localName ?? "Player"; - _t.PeerDisconnected += OnPeerGone; + _t.PeerDisconnected += QueuePeerGone; if (IsHost) _roster[0] = new MpRosterEntry(0, _localName, true, 0); } @@ -336,6 +336,13 @@ void RosterChanged() Broadcast(BuildRosterFrame()); } + // Transports raise PeerDisconnected from their own threads (the LAN + // keepalive task, EOS callbacks). OnPeerGone fires OnPlayerLeft, whose + // handlers edit the render host's actor list, so it must run on the + // thread that ticks the session — queue it and drain it in Tick. + readonly System.Collections.Concurrent.ConcurrentQueue _gonePeers = new(); + void QueuePeerGone(int peer) => _gonePeers.Enqueue(peer); + void OnPeerGone(int peer) { if (IsHost) @@ -546,6 +553,8 @@ public void SendRecruitGrant(uint scid, int owningPlayer) /// Call once per engine frame with the frame dt. public void Tick(double dt) { + while (_gonePeers.TryDequeue(out int gone)) + OnPeerGone(gone); SecondsSinceHost += dt; // The game clock runs while a game is under way and not paused. if (!Paused && (IsHost ? InProgress is not null : _joined)) GameSeconds += dt; @@ -1226,7 +1235,7 @@ void RelayChat(int fromPlayer, string text) public void Dispose() { - _t.PeerDisconnected -= OnPeerGone; + _t.PeerDisconnected -= QueuePeerGone; } } diff --git a/src/SiegeFX.Runtime/NetSelfTest.cs b/src/SiegeFX.Runtime/NetSelfTest.cs index 9a492d21..6694dc19 100644 --- a/src/SiegeFX.Runtime/NetSelfTest.cs +++ b/src/SiegeFX.Runtime/NetSelfTest.cs @@ -198,6 +198,7 @@ void Assert(bool cond, string what) "in-region: GameStart delivered region + difficulty to the client"); c2.Dispose(); // client drops — host must be told player 1 left + hs.Tick(0); // (on its next tick: transports report leaves off-thread) Assert(hostSawLeave == 1, $"in-region: host fired OnPlayerLeft on disconnect (got {hostSawLeave})"); hs.Dispose(); cs.Dispose(); h2.Dispose(); } @@ -230,6 +231,7 @@ void Assert(bool cond, string what) hs.OnPlayerLeft = id => leftName = hostRoster?.FirstOrDefault(e => e.Player == id).Name; var before = hostRoster; c3.Dispose(); + hs.Tick(0); // leaves are handled on the next tick (transports report them off-thread) Assert(hostRoster is { Count: 1 } && before!.Count == 2 && hs.Roster.Count == 1 && leftName == "Joiner", $"staging: a player who leaves drops out of the host's roster (left: '{leftName}')"); hs.Dispose(); cs.Dispose(); h3.Dispose(); diff --git a/tests/SiegeFX.Tests/MpDisconnectTests.cs b/tests/SiegeFX.Tests/MpDisconnectTests.cs new file mode 100644 index 00000000..92594108 --- /dev/null +++ b/tests/SiegeFX.Tests/MpDisconnectTests.cs @@ -0,0 +1,31 @@ +using SiegeFX.Core.Net; +using Xunit; + +namespace SiegeFX.Tests; + +public class MpDisconnectTests +{ + [Fact] + public void ADisconnectFromATransportThreadIsHandledOnTick() + { + // The LAN keepalive drops a silent peer from a pool thread; the + // render host's OnPlayerLeft edits its actor list, so the session + // must hold the leave until the main thread ticks it. + var (h, c) = LoopbackTransport.CreatePair(); + var host = new MpSession(h, isHost: true, "Host") { BuildSnapshot = () => [] }; + var client = new MpSession(c, isHost: false, "Client"); + client.SendJoinRequest(); + for (int i = 0; i < 4; i++) { host.Tick(0); client.Tick(0); } + + int left = -1, leftOnThread = -1, mainThread = Environment.CurrentManagedThreadId; + host.OnPlayerLeft = p => { left = p; leftOnThread = Environment.CurrentManagedThreadId; }; + + var t = new Thread(c.Dispose); + t.Start(); t.Join(); + Assert.Equal(-1, left); + + host.Tick(0); + Assert.Equal(1, left); + Assert.Equal(mainThread, leftOnThread); + } +}