Sync from GitHub main #1
@@ -4,14 +4,23 @@ using System.Text.Json.Nodes;
|
|||||||
|
|
||||||
namespace CommonwealthOnline.Server;
|
namespace CommonwealthOnline.Server;
|
||||||
|
|
||||||
|
// Runtime limits with the shipped production defaults. Injected only so tests can
|
||||||
|
// shorten timeouts and widen the local connect budget; ServerRuntime constructs
|
||||||
|
// the server without it, so production behavior is unchanged.
|
||||||
|
internal sealed record ServerTuning
|
||||||
|
{
|
||||||
|
public int MaxPacketsPerSecond { get; init; } = 120;
|
||||||
|
public int MaxConnectAttempts { get; init; } = 8;
|
||||||
|
public double ConnectAttemptWindowSeconds { get; init; } = 10.0;
|
||||||
|
public double ClientIdleTimeoutSeconds { get; init; } = 60.0;
|
||||||
|
public double ClientHandshakeTimeoutSeconds { get; init; } = 10.0;
|
||||||
|
|
||||||
|
public static ServerTuning Default { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable
|
internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private const int MaxPacketsPerSecond = 120;
|
private readonly ServerTuning _tuning;
|
||||||
private const int MaxConnectAttempts = 8;
|
|
||||||
private const double ConnectAttemptWindowSeconds = 10.0;
|
|
||||||
private const double ClientIdleTimeoutSeconds = 60.0;
|
|
||||||
private const double ClientHandshakeTimeoutSeconds = 10.0;
|
|
||||||
|
|
||||||
private readonly ServerOptions _options;
|
private readonly ServerOptions _options;
|
||||||
private readonly BanStore _banStore;
|
private readonly BanStore _banStore;
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
@@ -29,9 +38,10 @@ internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable
|
|||||||
private uint? _worldStateHostPlayerId;
|
private uint? _worldStateHostPlayerId;
|
||||||
private readonly double _startedAt = JsonHelpers.UnixTime();
|
private readonly double _startedAt = JsonHelpers.UnixTime();
|
||||||
|
|
||||||
public AuthoritativeServer(ServerOptions options)
|
public AuthoritativeServer(ServerOptions options, ServerTuning? tuning = null)
|
||||||
{
|
{
|
||||||
_options = options;
|
_options = options;
|
||||||
|
_tuning = tuning ?? ServerTuning.Default;
|
||||||
_banStore = new BanStore(options.BansPath);
|
_banStore = new BanStore(options.BansPath);
|
||||||
foreach (var name in new[]
|
foreach (var name in new[]
|
||||||
{
|
{
|
||||||
@@ -352,11 +362,11 @@ internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable
|
|||||||
var now = MonotonicClock.Now;
|
var now = MonotonicClock.Now;
|
||||||
if (now - client.RateWindowStart >= 1.0)
|
if (now - client.RateWindowStart >= 1.0)
|
||||||
{
|
{
|
||||||
if (client.RateWindowCount <= MaxPacketsPerSecond) client.RateViolations = Math.Max(0, client.RateViolations - 1);
|
if (client.RateWindowCount <= _tuning.MaxPacketsPerSecond) client.RateViolations = Math.Max(0, client.RateViolations - 1);
|
||||||
client.RateWindowStart = now; client.RateWindowCount = 0; client.RateWindowBlocked = false;
|
client.RateWindowStart = now; client.RateWindowCount = 0; client.RateWindowBlocked = false;
|
||||||
}
|
}
|
||||||
client.RateWindowCount++;
|
client.RateWindowCount++;
|
||||||
if (client.RateWindowCount <= MaxPacketsPerSecond) return true;
|
if (client.RateWindowCount <= _tuning.MaxPacketsPerSecond) return true;
|
||||||
Increment("rateLimitedPackets");
|
Increment("rateLimitedPackets");
|
||||||
if (!client.RateWindowBlocked)
|
if (!client.RateWindowBlocked)
|
||||||
{
|
{
|
||||||
@@ -370,13 +380,13 @@ internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable
|
|||||||
private bool AllowConnectAttempt(string ip)
|
private bool AllowConnectAttempt(string ip)
|
||||||
{
|
{
|
||||||
var now = MonotonicClock.Now;
|
var now = MonotonicClock.Now;
|
||||||
var cutoff = now - ConnectAttemptWindowSeconds;
|
var cutoff = now - _tuning.ConnectAttemptWindowSeconds;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_connectAttempts.TryGetValue(ip, out var queue)) _connectAttempts[ip] = queue = new Queue<double>();
|
if (!_connectAttempts.TryGetValue(ip, out var queue)) _connectAttempts[ip] = queue = new Queue<double>();
|
||||||
while (queue.Count > 0 && queue.Peek() < cutoff) queue.Dequeue();
|
while (queue.Count > 0 && queue.Peek() < cutoff) queue.Dequeue();
|
||||||
queue.Enqueue(now);
|
queue.Enqueue(now);
|
||||||
return queue.Count <= MaxConnectAttempts;
|
return queue.Count <= _tuning.MaxConnectAttempts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,12 +700,12 @@ internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable
|
|||||||
var now = JsonHelpers.UnixTime();
|
var now = JsonHelpers.UnixTime();
|
||||||
foreach (var client in snapshot)
|
foreach (var client in snapshot)
|
||||||
{
|
{
|
||||||
if (!client.GameplayActive && now - client.ConnectedAt > ClientHandshakeTimeoutSeconds)
|
if (!client.GameplayActive && now - client.ConnectedAt > _tuning.ClientHandshakeTimeoutSeconds)
|
||||||
{
|
{
|
||||||
Log($"Handshake timeout: {client.Label}", "warning");
|
Log($"Handshake timeout: {client.Label}", "warning");
|
||||||
await DisconnectClientAsync(client).ConfigureAwait(false);
|
await DisconnectClientAsync(client).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
else if (client.GameplayActive && client.LastPacketAt is { } last && now - last > ClientIdleTimeoutSeconds)
|
else if (client.GameplayActive && client.LastPacketAt is { } last && now - last > _tuning.ClientIdleTimeoutSeconds)
|
||||||
{
|
{
|
||||||
Log($"Idle timeout: player {client.PlayerId}", "warning");
|
Log($"Idle timeout: player {client.PlayerId}", "warning");
|
||||||
await DisconnectClientAsync(client).ConfigureAwait(false);
|
await DisconnectClientAsync(client).ConfigureAwait(false);
|
||||||
|
|||||||
+125
-21
@@ -12,10 +12,10 @@ namespace CommonwealthOnline.Server.Acceptance;
|
|||||||
// exercises framing, admission, relay, interest filtering and session teardown
|
// exercises framing, admission, relay, interest filtering and session teardown
|
||||||
// over an actual connection.
|
// over an actual connection.
|
||||||
//
|
//
|
||||||
// Covered here: the Baseline protocol, TCP compatibility, and interest-management
|
// Covered here: the Baseline protocol, TCP compatibility, interest-management,
|
||||||
// sections of the matrix. GNS sections wait on #2 (real-bridge loopback); the
|
// movement/combat-validation, and NPC-authority sections of the matrix. GNS
|
||||||
// 60s idle-timeout and 32/64-client load/fault runs are deferred follow-ups
|
// sections wait on #2 (real-bridge loopback); the 32/64-client load/fault runs
|
||||||
// (see the summary printed at the end).
|
// are deferred follow-ups (see the summary printed at the end).
|
||||||
internal static class Program
|
internal static class Program
|
||||||
{
|
{
|
||||||
private static int _passed;
|
private static int _passed;
|
||||||
@@ -39,13 +39,18 @@ internal static class Program
|
|||||||
await Run("tcp: two-client transform/playerState/worldState smoke", TwoClientSmoke);
|
await Run("tcp: two-client transform/playerState/worldState smoke", TwoClientSmoke);
|
||||||
await Run("player-state: action events are not replay-cached to late joiners", ActionEventsNotReplayCached);
|
await Run("player-state: action events are not replay-cached to late joiners", ActionEventsNotReplayCached);
|
||||||
await Run("tcp: disconnect leaves no stale session; reconnect works", DisconnectNoStale);
|
await Run("tcp: disconnect leaves no stale session; reconnect works", DisconnectNoStale);
|
||||||
|
await Run("movement: impossible movement is rejected, corrected, and not relayed", ImpossibleMovementRejected);
|
||||||
|
await Run("combat: self-targeted combat hit is rejected", SelfTargetedCombatRejected);
|
||||||
await Run("interest: same-cell relays, distant is filtered", InterestSameCellVsDistant);
|
await Run("interest: same-cell relays, distant is filtered", InterestSameCellVsDistant);
|
||||||
await Run("interest: multi-client across cells, no cross-cell transform spam", MultiClientNoCrossCellSpam);
|
await Run("interest: 16 clients across cells, no cross-cell transform spam", SixteenClientsNoCrossCellSpam);
|
||||||
|
await Run("authority: independent populated scopes get independent authorities", IndependentNpcAuthorities);
|
||||||
|
await Run("authority: a stale npc authority epoch is rejected over transport", StaleNpcAuthorityEpochRejected);
|
||||||
await Run("baseline: handshake timeout closes an unactivated session", HandshakeTimeoutCloses);
|
await Run("baseline: handshake timeout closes an unactivated session", HandshakeTimeoutCloses);
|
||||||
|
await Run("baseline: idle timeout closes a stale active session", IdleTimeoutCloses);
|
||||||
|
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.WriteLine($"acceptance: {_passed} passed, {_failed} failed");
|
Console.WriteLine($"acceptance: {_passed} passed, {_failed} failed");
|
||||||
Console.WriteLine("deferred (follow-ups): 60s idle-timeout, 32/64-client load + packet-loss/reorder + reconnect-churn, and all GNS sections (blocked on #2).");
|
Console.WriteLine("deferred (follow-ups): 32/64-client load + packet-loss/reorder + reconnect-churn, and all GNS sections (blocked on #2).");
|
||||||
return _failed == 0 ? 0 : 1;
|
return _failed == 0 ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,33 +232,32 @@ internal static class Program
|
|||||||
True(Stat(s.Server, "transformPacketsInterestFiltered") > filteredBefore, "distant transform counted as interest-filtered");
|
True(Stat(s.Server, "transformPacketsInterestFiltered") > filteredBefore, "distant transform counted as interest-filtered");
|
||||||
}
|
}
|
||||||
|
|
||||||
// The per-IP connect-attempt throttle admits 8 connections per 10s from one
|
// 16 clients over a single loopback IP exceed the production per-IP connect
|
||||||
// source address, so over a single loopback IP this case proves the no-cross-
|
// throttle (8/10s), so this case runs the test server with a widened local
|
||||||
// cell-spam property at 8 clients. Scaling to the matrix's 16/32/64-client load
|
// connect budget. The 32/64-client load runs remain follow-ups.
|
||||||
// runs needs distinct source IPs (loopback aliasing) or a loopback connect-
|
private const int SixteenClientCount = 16;
|
||||||
// throttle exemption; that is the load-testing follow-up.
|
|
||||||
private const int MultiClientCount = 8;
|
|
||||||
|
|
||||||
private static async Task MultiClientNoCrossCellSpam()
|
private static async Task SixteenClientsNoCrossCellSpam()
|
||||||
{
|
{
|
||||||
await using var s = new TestServer();
|
await using var s = new TestServer(tuning: new ServerTuning { MaxConnectAttempts = 64 });
|
||||||
var clients = new List<SyntheticProtocolClient>();
|
var clients = new List<SyntheticProtocolClient>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
for (var i = 0; i < MultiClientCount; i++)
|
for (var i = 0; i < SixteenClientCount; i++)
|
||||||
{
|
{
|
||||||
var c = await Connect(s.Port);
|
var c = await Connect(s.Port);
|
||||||
clients.Add(c);
|
clients.Add(c);
|
||||||
// every client sits in its own cell AND worldspace, far apart
|
// every client sits in its own cell AND worldspace (coordinates kept
|
||||||
await c.SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1_000_000.0, 0, "spawn"));
|
// well within MaxAbsCoordinate; distinct scopes do the isolating)
|
||||||
|
await c.SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1000.0, 0, "spawn"));
|
||||||
}
|
}
|
||||||
var ids = clients.Select(c => c.PlayerId).ToArray();
|
var ids = clients.Select(c => c.PlayerId).ToArray();
|
||||||
Equal(MultiClientCount, ids.Distinct().Count(), "unique server-owned ids for every client");
|
Equal(SixteenClientCount, ids.Distinct().Count(), "unique server-owned ids for every client");
|
||||||
foreach (var c in clients) await Drain(c, 150);
|
foreach (var c in clients) await Drain(c, 150);
|
||||||
|
|
||||||
// each client moves; nobody should see anybody else's transform
|
// each client moves; nobody should see anybody else's transform
|
||||||
for (var i = 0; i < clients.Count; i++)
|
for (var i = 0; i < clients.Count; i++)
|
||||||
await clients[i].SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1_000_000.0 + 10, 0));
|
await clients[i].SendAsync(TransformPacket((0x2000 + i).ToString("X8"), (0x9000 + i).ToString("X8"), i * 1000.0 + 10, 0));
|
||||||
|
|
||||||
foreach (var c in clients)
|
foreach (var c in clients)
|
||||||
True(await ExpectNone(c, p => Type(p) == "transform", 500), "client received no cross-cell transform spam");
|
True(await ExpectNone(c, p => Type(p) == "transform", 500), "client received no cross-cell transform spam");
|
||||||
@@ -264,6 +268,91 @@ internal static class Program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- movement / combat validation --------------------------------------
|
||||||
|
|
||||||
|
private static async Task ImpossibleMovementRejected()
|
||||||
|
{
|
||||||
|
await using var s = new TestServer();
|
||||||
|
await using var a = await Connect(s.Port);
|
||||||
|
await using var b = await Connect(s.Port);
|
||||||
|
// both anchor in the same cell so b would normally receive a's transforms
|
||||||
|
await a.SendAsync(TransformPacket("0000D00D", "0000D00D", 0, 0, "spawn"));
|
||||||
|
await b.SendAsync(TransformPacket("0000D00D", "0000D00D", 0, 0, "spawn"));
|
||||||
|
await Drain(a, 300); await Drain(b, 300);
|
||||||
|
var rejectedBefore = Stat(s.Server, "movementPacketsRejected");
|
||||||
|
|
||||||
|
// a "normal" jump of a million units in the same cell is physically impossible
|
||||||
|
await a.SendAsync(TransformPacket("0000D00D", "0000D00D", 1_000_000, 0, "normal"));
|
||||||
|
|
||||||
|
NotNull(await ReceiveUntil(a, p => Type(p) == "positionCorrection", 3000),
|
||||||
|
"sender received a position correction");
|
||||||
|
True(await ExpectNone(b, p => Type(p) == "transform" && UInt(p["playerId"]) == a.PlayerId, 1000),
|
||||||
|
"impossible movement was not relayed to b");
|
||||||
|
await SpinUntil(() => Stat(s.Server, "movementPacketsRejected") > rejectedBefore, 1500);
|
||||||
|
True(Stat(s.Server, "movementPacketsRejected") > rejectedBefore, "impossible movement counted as rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SelfTargetedCombatRejected()
|
||||||
|
{
|
||||||
|
await using var s = new TestServer();
|
||||||
|
await using var a = await Connect(s.Port);
|
||||||
|
await a.SendAsync(TransformPacket("0000C0FE", "0000C0FE", 0, 0, "spawn"));
|
||||||
|
await Drain(a, 300);
|
||||||
|
var rejectedBefore = Stat(s.Server, "packetsRejected");
|
||||||
|
var routedBefore = Stat(s.Server, "combatHitsRouted");
|
||||||
|
|
||||||
|
await a.SendAsync(new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = a.PlayerId, ["sequence"] = 1, ["damage"] = 10.0 });
|
||||||
|
|
||||||
|
await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000);
|
||||||
|
True(Stat(s.Server, "packetsRejected") > rejectedBefore, "self-targeted combat hit was rejected");
|
||||||
|
Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "self-targeted combat hit was not routed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- npc authority ------------------------------------------------------
|
||||||
|
|
||||||
|
private static async Task IndependentNpcAuthorities()
|
||||||
|
{
|
||||||
|
await using var s = new TestServer();
|
||||||
|
await using var a = await Connect(s.Port);
|
||||||
|
await using var b = await Connect(s.Port);
|
||||||
|
// a and b spawn into distinct, non-overlapping scopes
|
||||||
|
await a.SendAsync(TransformPacket("0000A111", "0000A111", 0, 0, "spawn"));
|
||||||
|
await b.SendAsync(TransformPacket("0000B222", "0000B222", 5_000_000, 0, "spawn"));
|
||||||
|
|
||||||
|
var grantA = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000);
|
||||||
|
var grantB = await ReceiveUntil(b, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == b.PlayerId, 3000);
|
||||||
|
NotNull(grantA, "a received authority for its own scope");
|
||||||
|
NotNull(grantB, "b received authority for its own scope");
|
||||||
|
True(UInt(grantA!["authorityEpoch"]) >= 1, "a's authority carries an epoch");
|
||||||
|
True(UInt(grantB!["authorityEpoch"]) >= 1, "b's authority carries an epoch");
|
||||||
|
Equal(2u, Stat(s.Server, "npcAuthorityScopes"), "two independent authority scopes exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task StaleNpcAuthorityEpochRejected()
|
||||||
|
{
|
||||||
|
await using var s = new TestServer();
|
||||||
|
await using var a = await Connect(s.Port);
|
||||||
|
await a.SendAsync(TransformPacket("0000E999", "0000E999", 0, 0, "spawn"));
|
||||||
|
var grant = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000);
|
||||||
|
NotNull(grant, "a became the authority for its scope");
|
||||||
|
var epoch = UInt(grant!["authorityEpoch"]);
|
||||||
|
await Drain(a, 200);
|
||||||
|
var rejectsBefore = Stat(s.Server, "npcAuthorityRejects");
|
||||||
|
|
||||||
|
// submit npcState claiming a wrong (future) authority epoch
|
||||||
|
await a.SendAsync(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "npcState",
|
||||||
|
["authorityEpoch"] = epoch + 1,
|
||||||
|
["authorityCellId"] = "0000E999",
|
||||||
|
["authorityWorldspaceId"] = "0000E999",
|
||||||
|
["npcs"] = new JsonArray()
|
||||||
|
});
|
||||||
|
|
||||||
|
await SpinUntil(() => Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, 2000);
|
||||||
|
True(Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, "npcState with a stale/wrong epoch was rejected");
|
||||||
|
}
|
||||||
|
|
||||||
// ---- session teardown ---------------------------------------------------
|
// ---- session teardown ---------------------------------------------------
|
||||||
|
|
||||||
private static async Task HandshakeTimeoutCloses()
|
private static async Task HandshakeTimeoutCloses()
|
||||||
@@ -277,6 +366,21 @@ internal static class Program
|
|||||||
Equal(0u, Stat(s.Server, "connectedClients"), "still no active clients after reap");
|
Equal(0u, Stat(s.Server, "connectedClients"), "still no active clients after reap");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task IdleTimeoutCloses()
|
||||||
|
{
|
||||||
|
// Short idle timeout so the reaper (500ms tick) fires quickly; handshake
|
||||||
|
// timeout keeps its default so the client can activate first.
|
||||||
|
await using var s = new TestServer(tuning: new ServerTuning { ClientIdleTimeoutSeconds = 2.0 });
|
||||||
|
await using var a = await Connect(s.Port);
|
||||||
|
await a.SendAsync(TransformPacket("0000171E", "0000171E", 0, 0, "spawn"));
|
||||||
|
await SpinUntil(() => Stat(s.Server, "connectedClients") == 1, 3000);
|
||||||
|
Equal(1u, Stat(s.Server, "connectedClients"), "client is active before going idle");
|
||||||
|
|
||||||
|
// stay idle past the 2s idle timeout; the reaper must close the session
|
||||||
|
await SpinUntil(() => Stat(s.Server, "connectedClients") == 0, 6000);
|
||||||
|
Equal(0u, Stat(s.Server, "connectedClients"), "idle active session was reaped");
|
||||||
|
}
|
||||||
|
|
||||||
// ---- infrastructure -----------------------------------------------------
|
// ---- infrastructure -----------------------------------------------------
|
||||||
|
|
||||||
private static async Task Run(string name, Func<Task> test)
|
private static async Task Run(string name, Func<Task> test)
|
||||||
@@ -377,7 +481,7 @@ internal static class Program
|
|||||||
private readonly TcpServerTransport _tcp;
|
private readonly TcpServerTransport _tcp;
|
||||||
private readonly string _dir;
|
private readonly string _dir;
|
||||||
|
|
||||||
public TestServer(int maxPlayers = 32)
|
public TestServer(int maxPlayers = 32, ServerTuning? tuning = null)
|
||||||
{
|
{
|
||||||
Port = FreePort();
|
Port = FreePort();
|
||||||
_dir = Path.Combine(Path.GetTempPath(), "co-accept-" + Guid.NewGuid().ToString("N"));
|
_dir = Path.Combine(Path.GetTempPath(), "co-accept-" + Guid.NewGuid().ToString("N"));
|
||||||
@@ -387,7 +491,7 @@ internal static class Program
|
|||||||
ConfigPath = Path.Combine(_dir, "commonwealth-server.json"),
|
ConfigPath = Path.Combine(_dir, "commonwealth-server.json"),
|
||||||
Host = "127.0.0.1", Port = Port, AdminPort = FreePort(), MaxPlayers = maxPlayers
|
Host = "127.0.0.1", Port = Port, AdminPort = FreePort(), MaxPlayers = maxPlayers
|
||||||
};
|
};
|
||||||
Server = new AuthoritativeServer(options);
|
Server = new AuthoritativeServer(options, tuning);
|
||||||
_tcp = new TcpServerTransport(options, Server);
|
_tcp = new TcpServerTransport(options, Server);
|
||||||
_tcp.Start();
|
_tcp.Start();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user