Net: heartbeat liveness, protocol version, message cap, replication reset
CI / build-test (push) Successful in 1m12s

Robustness pass over MrGameEng.Net:
- WebSocketServer heartbeats each connection (configurable interval/timeout)
  and drops peers idle past the timeout — detects half-open TCP that vanished
  without a close frame. Tracks last-activity per connection.
- Reassembled messages capped (server and client) so a peer cannot exhaust
  memory with an oversized fragmented message.
- Replication snapshots carry a protocol-version byte; a client receiving a
  mismatched version drops the message instead of decoding garbage, and a
  truncated snapshot is ignored without throwing.
- ReplicationClient.Clear() deletes all replicated entities, so clients can
  wipe stale state before reconnecting.

Tests: heartbeat healthy-survives / silent-peer-dropped, protocol-version
mismatch, truncated snapshot, replication clear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-13 04:44:07 +03:00
co-authored by Claude Fable 5
parent d360093be1
commit f382fc98ea
6 changed files with 380 additions and 52 deletions
@@ -0,0 +1,98 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using MrGameEng.Net;
using Xunit;
namespace MrGameEng.Net.Tests;
public class HeartbeatTests
{
[Fact]
public async Task HealthyClient_SurvivesPastIdleTimeout()
{
var port = FreePort();
using var server = new WebSocketServer(
port,
heartbeatInterval: TimeSpan.FromMilliseconds(100),
idleTimeout: TimeSpan.FromMilliseconds(400)
);
server.Start();
using var client = await WebSocketClient.ConnectAsync(
new Uri($"ws://localhost:{port}/"),
new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token
);
var connection = await WaitFor(
() => server.TryAcceptConnection(out var c) ? c : null,
"server accept"
);
// Дольше idleTimeout: живой клиент авто-отвечает pong на server-ping и остаётся открыт.
await Task.Delay(900, TestContext.Current.CancellationToken);
Assert.True(connection.IsOpen);
Assert.True(client.IsOpen);
}
[Fact]
public async Task SilentPeer_IsDroppedAfterIdleTimeout()
{
var port = FreePort();
using var server = new WebSocketServer(
port,
heartbeatInterval: TimeSpan.FromMilliseconds(100),
idleTimeout: TimeSpan.FromMilliseconds(400)
);
server.Start();
// Сырой peer: проходит рукопожатие, но дальше молчит и не отвечает на ping.
using var tcp = new TcpClient();
await tcp.ConnectAsync(IPAddress.Loopback, port, TestContext.Current.CancellationToken);
var request =
"GET / HTTP/1.1\r\n"
+ "Host: localhost\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
+ "Sec-WebSocket-Version: 13\r\n"
+ "\r\n";
var bytes = Encoding.ASCII.GetBytes(request);
await tcp.GetStream().WriteAsync(bytes, TestContext.Current.CancellationToken);
var connection = await WaitFor(
() => server.TryAcceptConnection(out var c) ? c : null,
"server accept"
);
Assert.True(connection.IsOpen);
// Peer не отвечает pong'ом → активность не обновляется → сервер закрывает по простою.
await WaitFor(() => connection.IsOpen ? null : "closed", "idle drop");
Assert.False(connection.IsOpen);
}
private static async Task<T> WaitFor<T>(Func<T?> poll, string what)
where T : class
{
for (var i = 0; i < 200; i++)
{
if (poll() is { } result)
{
return result;
}
await Task.Delay(25);
}
throw new TimeoutException($"Timed out waiting for {what}.");
}
private static int FreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}
+77 -2
View File
@@ -103,8 +103,8 @@ public class ReplicationTests
server.Send([connection]);
Assert.True(connection.Sent.TryDequeue(out var delta));
// Запись: type(1) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth.
Assert.Equal(22, delta!.Length);
// Запись: type(1) + version(1) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth.
Assert.Equal(23, delta!.Length);
client.Apply(delta);
var replicated = FindByNetId(clientStore, 1);
@@ -185,6 +185,81 @@ public class ReplicationTests
Assert.Equal(1, spawns);
}
[Fact]
public void Clear_DeletesEveryReplicatedEntity()
{
var serverStore = new EntityStore();
var clientStore = new EntityStore();
var server = new ReplicationServer(MakeSchema(), serverStore);
var client = new ReplicationClient(MakeSchema(), clientStore);
var connection = new FakeConnection();
serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = 1f, Y = 2f }
);
serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = 3f, Y = 4f }
);
server.Send([connection]);
client.Pump(connection);
Assert.Equal(2, client.EntityCount);
client.Clear();
Assert.Equal(0, client.EntityCount);
foreach (var entity in clientStore.Entities)
{
Assert.False(entity.HasComponent<NetId>());
}
}
[Fact]
public void MismatchedProtocolVersion_IsDropped()
{
var serverStore = new EntityStore();
var clientStore = new EntityStore();
var server = new ReplicationServer(MakeSchema(), serverStore);
var client = new ReplicationClient(MakeSchema(), clientStore);
var connection = new FakeConnection();
serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = 1f, Y = 2f }
);
server.Send([connection]);
Assert.True(connection.Sent.TryDequeue(out var snapshot));
// Портим байт версии (индекс 1: type=0, version=1) — клиент обязан отбросить снапшот целиком.
snapshot![1] = 0xFF;
client.Apply(snapshot);
Assert.Equal(0, client.EntityCount);
}
[Fact]
public void TruncatedSnapshot_IsIgnoredWithoutThrowing()
{
var serverStore = new EntityStore();
var clientStore = new EntityStore();
var server = new ReplicationServer(MakeSchema(), serverStore);
var client = new ReplicationClient(MakeSchema(), clientStore);
var connection = new FakeConnection();
serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = 5f, Y = 6f },
new TestHealth { Value = 9 }
);
server.Send([connection]);
Assert.True(connection.Sent.TryDequeue(out var snapshot));
// Режем хвост: заголовок и счётчик целы, но данные компонентов оборваны.
var truncated = snapshot!.AsSpan(0, snapshot.Length - 6).ToArray();
client.Apply(truncated); // не должно бросить
// Записи могли частично примениться, но клиент остался живым и консистентным.
Assert.True(client.EntityCount <= 1);
}
private static Entity FindByNetId(EntityStore store, int netId)
{
foreach (var entity in store.Entities)