using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Microsoft.Xna.Framework.Net
{
///
/// Monitors connection health using heartbeats and detects disconnections.
///
public class ConnectionHealthMonitor
{
private const int HEARTBEAT_INTERVAL_MS = 3000; // Every 3 seconds (LAN)
private const int HEARTBEAT_TIMEOUT_MS = 9000; // 3 missed = disconnect (fast for LAN)
private readonly Dictionary connections = new Dictionary();
private uint sequenceNumber = 0;
private NetworkSession session;
private bool isRunning = false;
private class GamerConnectionState
{
public DateTime LastHeartbeat { get; set; } = DateTime.UtcNow;
public int MissedHeartbeats { get; set; } = 0;
public TimeSpan LastRtt { get; set; } = TimeSpan.Zero;
public uint LastSequenceNumber { get; set; } = 0;
}
///
/// Starts monitoring connection health for the given session.
///
public void StartMonitoring(NetworkSession session)
{
if (isRunning)
return;
this.session = session;
isRunning = true;
// Initialize connection tracking for all remote gamers
lock (connections)
{
foreach (var gamer in session.AllGamers.Where(g => !g.IsLocal))
{
connections[gamer.Id] = new GamerConnectionState();
}
}
// Start heartbeat sender task
Task.Run(async () => await HeartbeatLoopAsync());
}
///
/// Stops monitoring.
///
public void StopMonitoring()
{
isRunning = false;
}
private async Task HeartbeatLoopAsync()
{
while (isRunning && session != null && !session.disposed)
{
try
{
// Get the session's local gamer (not the static one, which can be stale)
var localGamer = session.LocalGamers.FirstOrDefault();
if (localGamer == null)
continue; // No local gamer, skip this heartbeat
// Send heartbeat to all remote gamers
var heartbeat = new HeartbeatMessage
{
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
SequenceNumber = sequenceNumber++,
GamerCount = session.AllGamers.Count,
GamerId = localGamer.Id
};
var writer = new PacketWriter();
heartbeat.Serialize(writer);
var data = writer.GetData();
// Send to each remote gamer individually (peer-to-peer)
var remoteGamers = session.AllGamers.Where(g => !g.IsLocal).ToList();
Console.WriteLine($"[HEARTBEAT-SEND] Sending to {remoteGamers.Count} remote gamer(s)");
foreach (var gamer in remoteGamers)
{
Console.WriteLine($"[HEARTBEAT-SEND] -> {gamer.Gamertag} ({gamer.Id})");
session.SendDataToGamer(gamer, data, SendDataOptions.None);
}
// Check for disconnections
CheckForTimeouts();
await Task.Delay(HEARTBEAT_INTERVAL_MS);
}
catch (Exception ex)
{
Console.WriteLine($"[HEARTBEAT] Error in heartbeat loop: {ex.Message}");
}
}
}
private void CheckForTimeouts()
{
if (session == null)
return;
var now = DateTime.UtcNow;
var disconnected = new List();
lock (connections)
{
foreach (var kvp in connections.ToList())
{
var state = kvp.Value;
var timeSinceLastHB = (now - state.LastHeartbeat).TotalMilliseconds;
if (timeSinceLastHB > HEARTBEAT_TIMEOUT_MS)
{
// Mark for removal
var gamer = session.AllGamers.FirstOrDefault(g => g.Id == kvp.Key);
if (gamer != null)
{
disconnected.Add(gamer);
Console.WriteLine($"[HEARTBEAT] {gamer.Gamertag} timed out (no response for {timeSinceLastHB:F0}ms)");
}
}
}
}
// Remove disconnected gamers (outside lock to avoid deadlock)
foreach (var gamer in disconnected)
{
// Use reflection to call internal RemoveGamer method
var removeMethod = session.GetType().GetMethod("RemoveGamer",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
removeMethod?.Invoke(session, new object[] { gamer });
lock (connections)
{
connections.Remove(gamer.Id);
}
}
}
///
/// Called when HeartbeatMessage is received.
///
public void OnHeartbeatReceived(string gamerId, HeartbeatMessage heartbeat)
{
Console.WriteLine($"[HEARTBEAT-RECV] Received from gamer {gamerId}, seq {heartbeat.SequenceNumber}");
lock (connections)
{
if (!connections.ContainsKey(gamerId))
{
// New remote gamer
connections[gamerId] = new GamerConnectionState();
Console.WriteLine($"[HEARTBEAT-RECV] Created new connection state for {gamerId}");
}
var state = connections[gamerId];
state.LastHeartbeat = DateTime.UtcNow;
state.MissedHeartbeats = 0;
state.LastSequenceNumber = heartbeat.SequenceNumber;
}
// Send reply for RTT measurement using session's local gamer (not static property)
var localGamer = session?.LocalGamers.FirstOrDefault();
var reply = new HeartbeatReplyMessage
{
RequestTimestamp = heartbeat.Timestamp,
ReplyTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
GamerId = localGamer?.Id ?? string.Empty
};
var writer = new PacketWriter();
reply.Serialize(writer);
// Find the gamer and send reply
var gamer = session?.AllGamers.FirstOrDefault(g => g.Id == gamerId);
Console.WriteLine($"[HEARTBEAT-REPLY] Found gamer: {gamer?.Gamertag ?? "NULL"}, sending reply");
if (gamer != null)
{
session?.SendDataToGamer(gamer, writer.GetData(), SendDataOptions.None);
}
else
{
Console.WriteLine($"[HEARTBEAT-REPLY] ERROR: Could not find gamer {gamerId} to send reply!");
}
}
///
/// Called when HeartbeatReplyMessage is received (for RTT calculation).
///
public void OnHeartbeatReplyReceived(string gamerId, HeartbeatReplyMessage reply)
{
Console.WriteLine($"[HEARTBEAT-REPLY-RECV] Received reply from {gamerId}");
lock (connections)
{
if (connections.TryGetValue(gamerId, out var state))
{
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var rtt = TimeSpan.FromMilliseconds(now - reply.RequestTimestamp);
state.LastRtt = rtt;
// Update gamer's RTT for QoS display
var gamer = session?.AllGamers.FirstOrDefault(g => g.Id == gamerId);
gamer?.UpdateRoundtripTime(rtt);
}
}
}
///
/// Called when a new gamer joins to start tracking them.
///
public void OnGamerJoined(NetworkGamer gamer)
{
if (gamer == null || gamer.IsLocal)
return;
lock (connections)
{
connections[gamer.Id] = new GamerConnectionState();
}
}
///
/// Called when a gamer leaves to stop tracking them.
///
public void OnGamerLeft(NetworkGamer gamer)
{
if (gamer == null)
return;
lock (connections)
{
connections.Remove(gamer.Id);
}
}
}
}