Complete C# server cutover
This commit is contained in:
+101
-127
@@ -14,18 +14,17 @@ internal static class Program
|
||||
{
|
||||
await Run("transport policy", TestTransportPolicy);
|
||||
await Run("packet codec", TestPacketCodec);
|
||||
await Run("snapshot sequence", TestSnapshotSequence);
|
||||
await Run("snapshot sequencing", TestSnapshotSequencing);
|
||||
await Run("snapshot envelope", TestSnapshotEnvelope);
|
||||
await Run("player state validation", TestPlayerStateValidation);
|
||||
await Run("npc authority epochs", TestNpcAuthority);
|
||||
await Run("npc authority epochs", TestNpcAuthorityEpochs);
|
||||
await Run("interest filtering", TestInterestFiltering);
|
||||
await Run("config compatibility", TestConfigCompatibility);
|
||||
await Run("ban persistence", TestBanStore);
|
||||
await Run("authoritative server ids and interest", TestAuthoritativeServer);
|
||||
await Run("durable player state relay", TestPlayerStateRelay);
|
||||
await Run("npc authority validation", TestNpcAuthorityIntegration);
|
||||
await Run("combat interest routing", TestCombatInterest);
|
||||
|
||||
await Run("ban persistence", TestBanPersistence);
|
||||
await Run("server-owned ids and interest relay", TestServerOwnedIdsAndInterest);
|
||||
await Run("durable player state relay", TestDurablePlayerStateRelay);
|
||||
await Run("npc authority enforcement", TestNpcAuthorityEnforcement);
|
||||
await Run("combat interest enforcement", TestCombatInterest);
|
||||
Console.WriteLine($"C# server tests: {_passed} passed, {_failed} failed");
|
||||
return _failed == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -51,14 +50,13 @@ internal static class Program
|
||||
var encoded = PacketCodec.Encode(packet);
|
||||
Equal("playerState", encoded.PacketType);
|
||||
Equal(Delivery.ReliableOrdered, encoded.Delivery);
|
||||
var decoded = PacketCodec.Decode(encoded.Payload);
|
||||
Equal("Nomad", JsonHelpers.String(decoded["characterName"]));
|
||||
Throws<PacketCodecException>(() => PacketCodec.Encode(new JsonObject { ["type"] = "x", ["blob"] = new string('a', ProtocolConstants.MaxMessageBytes + 1) }));
|
||||
Equal("Nomad", JsonHelpers.String(PacketCodec.Decode(encoded.Payload)["characterName"]));
|
||||
Throws<PacketCodecException>(() => PacketCodec.Decode("[]"u8));
|
||||
Throws<PacketCodecException>(() => PacketCodec.Encode(new JsonObject { ["type"] = "oversize", ["payload"] = new string('x', ProtocolConstants.MaxMessageBytes + 1) }));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestSnapshotSequence()
|
||||
private static Task TestSnapshotSequencing()
|
||||
{
|
||||
var window = new SequenceWindow();
|
||||
True(window.Accept(1));
|
||||
@@ -66,28 +64,26 @@ internal static class Program
|
||||
False(window.Accept(2));
|
||||
False(window.Accept(3));
|
||||
True(SequenceWindow.IsNewer(1, uint.MaxValue));
|
||||
var counter = new SequenceCounter();
|
||||
Equal(1u, counter.Advance());
|
||||
False(SequenceWindow.IsNewer(uint.MaxValue, 1));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestSnapshotEnvelope()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{\"type\":\"transform\"}");
|
||||
var wire = GnsSnapshotEnvelope.Encode("transform", payload, 7);
|
||||
Equal(ProtocolConstants.MaxMessageBytes >= wire.Length, true);
|
||||
True(GnsSnapshotEnvelope.TryDecode(wire, out var envelope, out var error));
|
||||
var encoded = GnsSnapshotEnvelope.Encode("transform", payload, 7);
|
||||
True(GnsSnapshotEnvelope.TryDecode(encoded, out var envelope, out var error));
|
||||
Equal<string?>(null, error);
|
||||
Equal("transform", envelope.PacketType);
|
||||
Equal(7u, envelope.Sequence);
|
||||
SequenceEqual(payload, envelope.Payload);
|
||||
True(payload.AsSpan().SequenceEqual(envelope.Payload));
|
||||
False(GnsSnapshotEnvelope.TryDecode(payload, out _, out _));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestPlayerStateValidation()
|
||||
{
|
||||
var state = new JsonObject
|
||||
var packet = new JsonObject
|
||||
{
|
||||
["type"] = "playerState",
|
||||
["characterName"] = "Nomad",
|
||||
@@ -106,28 +102,32 @@ internal static class Program
|
||||
},
|
||||
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" })
|
||||
};
|
||||
var normalized = ProtocolValidation.NormalizePlayerState(state);
|
||||
var normalized = ProtocolValidation.NormalizePlayerState(packet);
|
||||
NotNull(normalized);
|
||||
Equal("00000123", JsonHelpers.String(((JsonObject)normalized!["appearance"]!)["hairColorFormId"]));
|
||||
var bad = (JsonObject)state.DeepClone();
|
||||
((JsonArray)bad["actionEvents"]!)[0]!["eventName"] = "bogus";
|
||||
Equal<JsonObject?>(null, ProtocolValidation.NormalizePlayerState(bad));
|
||||
var appearance = normalized!["appearance"] as JsonObject;
|
||||
NotNull(appearance);
|
||||
Equal("00000123", JsonHelpers.String(appearance!["hairColorFormId"]));
|
||||
var invalidActions = new JsonObject
|
||||
{
|
||||
["type"] = "playerState",
|
||||
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "invalid" })
|
||||
};
|
||||
Equal<JsonObject?>(null, ProtocolValidation.NormalizePlayerState(invalidActions));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestNpcAuthority()
|
||||
private static Task TestNpcAuthorityEpochs()
|
||||
{
|
||||
var manager = new NpcAuthorityManager();
|
||||
var scope = new ScopeKey("00000001", "");
|
||||
var changes = manager.Reconcile(new[] { (2u, scope), (1u, scope) });
|
||||
Equal(1, changes.Count);
|
||||
Equal(1u, changes[0].PlayerId);
|
||||
Equal(1u, changes[0].Epoch);
|
||||
var first = manager.Reconcile(new[] { (2u, scope), (1u, scope) });
|
||||
Equal(1u, first.Single().PlayerId);
|
||||
Equal(1u, first.Single().Epoch);
|
||||
True(manager.Authorize(1, scope, 1));
|
||||
changes = manager.Reconcile(Array.Empty<(uint, ScopeKey)>());
|
||||
Equal(2u, changes[0].Epoch);
|
||||
changes = manager.Reconcile(new[] { (3u, scope) });
|
||||
Equal(3u, changes[0].Epoch);
|
||||
var revoked = manager.Reconcile(Array.Empty<(uint PlayerId, ScopeKey Scope)>());
|
||||
Equal(2u, revoked.Single().Epoch);
|
||||
var regrant = manager.Reconcile(new[] { (3u, scope) });
|
||||
Equal(3u, regrant.Single().Epoch);
|
||||
False(manager.Authorize(1, scope, 1));
|
||||
True(manager.Authorize(3, scope, 3));
|
||||
return Task.CompletedTask;
|
||||
@@ -136,14 +136,10 @@ internal static class Program
|
||||
private static Task TestInterestFiltering()
|
||||
{
|
||||
var a = Transform("00000001", "000000AA", 0, 0);
|
||||
var sameCell = Transform("00000001", "000000BB", 999999, 999999);
|
||||
var near = Transform("00000002", "000000AA", 1000, 1000);
|
||||
var far = Transform("00000003", "000000AA", 20000, 0);
|
||||
var otherWorld = Transform("00000004", "000000BB", 0, 0);
|
||||
True(ProtocolValidation.StatesShareInterest(a, sameCell));
|
||||
True(ProtocolValidation.StatesShareInterest(a, near));
|
||||
False(ProtocolValidation.StatesShareInterest(a, far));
|
||||
False(ProtocolValidation.StatesShareInterest(a, otherWorld));
|
||||
True(ProtocolValidation.StatesShareInterest(a, Transform("00000001", "000000BB", 500000, 500000)));
|
||||
True(ProtocolValidation.StatesShareInterest(a, Transform("00000002", "000000AA", 1000, 1000)));
|
||||
False(ProtocolValidation.StatesShareInterest(a, Transform("00000003", "000000AA", 20000, 0)));
|
||||
False(ProtocolValidation.StatesShareInterest(a, Transform("00000004", "000000BB", 0, 0)));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -163,53 +159,46 @@ internal static class Program
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task TestBanStore()
|
||||
private static Task TestBanPersistence()
|
||||
{
|
||||
var dir = TempDir();
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(dir, "bans.json");
|
||||
var store = new BanStore(path);
|
||||
store.Ban("127.0.0.1", "test");
|
||||
var reloaded = new BanStore(path);
|
||||
Equal("test", reloaded.GetBan("127.0.0.1")?.Reason);
|
||||
True(reloaded.Unban("127.0.0.1"));
|
||||
new BanStore(path).Ban("127.0.0.1", "test");
|
||||
var loaded = new BanStore(path);
|
||||
Equal("test", loaded.GetBan("127.0.0.1")?.Reason);
|
||||
True(loaded.Unban("127.0.0.1"));
|
||||
Equal(0, new BanStore(path).List().Count);
|
||||
}
|
||||
finally { Directory.Delete(dir, true); }
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static async Task TestAuthoritativeServer()
|
||||
private static async Task TestServerOwnedIdsAndInterest()
|
||||
{
|
||||
var fixture = CreateServerFixture();
|
||||
await using var server = fixture.Server;
|
||||
await using var fixture = new ServerFixture();
|
||||
var a = new MemoryConnection("a", 31001);
|
||||
var b = new MemoryConnection("b", 31002);
|
||||
True(await server.AcceptConnectionAsync(a, CancellationToken.None));
|
||||
True(await server.AcceptConnectionAsync(b, CancellationToken.None));
|
||||
await Send(server, a, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
|
||||
await Send(server, b, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
|
||||
await Activate(fixture.Server, a, b);
|
||||
var aId = ReadyId(a); var bId = ReadyId(b);
|
||||
True(aId != bId);
|
||||
|
||||
await Send(server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
|
||||
await Send(server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
|
||||
await Send(fixture.Server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
|
||||
await Send(fixture.Server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
|
||||
a.Clear(); b.Clear();
|
||||
await Send(server, a, Transform("00000001", "000000AA", 1, 0));
|
||||
False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "transform" && JsonHelpers.TryUInt32(p["playerId"], 1, uint.MaxValue, out var id) && id == aId));
|
||||
await Send(fixture.Server, a, Transform("00000001", "000000AA", 1, 0));
|
||||
False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "transform" && PlayerId(p) == aId));
|
||||
}
|
||||
|
||||
private static async Task TestPlayerStateRelay()
|
||||
private static async Task TestDurablePlayerStateRelay()
|
||||
{
|
||||
var fixture = CreateServerFixture();
|
||||
await using var server = fixture.Server;
|
||||
await using var fixture = new ServerFixture();
|
||||
var a = new MemoryConnection("a", 32001); var b = new MemoryConnection("b", 32002);
|
||||
await Activate(server, a, b);
|
||||
await Send(server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
|
||||
await Send(server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
|
||||
await Activate(fixture.Server, a, b);
|
||||
await Send(fixture.Server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
|
||||
await Send(fixture.Server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
|
||||
a.Clear(); b.Clear();
|
||||
await Send(server, a, new JsonObject
|
||||
await Send(fixture.Server, a, new JsonObject
|
||||
{
|
||||
["type"] = "playerState",
|
||||
["characterName"] = "Nomad",
|
||||
@@ -220,62 +209,37 @@ internal static class Program
|
||||
False(relay.ContainsKey("actionEvents"));
|
||||
}
|
||||
|
||||
private static async Task TestNpcAuthorityIntegration()
|
||||
private static async Task TestNpcAuthorityEnforcement()
|
||||
{
|
||||
var fixture = CreateServerFixture();
|
||||
await using var server = fixture.Server;
|
||||
await using var fixture = new ServerFixture();
|
||||
var a = new MemoryConnection("a", 33001);
|
||||
True(await server.AcceptConnectionAsync(a, CancellationToken.None));
|
||||
await Send(server, a, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
|
||||
await Send(server, a, Transform("00000010", "", 0, 0, "spawn"));
|
||||
await Activate(fixture.Server, a);
|
||||
await Send(fixture.Server, a, Transform("00000010", "", 0, 0, "spawn"));
|
||||
var authority = a.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "npcAuthority");
|
||||
JsonHelpers.TryUInt32(authority["authorityEpoch"], 1, uint.MaxValue, out var epoch);
|
||||
True(JsonHelpers.TryUInt32(authority["authorityEpoch"], 1, uint.MaxValue, out var epoch));
|
||||
a.Clear();
|
||||
await Send(server, a, new JsonObject
|
||||
await Send(fixture.Server, a, new JsonObject
|
||||
{
|
||||
["type"] = "npcState",
|
||||
["authorityEpoch"] = epoch,
|
||||
["authorityCellId"] = "00000010",
|
||||
["authorityWorldspaceId"] = "",
|
||||
["npcs"] = new JsonArray(new JsonObject
|
||||
{
|
||||
["sourceFormId"] = "00000020", ["cellId"] = "00000010", ["worldspaceId"] = "",
|
||||
["x"] = 1.0, ["y"] = 2.0, ["z"] = 3.0, ["angleZ"] = 0.0
|
||||
})
|
||||
["type"] = "npcState", ["authorityEpoch"] = epoch, ["authorityCellId"] = "00000010", ["authorityWorldspaceId"] = "",
|
||||
["npcs"] = new JsonArray(new JsonObject { ["sourceFormId"] = "00000020", ["cellId"] = "00000010", ["worldspaceId"] = "", ["x"] = 1.0, ["y"] = 2.0, ["z"] = 3.0, ["angleZ"] = 0.0 })
|
||||
});
|
||||
var stats = server.GetCoreStats();
|
||||
True(JsonHelpers.TryUInt32(stats["npcStatePacketsReceived"], 0, uint.MaxValue, out var received) && received == 1);
|
||||
await Send(server, a, new JsonObject
|
||||
{
|
||||
["type"] = "npcState", ["authorityEpoch"] = Math.Max(1u, epoch - 1), ["authorityCellId"] = "00000010", ["authorityWorldspaceId"] = "",
|
||||
["npcs"] = new JsonArray()
|
||||
});
|
||||
stats = server.GetCoreStats();
|
||||
True(JsonHelpers.TryUInt32(stats["npcAuthorityRejects"], 0, uint.MaxValue, out var rejects) && rejects >= 1);
|
||||
Equal(1u, Stat(fixture.Server, "npcStatePacketsReceived"));
|
||||
await Send(fixture.Server, a, new JsonObject { ["type"] = "npcState", ["authorityEpoch"] = epoch + 1, ["authorityCellId"] = "00000010", ["authorityWorldspaceId"] = "", ["npcs"] = new JsonArray() });
|
||||
True(Stat(fixture.Server, "npcAuthorityRejects") >= 1);
|
||||
}
|
||||
|
||||
private static async Task TestCombatInterest()
|
||||
{
|
||||
var fixture = CreateServerFixture();
|
||||
await using var server = fixture.Server;
|
||||
await using var fixture = new ServerFixture();
|
||||
var a = new MemoryConnection("a", 34001); var b = new MemoryConnection("b", 34002);
|
||||
await Activate(server, a, b);
|
||||
await Activate(fixture.Server, a, b);
|
||||
var bId = ReadyId(b);
|
||||
await Send(server, a, Transform("00000100", "000000AA", 0, 0, "spawn"));
|
||||
await Send(server, b, Transform("00000200", "000000BB", 0, 0, "spawn"));
|
||||
await Send(fixture.Server, a, Transform("00000100", "000000AA", 0, 0, "spawn"));
|
||||
await Send(fixture.Server, b, Transform("00000200", "000000BB", 0, 0, "spawn"));
|
||||
a.Clear(); b.Clear();
|
||||
await Send(server, a, new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = bId, ["sequence"] = 1, ["damage"] = 10.0 });
|
||||
await Send(fixture.Server, a, new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = bId, ["sequence"] = 1, ["damage"] = 10.0 });
|
||||
False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "combatHit"));
|
||||
var stats = server.GetCoreStats();
|
||||
True(JsonHelpers.TryUInt32(stats["packetsRejected"], 0, uint.MaxValue, out var rejected) && rejected >= 1);
|
||||
}
|
||||
|
||||
private static (AuthoritativeServer Server, string Dir) CreateServerFixture()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var path = Path.Combine(dir, "commonwealth-server.json");
|
||||
var options = new ServerOptions { ConfigPath = path, Host = "127.0.0.1", Port = 7777, AdminPort = 7779, MaxPlayers = 32 };
|
||||
return (new AuthoritativeServer(options), dir);
|
||||
True(Stat(fixture.Server, "packetsRejected") >= 1);
|
||||
}
|
||||
|
||||
private static async Task Activate(AuthoritativeServer server, params MemoryConnection[] connections)
|
||||
@@ -287,15 +251,7 @@ internal static class Program
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ReadyId(MemoryConnection connection)
|
||||
{
|
||||
var ready = connection.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "sessionReady");
|
||||
True(JsonHelpers.TryUInt32(ready["playerId"], 1, uint.MaxValue, out var id));
|
||||
return id;
|
||||
}
|
||||
|
||||
private static Task Send(AuthoritativeServer server, MemoryConnection connection, JsonObject packet) =>
|
||||
server.HandleMessageAsync(connection, PacketCodec.Encode(packet).Payload, CancellationToken.None);
|
||||
private static Task Send(AuthoritativeServer server, MemoryConnection connection, JsonObject packet) => server.HandleMessageAsync(connection, PacketCodec.Encode(packet).Payload, CancellationToken.None);
|
||||
|
||||
private static JsonObject Transform(string cell, string world, double x, double y, string movement = "normal") => new()
|
||||
{
|
||||
@@ -303,6 +259,16 @@ internal static class Program
|
||||
["cellId"] = cell, ["worldspaceId"] = world, ["movementType"] = movement
|
||||
};
|
||||
|
||||
private static uint ReadyId(MemoryConnection connection)
|
||||
{
|
||||
var packet = connection.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "sessionReady");
|
||||
True(JsonHelpers.TryUInt32(packet["playerId"], 1, uint.MaxValue, out var id));
|
||||
return id;
|
||||
}
|
||||
|
||||
private static uint PlayerId(JsonObject packet) => JsonHelpers.TryUInt32(packet["playerId"], 0, uint.MaxValue, out var id) ? id : 0;
|
||||
private static uint Stat(AuthoritativeServer server, string name) => JsonHelpers.TryUInt32(server.GetCoreStats()[name], 0, uint.MaxValue, out var value) ? value : 0;
|
||||
|
||||
private static string TempDir()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "co-csharp-tests-" + Guid.NewGuid().ToString("N"));
|
||||
@@ -313,35 +279,43 @@ internal static class Program
|
||||
private static void True(bool value) { if (!value) throw new Exception("expected true"); }
|
||||
private static void False(bool value) { if (value) throw new Exception("expected false"); }
|
||||
private static void NotNull(object? value) { if (value is null) throw new Exception("expected non-null"); }
|
||||
private static void Equal<T>(T expected, T actual) where T : notnull { if (!EqualityComparer<T>.Default.Equals(expected, actual)) throw new Exception($"expected {expected}, got {actual}"); }
|
||||
private static void SequenceEqual(byte[] expected, byte[] actual) { if (!expected.AsSpan().SequenceEqual(actual)) throw new Exception("byte sequences differ"); }
|
||||
private static void Equal<T>(T expected, T actual) { if (!EqualityComparer<T>.Default.Equals(expected, actual)) throw new Exception($"expected {expected}, got {actual}"); }
|
||||
private static void Throws<T>(Action action) where T : Exception { try { action(); } catch (T) { return; } throw new Exception($"expected {typeof(T).Name}"); }
|
||||
|
||||
private sealed class ServerFixture : IAsyncDisposable
|
||||
{
|
||||
public ServerFixture()
|
||||
{
|
||||
Directory = TempDir();
|
||||
Server = new AuthoritativeServer(new ServerOptions { ConfigPath = Path.Combine(Directory, "commonwealth-server.json"), Host = "127.0.0.1", Port = 7777, AdminPort = 7779, MaxPlayers = 32 });
|
||||
}
|
||||
public string Directory { get; }
|
||||
public AuthoritativeServer Server { get; }
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Server.DisposeAsync();
|
||||
try { System.IO.Directory.Delete(Directory, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MemoryConnection : IGameConnection
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<JsonObject> _sent = new();
|
||||
private int _closed;
|
||||
|
||||
public MemoryConnection(string name, int port)
|
||||
{
|
||||
ConnectionKey = "memory:" + name;
|
||||
RemoteEndpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), port);
|
||||
}
|
||||
|
||||
public MemoryConnection(string name, int port) { ConnectionKey = "memory:" + name; RemoteEndpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), port); }
|
||||
public string ConnectionKey { get; }
|
||||
public string TransportName => "memory";
|
||||
public IPEndPoint RemoteEndpoint { get; }
|
||||
public bool IsClosed => Volatile.Read(ref _closed) != 0;
|
||||
public IReadOnlyList<JsonObject> SentPackets { get { lock (_gate) return _sent.Select(JsonHelpers.CloneObject).ToArray(); } }
|
||||
|
||||
public ValueTask<SendOutcome> SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected);
|
||||
lock (_gate) _sent.Add(PacketCodec.Decode(packet.Payload));
|
||||
return ValueTask.FromResult(SendOutcome.Sent);
|
||||
}
|
||||
|
||||
public ValueTask DisconnectAsync(int reason, string debug) { Interlocked.Exchange(ref _closed, 1); return ValueTask.CompletedTask; }
|
||||
public ValueTask DisposeAsync() => DisconnectAsync(0, "dispose");
|
||||
public void Clear() { lock (_gate) _sent.Clear(); }
|
||||
|
||||
Reference in New Issue
Block a user