Net: heartbeat liveness, protocol version, message cap, replication reset
CI / build-test (push) Successful in 1m12s
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:
co-authored by
Claude Fable 5
parent
d360093be1
commit
f382fc98ea
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user