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
@@ -1,4 +1,5 @@
using Friflo.Engine.ECS;
using MrGameEng.Core;
namespace MrGameEng.Net;
@@ -21,6 +22,7 @@ public sealed class ReplicationClient
private readonly ReplicationSchema _schema;
private readonly EntityStore _store;
private readonly Dictionary<int, Entity> _entities = [];
private bool _warnedVersion;
/// <summary>Creates a replication client writing into <paramref name="store"/>.</summary>
public ReplicationClient(ReplicationSchema schema, EntityStore store)
@@ -38,55 +40,98 @@ public sealed class ReplicationClient
}
}
/// <summary>
/// Deletes every replicated entity and forgets all net ids. Call before reconnecting: the
/// fresh connection receives the full world again with a clean id space, so stale entities
/// from the previous session don't linger as duplicates.
/// </summary>
public void Clear()
{
foreach (var entity in _entities.Values)
{
entity.DeleteEntity();
}
_entities.Clear();
}
/// <summary>Applies one snapshot message to the local store.</summary>
public void Apply(byte[] message)
{
using var reader = new BinaryReader(new MemoryStream(message));
if (reader.ReadByte() != ReplicationMessage.Snapshot)
// Заголовок: тип(1) + версия(1). Короче — точно не наш снапшот.
if (message.Length < 2 || reader.ReadByte() != ReplicationMessage.Snapshot)
{
return; // незнакомый тип сообщения — пропускаем, это не снапшот
}
var slots = _schema.Slots;
var count = reader.ReadInt32();
for (var record = 0; record < count; record++)
var version = reader.ReadByte();
if (version != ReplicationMessage.ProtocolVersion)
{
var netId = reader.ReadInt32();
var op = reader.ReadByte();
if (op == ReplicationMessage.OpDespawn)
if (!_warnedVersion)
{
if (_entities.Remove(netId, out var dead))
{
dead.DeleteEntity();
}
continue;
_warnedVersion = true;
Log.Warning(
$"Replication protocol mismatch: server v{version}, client "
+ $"v{ReplicationMessage.ProtocolVersion} — snapshots dropped. Schemas out of sync."
);
}
var mask = reader.ReadUInt32();
var spawned = false;
if (!_entities.TryGetValue(netId, out var entity))
{
entity = _store.CreateEntity(new NetId { Value = netId });
_entities[netId] = entity;
spawned = true;
}
return;
}
foreach (var slot in slots)
var slots = _schema.Slots;
try
{
var count = reader.ReadInt32();
for (var record = 0; record < count; record++)
{
if ((mask & (1u << slot.Bit)) == 0)
var netId = reader.ReadInt32();
var op = reader.ReadByte();
if (op == ReplicationMessage.OpDespawn)
{
if (_entities.Remove(netId, out var dead))
{
dead.DeleteEntity();
}
continue;
}
var data = reader.ReadBytes(slot.Size);
slot.Apply(entity, data, 0);
}
var mask = reader.ReadUInt32();
var spawned = false;
if (!_entities.TryGetValue(netId, out var entity))
{
entity = _store.CreateEntity(new NetId { Value = netId });
_entities[netId] = entity;
spawned = true;
}
if (spawned)
{
EntitySpawned?.Invoke(entity);
foreach (var slot in slots)
{
if ((mask & (1u << slot.Bit)) == 0)
{
continue;
}
var data = reader.ReadBytes(slot.Size);
if (data.Length < slot.Size)
{
return; // снапшот оборван на полпути — дальше читать нечего
}
slot.Apply(entity, data, 0);
}
if (spawned)
{
EntitySpawned?.Invoke(entity);
}
}
}
catch (EndOfStreamException)
{
// Структурно битый/усечённый снапшот — игнорируем остаток, соединение не роняем.
}
}
}
@@ -98,6 +98,7 @@ public sealed class ReplicationServer
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
writer.Write(ReplicationMessage.Snapshot);
writer.Write(ReplicationMessage.ProtocolVersion); // версия формата — клиент отвергает чужую
var countPosition = stream.Position;
writer.Write(0); // количество записей, допишем в конце
var records = 0;
@@ -169,6 +170,14 @@ public sealed class ReplicationServer
internal static class ReplicationMessage
{
internal const byte Snapshot = 1;
/// <summary>
/// Wire-format version. Bump whenever the snapshot layout or the meaning of the schema's
/// component blits changes; a client receiving a mismatched version drops the message
/// instead of decoding garbage (guards against a desync between server and client schemas).
/// </summary>
internal const byte ProtocolVersion = 1;
internal const byte OpUpsert = 0;
internal const byte OpDespawn = 1;
}