Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 64 additions & 4 deletions Source/Client/Networking/NetworkingSteam.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Multiplayer.Common;
using Multiplayer.Common.Networking.Packet;
using Steamworks;
Expand All @@ -17,6 +18,9 @@ public abstract class SteamBaseConn(CSteamID remoteId, ushort recvChannel, ushor
public readonly ushort recvChannel = recvChannel; // currently only for client
public readonly ushort sendChannel = sendChannel; // currently only for server

// Time given to Steam to flush a queued goodbye packet before the P2P session is freed (#843).
private static readonly TimeSpan SteamGoodbyeFlushDelay = TimeSpan.FromSeconds(3);

protected override void SendRaw(byte[] raw, bool reliable = true)
{
byte[] full = new byte[1 + raw.Length];
Expand All @@ -42,12 +46,48 @@ public void SendRawSteam(byte[] raw, bool reliable)

public abstract void OnError(EP2PSessionError error);

// A goodbye is only ever non-null server-side, and CloseP2PSessionWithUser discards queued unsent
// packets. Closing immediately would drop the just-queued goodbye (e.g. a wrong-password or kick
// reason) before Steam flushes it, so defer the close to let the reliable packet reach the client.
protected override void OnClose(ServerDisconnectPacket? goodbye)
{
if (goodbye.HasValue) Send(goodbye.Value);
// TODO this should probably include SteamNetworking.CloseP2PSessionWithUser to free up any leftover
// resources in the Steam API. The API docs are not clear whether the connection is closed instantly, or
// are the queued packets sent.
if (!goodbye.HasValue)
{
CloseSteamSession();
return;
}

Send(goodbye.Value);

var server = serverPlayer.Server;
var id = remoteId;

Task.Delay(SteamGoodbyeFlushDelay).ContinueWith(_ => server.Enqueue(() =>
{
// A fast reconnect (SteamP2PNetManager.Tick) reuses this same remoteId on a fresh
// connection during the delay window. Closing then would tear that new session down, so
// skip the close if another connection already replaced this one (#843).
if (server.playerManager.Players.Any(p => p.conn is SteamBaseConn c && c != this && c.remoteId == id))
{
ServerLog.Log($"Skipping delayed Steam P2P session close with {id}; a reconnect replaced it");
return;
}

CloseSteamSession();
}));
}

// Frees the underlying Steam P2P session. This is required so that a later reconnect from
// the same user produces a fresh P2PSessionRequest_t (and, on the host, a new accept prompt)
// instead of Steam silently reusing the still-open session, which left the peer stuck and the
// host without a prompt (#843).
//
// Called immediately when no goodbye is queued; server-initiated disconnects that queue a goodbye
// defer it (see OnClose) so SendP2PPacket can flush the reason before the session is torn down.
protected void CloseSteamSession()
{
ServerLog.Log($"Closing Steam P2P session with {remoteId}");
SteamNetworking.CloseP2PSessionWithUser(remoteId);
}

public override string ToString() => $"SteamP2P ({remoteId}:{username})";
Expand Down Expand Up @@ -108,6 +148,10 @@ public override void OnKeepAliveArrived(bool idMatched)

private void OnDisconnect()
{
// The P2P timeout/error path does not go through OnClose, so close the Steam session here
// too. Otherwise the host keeps a half-open session with the departed client and their
// reconnect reuses it without firing a new accept prompt (#843).
CloseSteamSession();
serverPlayer.Server.playerManager.SetDisconnected(this, MpDisconnectReason.ClientLeft);
}
}
Expand All @@ -132,6 +176,22 @@ public void Tick()
var player = playerManager.Players
.FirstOrDefault(p => p.conn is SteamBaseConn conn && conn.remoteId == packet.remote);

// A join packet from a remote we still consider connected means their previous
// session died and they are reconnecting on a fresh one (e.g. a quick rejoin before
// the old connection timed out). Drop the stale player so the join is accepted below
// instead of being discarded, which would otherwise leave them stuck on "waiting for
// host to accept" (#843).
//
// Use SetDisconnected, not Close/Disconnect: the new join packet just arrived on this
// same Steam P2P session, and Close -> OnClose -> CloseSteamSession would tear that
// shared session down and break the very connection we're about to accept.
if (packet.joinPacket && player != null)
{
ServerLog.Log($"Reconnect from {packet.remote}; replacing stale connection {player.conn}");
playerManager.SetDisconnected(player.conn, MpDisconnectReason.ClientLeft);
player = null;
}

if (packet.joinPacket && player == null)
{
ConnectionBase conn = new SteamServerConn(packet.remote, packet.channel);
Expand Down
42 changes: 28 additions & 14 deletions Source/Client/Networking/SteamIntegration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,37 @@ public static void InitCallbacks()
{
ServerLog.Log($"Received P2P session request from {req.m_steamIDRemote}");
var session = Multiplayer.session;
if (Multiplayer.LocalServer?.settings.steam == true && !session.pendingSteam.Contains(req.m_steamIDRemote))
if (Multiplayer.LocalServer?.settings.steam != true)
return;

var remoteId = req.m_steamIDRemote;

if (Multiplayer.settings.autoAcceptSteam)
{
SteamNetworking.AcceptP2PSessionWithUser(remoteId);
}
// pendingSteam doubles as the dedup set: an entry exists exactly while a prompt is
// unanswered (both accept and reject clear it), so this skips duplicate prompts
// without a stale entry ever blocking a reconnect permanently (#843).
else if (!session.pendingSteam.Contains(remoteId))
{
if (Multiplayer.settings.autoAcceptSteam)
SteamNetworking.AcceptP2PSessionWithUser(req.m_steamIDRemote);
else
session.pendingSteam.Add(remoteId);
PendingPlayerWindow.EnqueueJoinRequest(remoteId, (joinReq, accepted) =>
{
session.pendingSteam.Add(req.m_steamIDRemote);
PendingPlayerWindow.EnqueueJoinRequest(req.m_steamIDRemote, (joinReq, accepted) =>
{
if(joinReq.steamId.HasValue && accepted) AcceptPlayerJoinRequest(joinReq.steamId.Value);
});
}
session.knownUsers.Add(req.m_steamIDRemote);
session.NotifyChat();

SteamFriends.RequestUserInformation(req.m_steamIDRemote, true);
if (!joinReq.steamId.HasValue) return;
if (accepted)
AcceptPlayerJoinRequest(joinReq.steamId.Value);
else
// Clean up so the player isn't blocked from prompting again on reconnect.
session.pendingSteam.Remove(joinReq.steamId.Value);
});
}

if (!session.knownUsers.Contains(remoteId))
session.knownUsers.Add(remoteId);
session.NotifyChat();

SteamFriends.RequestUserInformation(remoteId, true);
});

friendRchpUpdate = Callback<FriendRichPresenceUpdate_t>.Create(update =>
Expand Down