Refactor PlayerPresenceController and NotifyHub: enhance online presence tracking and improve connection handling logic

This commit is contained in:
Alexa
2026-06-05 22:54:08 +03:00
parent 3c0dd64293
commit 4d58130acd
3 changed files with 43 additions and 18 deletions
@@ -35,7 +35,9 @@ namespace DorkNet.Server.Controllers.Match;
/// null. Players who haven't /goto'd anywhere still report offline. /// null. Players who haven't /goto'd anywhere still report offline.
/// </summary> /// </summary>
[ApiController] [ApiController]
public class PlayerPresenceController(PlayerPresenceService presence) : ControllerBase public class PlayerPresenceController(
PlayerPresenceService presence,
OnlinePresenceService onlinePresence) : ControllerBase
{ {
[HttpGet("/player")] [HttpGet("/player")]
public ActionResult<List<PlayerPresenceDto>> GetPresences() public ActionResult<List<PlayerPresenceDto>> GetPresences()
@@ -81,17 +83,19 @@ public class PlayerPresenceController(PlayerPresenceService presence) : Controll
// privacy concern actually applies. // privacy concern actually applies.
var callerId = this.CurrentPlayerId(); var callerId = this.CurrentPlayerId();
var callerRoom = callerId is long cid ? presence.GetRoom(cid) : null; var callerRoom = callerId is long cid ? presence.GetRoom(cid) : null;
var onlineIds = onlinePresence.OnlinePlayerIds().ToHashSet();
var result = ids.Select(id => var result = ids.Select(id =>
{ {
var room = presence.GetRoom(id); var room = presence.GetRoom(id);
// IsOnline is derived from current room presence: a player // Other players need both a room presence and an active
// who has heartbeated recently has a non-null RoomInstance // notify connection. Presence keys intentionally live for
// in PlayerPresenceService; one who hasn't shows up as // minutes to survive long room loads, so using them alone
// null. Without this, IsOnline defaulted to `true` and the // leaves friends stuck online after a graceful disconnect.
// watch's friends list showed every queried account as // For self, keep using room presence: boot-time bulk probes
// Online regardless of actual session state — verified live // can race the SignalR connection, and returning self as
// (six accounts shown Online despite none being connected). // offline there can desync the heartbeat cache.
var isOnline = room is not null; var isSelf = callerId == id;
var isOnline = room is not null && (isSelf || onlineIds.Contains(id));
return new PlayerPresenceDto return new PlayerPresenceDto
{ {
PlayerId = (int)id, PlayerId = (int)id,
@@ -109,9 +113,9 @@ public class PlayerPresenceController(PlayerPresenceService presence) : Controll
// joins; returning a redacted same-room instance there // joins; returning a redacted same-room instance there
// leaves the 2020 client with an incomplete roomInstance // leaves the 2020 client with an incomplete roomInstance
// and can null-ref while reconciling player presence. // and can null-ref while reconciling player presence.
RoomInstance = ShouldExposeFullRoom(callerId, callerRoom, id, room) RoomInstance = isOnline && ShouldExposeFullRoom(callerId, callerRoom, id, room)
? room ? room
: RoomInstanceDto.Redact(room), : RoomInstanceDto.Redact(isOnline ? room : null),
IsOnline = isOnline, IsOnline = isOnline,
}; };
}).ToList(); }).ToList();
+12 -3
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using DorkNet.Server.Data;
using DorkNet.Server.Services; using DorkNet.Server.Services;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
@@ -40,6 +41,8 @@ namespace DorkNet.Server.Hubs;
public class NotifyHub( public class NotifyHub(
OnlinePresenceService presence, OnlinePresenceService presence,
NotificationService notifications, NotificationService notifications,
DorkNetDbContext db,
ServerSettingsService serverSettings,
ILogger<NotifyHub> logger) : Hub ILogger<NotifyHub> logger) : Hub
{ {
/// <summary>SignalR group name for a given player id. Use this /// <summary>SignalR group name for a given player id. Use this
@@ -111,11 +114,17 @@ public class NotifyHub(
var pid = TryGetPlayerId(); var pid = TryGetPlayerId();
if (pid is long id) if (pid is long id)
{ {
await presence.RemoveConnectionAsync(id, Context.ConnectionId); var wentOffline = await presence.RemoveConnectionAsync(id, Context.ConnectionId);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupForPlayer(id)); await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupForPlayer(id));
if (wentOffline)
{
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, id);
if (friendIds.Count > 0)
await notifications.PlayerWentOffline(friendIds, id);
}
logger.LogInformation( logger.LogInformation(
"[hub] disconnected player={PlayerId} conn={ConnectionId} error={Error}", "[hub] disconnected player={PlayerId} conn={ConnectionId} wentOffline={WentOffline} error={Error}",
id, Context.ConnectionId, exception?.Message); id, Context.ConnectionId, wentOffline, exception?.Message);
} }
else else
{ {
@@ -68,7 +68,10 @@ public class OnlinePresenceService
lock (set) set.Add(connectionId); lock (set) set.Add(connectionId);
} }
public async Task RemoveConnectionAsync(long playerId, string connectionId) /// <summary>Remove one SignalR connection. Returns true when that was
/// the player's final active connection and they should now be treated
/// as offline by friends-list presence.</summary>
public async Task<bool> RemoveConnectionAsync(long playerId, string connectionId)
{ {
if (_redis is { } mux) if (_redis is { } mux)
{ {
@@ -84,16 +87,25 @@ public class OnlinePresenceService
// from the online set so admin queries see "offline". // from the online set so admin queries see "offline".
await db.SetRemoveAsync(OnlineSetKey, playerId); await db.SetRemoveAsync(OnlineSetKey, playerId);
await db.KeyDeleteAsync(connsKey); await db.KeyDeleteAsync(connsKey);
return true;
} }
} }
return; return false;
} }
if (_localConns.TryGetValue(playerId, out var set)) if (_localConns.TryGetValue(playerId, out var set))
{ {
lock (set) set.Remove(connectionId); lock (set)
if (set.Count == 0) _localConns.TryRemove(playerId, out _); {
set.Remove(connectionId);
if (set.Count != 0) return false;
} }
_localConns.TryRemove(playerId, out _);
return true;
}
return false;
} }
/// <summary>Snapshot of SignalR connection ids that currently belong /// <summary>Snapshot of SignalR connection ids that currently belong