feat: port session 07 — Protocol Parser, Auth extras (TPM/certidp/certstore), Internal utilities & data structures

Session 07 scope (5 features, 17 tests, ~1165 Go LOC):
- Protocol/ParserTypes.cs: ParserState enum (79 states), PublishArgument, ParseContext
- Protocol/IProtocolHandler.cs: handler interface decoupling parser from client
- Protocol/ProtocolParser.cs: Parse(), ProtoSnippet(), OverMaxControlLineLimit(),
  ProcessPub/HeaderPub/RoutedMsgArgs/RoutedHeaderMsgArgs, ClonePubArg(), GetHeader()
- tests/Protocol/ProtocolParserTests.cs: 17 tests via TestProtocolHandler stub

Auth extras from session 06 (committed separately):
- Auth/TpmKeyProvider.cs, Auth/CertificateIdentityProvider/, Auth/CertificateStore/

Internal utilities & data structures (session 06 overflow):
- Internal/AccessTimeService.cs, ElasticPointer.cs, SystemMemory.cs, ProcessStatsProvider.cs
- Internal/DataStructures/GenericSublist.cs, HashWheel.cs
- Internal/DataStructures/SubjectTree.cs, SubjectTreeNode.cs, SubjectTreeParts.cs

All 461 tests pass (460 unit + 1 integration). DB updated for features 2588-2592 and tests 2598-2614.
This commit is contained in:
Joseph Doherty
2026-02-26 13:16:56 -05:00
parent 0a54d342ba
commit 88b1391ef0
56 changed files with 9006 additions and 6 deletions

View File

@@ -0,0 +1,39 @@
using Shouldly;
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
namespace ZB.MOM.NatsNet.Server.Tests.Auth.CertificateIdentityProvider;
/// <summary>
/// Tests for the certidp module, mirroring certidp_test.go and ocsp_responder_test.go.
/// </summary>
public sealed class CertificateIdentityProviderTests
{
[Theory]
[InlineData(0, "good")]
[InlineData(1, "revoked")]
[InlineData(2, "unknown")]
[InlineData(42, "unknown")] // Invalid → defaults to unknown (never good)
public void GetStatusAssertionStr_ShouldMapCorrectly(int input, string expected)
{
// Mirror: TestGetStatusAssertionStr
OcspStatusAssertionExtensions.GetStatusAssertionStr(input).ShouldBe(expected);
}
[Fact]
public void EncodeOCSPRequest_ShouldProduceUrlSafeBase64()
{
// Mirror: TestEncodeOCSPRequest
var data = "test data for OCSP request"u8.ToArray();
var encoded = OcspResponder.EncodeOCSPRequest(data);
// Should not contain unescaped base64 chars that are URL-unsafe.
encoded.ShouldNotContain("+");
encoded.ShouldNotContain("/");
encoded.ShouldNotContain("=");
// Should round-trip: URL-unescape → base64-decode → original bytes.
var unescaped = Uri.UnescapeDataString(encoded);
var decoded = Convert.FromBase64String(unescaped);
decoded.ShouldBe(data);
}
}

View File

@@ -0,0 +1,42 @@
using System.Runtime.InteropServices;
using Shouldly;
using ZB.MOM.NatsNet.Server.Auth;
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
public sealed class TpmKeyProviderTests
{
private static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
[Fact]
public void LoadJetStreamEncryptionKeyFromTpm_NonWindows_ThrowsPlatformNotSupportedException()
{
if (IsWindows)
return; // This test is for non-Windows only
var ex = Should.Throw<PlatformNotSupportedException>(() =>
TpmKeyProvider.LoadJetStreamEncryptionKeyFromTpm("", "keys.json", "password", 22));
ex.Message.ShouldContain("TPM");
}
[Fact]
public void LoadJetStreamEncryptionKeyFromTpm_Create_ShouldSucceed()
{
if (!IsWindows)
return; // Requires real TPM hardware on Windows
var tempFile = Path.Combine(Path.GetTempPath(), $"jskeys_{Guid.NewGuid():N}.json");
try
{
if (File.Exists(tempFile)) File.Delete(tempFile);
var key = TpmKeyProvider.LoadJetStreamEncryptionKeyFromTpm("", tempFile, "password", 22);
key.ShouldNotBeNullOrEmpty();
}
finally
{
if (File.Exists(tempFile)) File.Delete(tempFile);
}
}
}

View File

@@ -0,0 +1,80 @@
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
/// <summary>
/// Tests for <see cref="AccessTimeService"/>, mirroring ats_test.go.
/// </summary>
[Collection("AccessTimeService")]
public sealed class AccessTimeServiceTests : IDisposable
{
public AccessTimeServiceTests()
{
AccessTimeService.Reset();
}
public void Dispose()
{
AccessTimeService.Reset();
}
[Fact]
public void NotRunningValue_ShouldReturnNonZero()
{
// Mirror: TestNotRunningValue
// No registrants; AccessTime() must still return a non-zero value.
var at = AccessTimeService.AccessTime();
at.ShouldBeGreaterThan(0);
// Should be stable (no background timer updating it).
var atn = AccessTimeService.AccessTime();
atn.ShouldBe(at);
}
[Fact]
public async Task RegisterAndUnregister_ShouldManageLifetime()
{
// Mirror: TestRegisterAndUnregister
AccessTimeService.Register();
var at = AccessTimeService.AccessTime();
at.ShouldBeGreaterThan(0);
// Background timer should update the time.
await Task.Delay(AccessTimeService.TickInterval * 3);
var atn = AccessTimeService.AccessTime();
atn.ShouldBeGreaterThan(at);
// Unregister; timer should stop.
AccessTimeService.Unregister();
await Task.Delay(AccessTimeService.TickInterval);
at = AccessTimeService.AccessTime();
await Task.Delay(AccessTimeService.TickInterval * 3);
atn = AccessTimeService.AccessTime();
atn.ShouldBe(at);
// Re-register should restart the timer.
AccessTimeService.Register();
try
{
at = AccessTimeService.AccessTime();
await Task.Delay(AccessTimeService.TickInterval * 3);
atn = AccessTimeService.AccessTime();
atn.ShouldBeGreaterThan(at);
}
finally
{
AccessTimeService.Unregister();
}
}
[Fact]
public void UnbalancedUnregister_ShouldThrow()
{
// Mirror: TestUnbalancedUnregister
Should.Throw<InvalidOperationException>(() => AccessTimeService.Unregister());
}
}

View File

@@ -0,0 +1,511 @@
// Copyright 2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server.Tests.Internal.DataStructures;
/// <summary>
/// Ports all 21 tests from Go's gsl/gsl_test.go.
/// </summary>
public sealed class GenericSublistTests
{
// -------------------------------------------------------------------------
// Helpers (mirror Go's require_* functions)
// -------------------------------------------------------------------------
/// <summary>
/// Counts how many values the sublist matches for <paramref name="subject"/>
/// and asserts that count equals <paramref name="expected"/>.
/// Mirrors Go's <c>require_Matches</c>.
/// </summary>
private static void RequireMatches<T>(GenericSublist<T> s, string subject, int expected)
where T : notnull
{
var matches = 0;
s.Match(subject, _ => matches++);
matches.ShouldBe(expected);
}
// -------------------------------------------------------------------------
// TestGenericSublistInit
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistInit()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Count.ShouldBe(0u);
}
// -------------------------------------------------------------------------
// TestGenericSublistInsertCount
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistInsertCount()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("foo", EmptyStruct.Value);
s.Insert("bar", EmptyStruct.Value);
s.Insert("foo.bar", EmptyStruct.Value);
s.Count.ShouldBe(3u);
}
// -------------------------------------------------------------------------
// TestGenericSublistSimple
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistSimple()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("foo", EmptyStruct.Value);
RequireMatches(s, "foo", 1);
}
// -------------------------------------------------------------------------
// TestGenericSublistSimpleMultiTokens
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistSimpleMultiTokens()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("foo.bar.baz", EmptyStruct.Value);
RequireMatches(s, "foo.bar.baz", 1);
}
// -------------------------------------------------------------------------
// TestGenericSublistPartialWildcard
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistPartialWildcard()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("a.b.c", EmptyStruct.Value);
s.Insert("a.*.c", EmptyStruct.Value);
RequireMatches(s, "a.b.c", 2);
}
// -------------------------------------------------------------------------
// TestGenericSublistPartialWildcardAtEnd
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistPartialWildcardAtEnd()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("a.b.c", EmptyStruct.Value);
s.Insert("a.b.*", EmptyStruct.Value);
RequireMatches(s, "a.b.c", 2);
}
// -------------------------------------------------------------------------
// TestGenericSublistFullWildcard
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistFullWildcard()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("a.b.c", EmptyStruct.Value);
s.Insert("a.>", EmptyStruct.Value);
RequireMatches(s, "a.b.c", 2);
RequireMatches(s, "a.>", 1);
}
// -------------------------------------------------------------------------
// TestGenericSublistRemove
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemove()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("a.b.c.d", EmptyStruct.Value);
s.Count.ShouldBe(1u);
RequireMatches(s, "a.b.c.d", 1);
s.Remove("a.b.c.d", EmptyStruct.Value);
s.Count.ShouldBe(0u);
RequireMatches(s, "a.b.c.d", 0);
}
// -------------------------------------------------------------------------
// TestGenericSublistRemoveWildcard
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemoveWildcard()
{
var s = GenericSublist<int>.NewSublist();
s.Insert("a.b.c.d", 11);
s.Insert("a.b.*.d", 22);
s.Insert("a.b.>", 33);
s.Count.ShouldBe(3u);
RequireMatches(s, "a.b.c.d", 3);
s.Remove("a.b.*.d", 22);
s.Count.ShouldBe(2u);
RequireMatches(s, "a.b.c.d", 2);
s.Remove("a.b.>", 33);
s.Count.ShouldBe(1u);
RequireMatches(s, "a.b.c.d", 1);
s.Remove("a.b.c.d", 11);
s.Count.ShouldBe(0u);
RequireMatches(s, "a.b.c.d", 0);
}
// -------------------------------------------------------------------------
// TestGenericSublistRemoveCleanup
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemoveCleanup()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.NumLevels().ShouldBe(0);
s.Insert("a.b.c.d.e.f", EmptyStruct.Value);
s.NumLevels().ShouldBe(6);
s.Remove("a.b.c.d.e.f", EmptyStruct.Value);
s.NumLevels().ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestGenericSublistRemoveCleanupWildcards
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemoveCleanupWildcards()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.NumLevels().ShouldBe(0);
s.Insert("a.b.*.d.e.>", EmptyStruct.Value);
s.NumLevels().ShouldBe(6);
s.Remove("a.b.*.d.e.>", EmptyStruct.Value);
s.NumLevels().ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestGenericSublistInvalidSubjectsInsert
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistInvalidSubjectsInsert()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
// Insert, or subscriptions, can have wildcards, but not empty tokens,
// and can not have a FWC that is not the terminal token.
Should.Throw<ArgumentException>(() => s.Insert(".foo", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Insert("foo.", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Insert("foo..bar", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Insert("foo.bar..baz", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Insert("foo.>.baz", EmptyStruct.Value));
}
// -------------------------------------------------------------------------
// TestGenericSublistBadSubjectOnRemove
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistBadSubjectOnRemove()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
Should.Throw<ArgumentException>(() => s.Insert("a.b..d", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Remove("a.b..d", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Remove("a.>.b", EmptyStruct.Value));
}
// -------------------------------------------------------------------------
// TestGenericSublistTwoTokenPubMatchSingleTokenSub
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistTwoTokenPubMatchSingleTokenSub()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("foo", EmptyStruct.Value);
RequireMatches(s, "foo", 1);
RequireMatches(s, "foo.bar", 0);
}
// -------------------------------------------------------------------------
// TestGenericSublistInsertWithWildcardsAsLiterals
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistInsertWithWildcardsAsLiterals()
{
var s = GenericSublist<int>.NewSublist();
var subjects = new[] { "foo.*-", "foo.>-" };
for (var i = 0; i < subjects.Length; i++)
{
var subject = subjects[i];
s.Insert(subject, i);
RequireMatches(s, "foo.bar", 0);
RequireMatches(s, subject, 1);
}
}
// -------------------------------------------------------------------------
// TestGenericSublistRemoveWithWildcardsAsLiterals
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemoveWithWildcardsAsLiterals()
{
var s = GenericSublist<int>.NewSublist();
var subjects = new[] { "foo.*-", "foo.>-" };
for (var i = 0; i < subjects.Length; i++)
{
var subject = subjects[i];
s.Insert(subject, i);
RequireMatches(s, "foo.bar", 0);
RequireMatches(s, subject, 1);
Should.Throw<KeyNotFoundException>(() => s.Remove("foo.bar", i));
s.Count.ShouldBe(1u);
s.Remove(subject, i);
s.Count.ShouldBe(0u);
}
}
// -------------------------------------------------------------------------
// TestGenericSublistMatchWithEmptyTokens
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistMatchWithEmptyTokens()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert(">", EmptyStruct.Value);
var subjects = new[]
{
".foo", "..foo", "foo..", "foo.", "foo..bar", "foo...bar"
};
foreach (var subject in subjects)
{
RequireMatches(s, subject, 0);
}
}
// -------------------------------------------------------------------------
// TestGenericSublistHasInterest
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistHasInterest()
{
var s = GenericSublist<int>.NewSublist();
s.Insert("foo", 11);
// Expect to find that "foo" matches but "bar" doesn't.
s.HasInterest("foo").ShouldBeTrue();
s.HasInterest("bar").ShouldBeFalse();
// Call Match on a subject we know there is no match.
RequireMatches(s, "bar", 0);
s.HasInterest("bar").ShouldBeFalse();
// Remove fooSub and check interest again.
s.Remove("foo", 11);
s.HasInterest("foo").ShouldBeFalse();
// Try with some wildcards.
s.Insert("foo.*", 22);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.bar.baz").ShouldBeFalse();
// Remove sub, there should be no interest.
s.Remove("foo.*", 22);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeFalse();
s.HasInterest("foo.bar.baz").ShouldBeFalse();
s.Insert("foo.>", 33);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.bar.baz").ShouldBeTrue();
s.Remove("foo.>", 33);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeFalse();
s.HasInterest("foo.bar.baz").ShouldBeFalse();
s.Insert("*.>", 44);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.baz").ShouldBeTrue();
s.Remove("*.>", 44);
s.Insert("*.bar", 55);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.baz").ShouldBeFalse();
s.Remove("*.bar", 55);
s.Insert("*", 66);
s.HasInterest("foo").ShouldBeTrue();
s.HasInterest("foo.bar").ShouldBeFalse();
s.Remove("*", 66);
}
// -------------------------------------------------------------------------
// TestGenericSublistHasInterestOverlapping
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistHasInterestOverlapping()
{
var s = GenericSublist<int>.NewSublist();
s.Insert("stream.A.child", 11);
s.Insert("stream.*", 11);
s.HasInterest("stream.A.child").ShouldBeTrue();
s.HasInterest("stream.A").ShouldBeTrue();
}
// -------------------------------------------------------------------------
// TestGenericSublistHasInterestStartingInRace
// Tests that HasInterestStartingIn is safe to call concurrently with
// modifications to the sublist. Mirrors Go's goroutine test using Tasks.
// -------------------------------------------------------------------------
[Fact]
public async Task TestGenericSublistHasInterestStartingInRace()
{
var s = GenericSublist<int>.NewSublist();
// Pre-populate with some patterns.
for (var i = 0; i < 10; i++)
{
s.Insert("foo.bar.baz", i);
s.Insert("foo.*.baz", i + 10);
s.Insert("foo.>", i + 20);
}
const int iterations = 1000;
// Task 1: repeatedly call HasInterestStartingIn.
var task1 = Task.Run(() =>
{
for (var i = 0; i < iterations; i++)
{
s.HasInterestStartingIn("foo");
s.HasInterestStartingIn("foo.bar");
s.HasInterestStartingIn("foo.bar.baz");
s.HasInterestStartingIn("other.subject");
}
});
// Task 2: repeatedly modify the sublist.
var task2 = Task.Run(() =>
{
for (var i = 0; i < iterations; i++)
{
var val = 1000 + i;
var dynSubject = "test.subject." + (char)('a' + i % 26);
s.Insert(dynSubject, val);
s.Insert("foo.*.test", val);
// Remove may fail if not found (concurrent), so swallow KeyNotFoundException.
try { s.Remove(dynSubject, val); } catch (KeyNotFoundException) { }
try { s.Remove("foo.*.test", val); } catch (KeyNotFoundException) { }
}
});
// Task 3: also call HasInterest (which does lock).
var task3 = Task.Run(() =>
{
for (var i = 0; i < iterations; i++)
{
s.HasInterest("foo.bar.baz");
s.HasInterest("foo.something.baz");
}
});
await Task.WhenAll(task1, task2, task3);
}
// -------------------------------------------------------------------------
// TestGenericSublistNumInterest
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistNumInterest()
{
var s = GenericSublist<int>.NewSublist();
s.Insert("foo", 11);
void RequireNumInterest(string subj, int expected)
{
RequireMatches(s, subj, expected);
s.NumInterest(subj).ShouldBe(expected);
}
// Expect to find that "foo" matches but "bar" doesn't.
RequireNumInterest("foo", 1);
RequireNumInterest("bar", 0);
// Remove fooSub and check interest again.
s.Remove("foo", 11);
RequireNumInterest("foo", 0);
// Try with some wildcards.
s.Insert("foo.*", 22);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 0);
// Remove sub, there should be no interest.
s.Remove("foo.*", 22);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 0);
RequireNumInterest("foo.bar.baz", 0);
s.Insert("foo.>", 33);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 1);
s.Remove("foo.>", 33);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 0);
RequireNumInterest("foo.bar.baz", 0);
s.Insert("*.>", 44);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 1);
s.Remove("*.>", 44);
s.Insert("*.bar", 55);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 0);
s.Remove("*.bar", 55);
s.Insert("*", 66);
RequireNumInterest("foo", 1);
RequireNumInterest("foo.bar", 0);
s.Remove("*", 66);
}
}

View File

@@ -0,0 +1,238 @@
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server.Tests.Internal.DataStructures;
/// <summary>
/// Tests for <see cref="HashWheel"/>, mirroring thw_test.go (functional tests only;
/// benchmarks are omitted as they require BenchmarkDotNet).
/// </summary>
public sealed class HashWheelTests
{
private static readonly long Second = 1_000_000_000L; // nanoseconds
[Fact]
public void HashWheelBasics_ShouldSucceed()
{
// Mirror: TestHashWheelBasics
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
var seq = 1UL;
var expires = now + 5 * Second;
hw.Add(seq, expires);
hw.Count.ShouldBe(1UL);
// Remove non-existent sequence.
Should.Throw<InvalidOperationException>(() => hw.Remove(999, expires));
hw.Count.ShouldBe(1UL);
// Remove properly.
hw.Remove(seq, expires);
hw.Count.ShouldBe(0UL);
// Already gone.
Should.Throw<InvalidOperationException>(() => hw.Remove(seq, expires));
hw.Count.ShouldBe(0UL);
}
[Fact]
public void HashWheelUpdate_ShouldSucceed()
{
// Mirror: TestHashWheelUpdate
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
var oldExpires = now + 5 * Second;
var newExpires = now + 10 * Second;
hw.Add(1, oldExpires);
hw.Count.ShouldBe(1UL);
hw.Update(1, oldExpires, newExpires);
hw.Count.ShouldBe(1UL);
// Old position gone.
Should.Throw<InvalidOperationException>(() => hw.Remove(1, oldExpires));
hw.Count.ShouldBe(1UL);
// New position exists.
hw.Remove(1, newExpires);
hw.Count.ShouldBe(0UL);
}
[Fact]
public void HashWheelExpiration_ShouldExpireOnly_AlreadyExpired()
{
// Mirror: TestHashWheelExpiration
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
var seqs = new Dictionary<ulong, long>
{
[1] = now - 1 * Second, // already expired
[2] = now + 1 * Second,
[3] = now + 10 * Second,
[4] = now + 60 * Second,
};
foreach (var (s, exp) in seqs)
hw.Add(s, exp);
hw.Count.ShouldBe((ulong)seqs.Count);
var expired = new HashSet<ulong>();
hw.ExpireTasksInternal(now, (s, _) => { expired.Add(s); return true; });
expired.Count.ShouldBe(1);
expired.ShouldContain(1UL);
hw.Count.ShouldBe(3UL);
}
[Fact]
public void HashWheelManualExpiration_ShouldRespectCallbackReturn()
{
// Mirror: TestHashWheelManualExpiration
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
for (var s = 1UL; s <= 4; s++)
hw.Add(s, now);
hw.Count.ShouldBe(4UL);
// Iterate without removing.
var expired = new Dictionary<ulong, ulong>();
for (var i = 0UL; i <= 1; i++)
{
hw.ExpireTasksInternal(now, (s, _) => { expired.TryAdd(s, 0); expired[s]++; return false; });
expired.Count.ShouldBe(4);
expired[1].ShouldBe(1 + i);
expired[2].ShouldBe(1 + i);
expired[3].ShouldBe(1 + i);
expired[4].ShouldBe(1 + i);
hw.Count.ShouldBe(4UL);
}
// Remove only even sequences.
for (var i = 0UL; i <= 1; i++)
{
hw.ExpireTasksInternal(now, (s, _) => { expired.TryAdd(s, 0); expired[s]++; return s % 2 == 0; });
expired[1].ShouldBe(3 + i);
expired[2].ShouldBe(3UL);
expired[3].ShouldBe(3 + i);
expired[4].ShouldBe(3UL);
hw.Count.ShouldBe(2UL);
}
// Manually remove remaining.
hw.Remove(1, now);
hw.Remove(3, now);
hw.Count.ShouldBe(0UL);
}
[Fact]
public void HashWheelExpirationLargerThanWheel_ShouldExpireAll()
{
// Mirror: TestHashWheelExpirationLargerThanWheel
const int WheelMask = (1 << 12) - 1;
var hw = HashWheel.NewHashWheel();
hw.Add(1, 0);
hw.Add(2, Second);
hw.Count.ShouldBe(2UL);
// Timestamp large enough to wrap the entire wheel.
var nowWrapped = Second * WheelMask;
var expired = new HashSet<ulong>();
hw.ExpireTasksInternal(nowWrapped, (s, _) => { expired.Add(s); return true; });
expired.Count.ShouldBe(2);
hw.Count.ShouldBe(0UL);
}
[Fact]
public void HashWheelNextExpiration_ShouldReturnEarliest()
{
// Mirror: TestHashWheelNextExpiration
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
var seqs = new Dictionary<ulong, long>
{
[1] = now + 5 * Second,
[2] = now + 3 * Second, // earliest
[3] = now + 10 * Second,
};
foreach (var (s, exp) in seqs)
hw.Add(s, exp);
var tick = now + 6 * Second;
hw.GetNextExpiration(tick).ShouldBe(seqs[2]);
var empty = HashWheel.NewHashWheel();
empty.GetNextExpiration(now + Second).ShouldBe(long.MaxValue);
}
[Fact]
public void HashWheelStress_ShouldHandleLargeScale()
{
// Mirror: TestHashWheelStress
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
const int numSeqs = 100_000;
for (var seq = 0; seq < numSeqs; seq++)
{
var exp = now + (long)seq * Second;
hw.Add((ulong)seq, exp);
}
// Update even sequences.
for (var seq = 0; seq < numSeqs; seq += 2)
{
var oldExp = now + (long)seq * Second;
var newExp = now + (long)(seq + numSeqs) * Second;
hw.Update((ulong)seq, oldExp, newExp);
}
// Remove odd sequences.
for (var seq = 1; seq < numSeqs; seq += 2)
{
var exp = now + (long)seq * Second;
hw.Remove((ulong)seq, exp);
}
}
[Fact]
public void HashWheelEncodeDecode_ShouldRoundTrip()
{
// Mirror: TestHashWheelEncodeDecode
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
const int numSeqs = 100_000;
for (var seq = 0; seq < numSeqs; seq++)
{
var exp = now + (long)seq * Second;
hw.Add((ulong)seq, exp);
}
var b = hw.Encode(12345);
b.Length.ShouldBeGreaterThan(17);
var nhw = HashWheel.NewHashWheel();
var stamp = nhw.Decode(b);
stamp.ShouldBe(12345UL);
// Lowest expiry should match.
hw.GetNextExpiration(long.MaxValue).ShouldBe(nhw.GetNextExpiration(long.MaxValue));
// Verify all entries transferred by removing them from nhw.
for (var seq = 0; seq < numSeqs; seq++)
{
var exp = now + (long)seq * Second;
nhw.Remove((ulong)seq, exp); // throws if missing
}
nhw.Count.ShouldBe(0UL);
}
}

View File

@@ -0,0 +1,948 @@
// Copyright 2023-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server.Tests.Internal.DataStructures;
public class SubjectTreeTests
{
// Helper to convert string to byte array (Latin-1).
private static byte[] B(string s) => System.Text.Encoding.Latin1.GetBytes(s);
// Helper to count matches.
private static int MatchCount(SubjectTree<int> st, string filter)
{
var count = 0;
st.Match(B(filter), (_, _) =>
{
count++;
return true;
});
return count;
}
// -------------------------------------------------------------------------
// TestSubjectTreeBasics
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeBasics()
{
var st = new SubjectTree<int>();
st.Size().ShouldBe(0);
// Single leaf insert.
var (old, updated) = st.Insert(B("foo.bar.baz"), 22);
old.ShouldBe(default);
updated.ShouldBeFalse();
st.Size().ShouldBe(1);
// Find should not work with a wildcard.
var (_, found) = st.Find(B("foo.bar.*"));
found.ShouldBeFalse();
// Find with literal — single leaf.
var (val, found2) = st.Find(B("foo.bar.baz"));
found2.ShouldBeTrue();
val.ShouldBe(22);
// Update single leaf.
var (old2, updated2) = st.Insert(B("foo.bar.baz"), 33);
old2.ShouldBe(22);
updated2.ShouldBeTrue();
st.Size().ShouldBe(1);
// Split the tree.
var (old3, updated3) = st.Insert(B("foo.bar"), 22);
old3.ShouldBe(default);
updated3.ShouldBeFalse();
st.Size().ShouldBe(2);
// Find both entries after split.
var (v1, f1) = st.Find(B("foo.bar"));
f1.ShouldBeTrue();
v1.ShouldBe(22);
var (v2, f2) = st.Find(B("foo.bar.baz"));
f2.ShouldBeTrue();
v2.ShouldBe(33);
}
// -------------------------------------------------------------------------
// TestSubjectTreeConstruction
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeConstruction()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 1);
st.Insert(B("foo.bar.B"), 2);
st.Insert(B("foo.bar.C"), 3);
st.Insert(B("foo.baz.A"), 11);
st.Insert(B("foo.baz.B"), 22);
st.Insert(B("foo.baz.C"), 33);
st.Insert(B("foo.bar"), 42);
// Validate structure.
st._root.ShouldNotBeNull();
st._root!.Kind.ShouldBe("NODE4");
st._root.NumChildren.ShouldBe(2);
// Now delete "foo.bar" and verify structure collapses correctly.
var (v, found) = st.Delete(B("foo.bar"));
found.ShouldBeTrue();
v.ShouldBe(42);
// The remaining entries should still be findable.
var (v1, f1) = st.Find(B("foo.bar.A"));
f1.ShouldBeTrue();
v1.ShouldBe(1);
var (v2, f2) = st.Find(B("foo.bar.B"));
f2.ShouldBeTrue();
v2.ShouldBe(2);
var (v3, f3) = st.Find(B("foo.bar.C"));
f3.ShouldBeTrue();
v3.ShouldBe(3);
var (v4, f4) = st.Find(B("foo.baz.A"));
f4.ShouldBeTrue();
v4.ShouldBe(11);
}
// -------------------------------------------------------------------------
// TestSubjectTreeNodeGrow
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeNodeGrow()
{
var st = new SubjectTree<int>();
// Fill a node4 (4 children).
for (var i = 0; i < 4; i++)
{
var subj = B($"foo.bar.{(char)('A' + i)}");
var (old, upd) = st.Insert(subj, 22);
old.ShouldBe(default);
upd.ShouldBeFalse();
}
st._root.ShouldBeOfType<SubjectTreeNode4<int>>();
// 5th child causes grow to node10.
st.Insert(B("foo.bar.E"), 22);
st._root.ShouldBeOfType<SubjectTreeNode10<int>>();
// Fill to 10.
for (var i = 5; i < 10; i++)
{
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
}
// 11th child causes grow to node16.
st.Insert(B("foo.bar.K"), 22);
st._root.ShouldBeOfType<SubjectTreeNode16<int>>();
// Fill to 16.
for (var i = 11; i < 16; i++)
{
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
}
// 17th child causes grow to node48.
st.Insert(B("foo.bar.Q"), 22);
st._root.ShouldBeOfType<SubjectTreeNode48<int>>();
// Fill the node48.
for (var i = 17; i < 48; i++)
{
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
}
// 49th child causes grow to node256.
var subjLast = B($"foo.bar.{(char)('A' + 49)}");
st.Insert(subjLast, 22);
st._root.ShouldBeOfType<SubjectTreeNode256<int>>();
}
// -------------------------------------------------------------------------
// TestSubjectTreeInsertSamePivot (same pivot bug)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeInsertSamePivot()
{
var testSubjects = new[]
{
B("0d00.2abbb82c1d.6e16.fa7f85470e.3e46"),
B("534b12.3486c17249.4dde0666"),
B("6f26aabd.920ee3.d4d3.5ffc69f6"),
B("8850.ade3b74c31.aa533f77.9f59.a4bd8415.b3ed7b4111"),
B("5a75047dcb.5548e845b6.76024a34.14d5b3.80c426.51db871c3a"),
B("825fa8acfc.5331.00caf8bbbd.107c4b.c291.126d1d010e"),
};
var st = new SubjectTree<int>();
foreach (var subj in testSubjects)
{
var (old, upd) = st.Insert(subj, 22);
old.ShouldBe(default);
upd.ShouldBeFalse();
var (_, found) = st.Find(subj);
found.ShouldBeTrue($"Could not find subject '{System.Text.Encoding.Latin1.GetString(subj)}' after insert");
}
}
// -------------------------------------------------------------------------
// TestSubjectTreeInsertLonger
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeInsertLonger()
{
var st = new SubjectTree<int>();
st.Insert(B("a1.aaaaaaaaaaaaaaaaaaaaaa0"), 1);
st.Insert(B("a2.0"), 2);
st.Insert(B("a1.aaaaaaaaaaaaaaaaaaaaaa1"), 3);
st.Insert(B("a2.1"), 4);
// Simulate purge of a2.>
st.Delete(B("a2.0"));
st.Delete(B("a2.1"));
st.Size().ShouldBe(2);
var (v1, f1) = st.Find(B("a1.aaaaaaaaaaaaaaaaaaaaaa0"));
f1.ShouldBeTrue();
v1.ShouldBe(1);
var (v2, f2) = st.Find(B("a1.aaaaaaaaaaaaaaaaaaaaaa1"));
f2.ShouldBeTrue();
v2.ShouldBe(3);
}
// -------------------------------------------------------------------------
// TestInsertEdgeCases
// -------------------------------------------------------------------------
[Fact]
public void TestInsertEdgeCases()
{
var st = new SubjectTree<int>();
// Reject subject with noPivot byte (127).
var (old, upd) = st.Insert(new byte[] { (byte)'f', (byte)'o', (byte)'o', 127 }, 1);
old.ShouldBe(default);
upd.ShouldBeFalse();
st.Size().ShouldBe(0);
// Empty-ish subjects.
st.Insert(B("a"), 1);
st.Insert(B("b"), 2);
st.Size().ShouldBe(2);
}
// -------------------------------------------------------------------------
// TestFindEdgeCases
// -------------------------------------------------------------------------
[Fact]
public void TestFindEdgeCases()
{
var st = new SubjectTree<int>();
var (_, found) = st.Find(B("anything"));
found.ShouldBeFalse();
st.Insert(B("foo"), 42);
var (v, f) = st.Find(B("foo"));
f.ShouldBeTrue();
v.ShouldBe(42);
var (_, f2) = st.Find(B("fo"));
f2.ShouldBeFalse();
var (_, f3) = st.Find(B("foobar"));
f3.ShouldBeFalse();
}
// -------------------------------------------------------------------------
// TestSubjectTreeNodeDelete
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeNodeDelete()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 22);
var (v, found) = st.Delete(B("foo.bar.A"));
found.ShouldBeTrue();
v.ShouldBe(22);
st._root.ShouldBeNull();
// Delete non-existent.
var (v2, found2) = st.Delete(B("foo.bar.A"));
found2.ShouldBeFalse();
v2.ShouldBe(default);
// Fill to node4 then shrink back through deletes.
st.Insert(B("foo.bar.A"), 11);
st.Insert(B("foo.bar.B"), 22);
st.Insert(B("foo.bar.C"), 33);
var (vC, fC) = st.Delete(B("foo.bar.C"));
fC.ShouldBeTrue();
vC.ShouldBe(33);
var (vB, fB) = st.Delete(B("foo.bar.B"));
fB.ShouldBeTrue();
vB.ShouldBe(22);
// Should have shrunk to a leaf.
st._root.ShouldNotBeNull();
st._root!.IsLeaf.ShouldBeTrue();
var (vA, fA) = st.Delete(B("foo.bar.A"));
fA.ShouldBeTrue();
vA.ShouldBe(11);
st._root.ShouldBeNull();
// Pop up to node10 and shrink back.
for (var i = 0; i < 5; i++)
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
st._root.ShouldBeOfType<SubjectTreeNode10<int>>();
var (vDel, fDel) = st.Delete(B("foo.bar.A"));
fDel.ShouldBeTrue();
vDel.ShouldBe(22);
st._root.ShouldBeOfType<SubjectTreeNode4<int>>();
// Pop up to node16 and shrink back.
for (var i = 0; i < 11; i++)
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
st._root.ShouldBeOfType<SubjectTreeNode16<int>>();
var (vDel2, fDel2) = st.Delete(B("foo.bar.A"));
fDel2.ShouldBeTrue();
vDel2.ShouldBe(22);
st._root.ShouldBeOfType<SubjectTreeNode10<int>>();
// Pop up to node48 and shrink back.
st = new SubjectTree<int>();
for (var i = 0; i < 17; i++)
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
st._root.ShouldBeOfType<SubjectTreeNode48<int>>();
var (vDel3, fDel3) = st.Delete(B("foo.bar.A"));
fDel3.ShouldBeTrue();
vDel3.ShouldBe(22);
st._root.ShouldBeOfType<SubjectTreeNode16<int>>();
// Pop up to node256 and shrink back.
st = new SubjectTree<int>();
for (var i = 0; i < 49; i++)
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
st._root.ShouldBeOfType<SubjectTreeNode256<int>>();
var (vDel4, fDel4) = st.Delete(B("foo.bar.A"));
fDel4.ShouldBeTrue();
vDel4.ShouldBe(22);
st._root.ShouldBeOfType<SubjectTreeNode48<int>>();
}
// -------------------------------------------------------------------------
// TestDeleteEdgeCases
// -------------------------------------------------------------------------
[Fact]
public void TestDeleteEdgeCases()
{
var st = new SubjectTree<int>();
// Delete from empty tree.
var (v, f) = st.Delete(B("foo"));
f.ShouldBeFalse();
v.ShouldBe(default);
// Insert and delete the only item.
st.Insert(B("foo"), 1);
var (v2, f2) = st.Delete(B("foo"));
f2.ShouldBeTrue();
v2.ShouldBe(1);
st.Size().ShouldBe(0);
st._root.ShouldBeNull();
// Delete a non-existent item in a non-empty tree.
st.Insert(B("bar"), 2);
var (v3, f3) = st.Delete(B("baz"));
f3.ShouldBeFalse();
v3.ShouldBe(default);
st.Size().ShouldBe(1);
}
// -------------------------------------------------------------------------
// TestSubjectTreeMatchLeafOnly
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeMatchLeafOnly()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.baz.A"), 1);
// All positions of pwc.
MatchCount(st, "foo.bar.*.A").ShouldBe(1);
MatchCount(st, "foo.*.baz.A").ShouldBe(1);
MatchCount(st, "foo.*.*.A").ShouldBe(1);
MatchCount(st, "foo.*.*.*").ShouldBe(1);
MatchCount(st, "*.*.*.*").ShouldBe(1);
// fwc tests.
MatchCount(st, ">").ShouldBe(1);
MatchCount(st, "foo.>").ShouldBe(1);
MatchCount(st, "foo.*.>").ShouldBe(1);
MatchCount(st, "foo.bar.>").ShouldBe(1);
MatchCount(st, "foo.bar.*.>").ShouldBe(1);
// Partial match should not trigger.
MatchCount(st, "foo.bar.baz").ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestSubjectTreeMatchNodes
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeMatchNodes()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 1);
st.Insert(B("foo.bar.B"), 2);
st.Insert(B("foo.bar.C"), 3);
st.Insert(B("foo.baz.A"), 11);
st.Insert(B("foo.baz.B"), 22);
st.Insert(B("foo.baz.C"), 33);
// Literals.
MatchCount(st, "foo.bar.A").ShouldBe(1);
MatchCount(st, "foo.baz.A").ShouldBe(1);
MatchCount(st, "foo.bar").ShouldBe(0);
// Internal pwc.
MatchCount(st, "foo.*.A").ShouldBe(2);
// Terminal pwc.
MatchCount(st, "foo.bar.*").ShouldBe(3);
MatchCount(st, "foo.baz.*").ShouldBe(3);
// fwc.
MatchCount(st, ">").ShouldBe(6);
MatchCount(st, "foo.>").ShouldBe(6);
MatchCount(st, "foo.bar.>").ShouldBe(3);
MatchCount(st, "foo.baz.>").ShouldBe(3);
// No false positives on prefix.
MatchCount(st, "foo.ba").ShouldBe(0);
// Add "foo.bar" and re-test.
st.Insert(B("foo.bar"), 42);
MatchCount(st, "foo.bar.A").ShouldBe(1);
MatchCount(st, "foo.bar").ShouldBe(1);
MatchCount(st, "foo.*.A").ShouldBe(2);
MatchCount(st, "foo.bar.*").ShouldBe(3);
MatchCount(st, ">").ShouldBe(7);
MatchCount(st, "foo.>").ShouldBe(7);
MatchCount(st, "foo.bar.>").ShouldBe(3);
MatchCount(st, "foo.baz.>").ShouldBe(3);
}
// -------------------------------------------------------------------------
// TestSubjectTreePartialTermination (partial termination)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreePartialTermination()
{
var st = new SubjectTree<int>();
st.Insert(B("STATE.GLOBAL.CELL1.7PDSGAALXNN000010.PROPERTY-A"), 5);
st.Insert(B("STATE.GLOBAL.CELL1.7PDSGAALXNN000010.PROPERTY-B"), 1);
st.Insert(B("STATE.GLOBAL.CELL1.7PDSGAALXNN000010.PROPERTY-C"), 2);
MatchCount(st, "STATE.GLOBAL.CELL1.7PDSGAALXNN000010.*").ShouldBe(3);
}
// -------------------------------------------------------------------------
// TestSubjectTreeMatchMultiple
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeMatchMultiple()
{
var st = new SubjectTree<int>();
st.Insert(B("A.B.C.D.0.G.H.I.0"), 22);
st.Insert(B("A.B.C.D.1.G.H.I.0"), 22);
MatchCount(st, "A.B.*.D.1.*.*.I.0").ShouldBe(1);
}
// -------------------------------------------------------------------------
// TestSubjectTreeMatchSubject (verify correct subject bytes in callback)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeMatchSubject()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 1);
st.Insert(B("foo.bar.B"), 2);
st.Insert(B("foo.bar.C"), 3);
st.Insert(B("foo.baz.A"), 11);
st.Insert(B("foo.baz.B"), 22);
st.Insert(B("foo.baz.C"), 33);
st.Insert(B("foo.bar"), 42);
var checkValMap = new Dictionary<string, int>
{
["foo.bar.A"] = 1,
["foo.bar.B"] = 2,
["foo.bar.C"] = 3,
["foo.baz.A"] = 11,
["foo.baz.B"] = 22,
["foo.baz.C"] = 33,
["foo.bar"] = 42,
};
st.Match(B(">"), (subject, val) =>
{
var subjectStr = System.Text.Encoding.Latin1.GetString(subject);
checkValMap.ShouldContainKey(subjectStr);
val.ShouldBe(checkValMap[subjectStr]);
return true;
});
}
// -------------------------------------------------------------------------
// TestMatchEdgeCases
// -------------------------------------------------------------------------
[Fact]
public void TestMatchEdgeCases()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.123"), 22);
st.Insert(B("one.two.three.four.five"), 22);
// Basic fwc.
MatchCount(st, ">").ShouldBe(2);
// No matches.
MatchCount(st, "invalid.>").ShouldBe(0);
// fwc after content is not terminal — should not match.
MatchCount(st, "foo.>.bar").ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestSubjectTreeIterOrdered
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeIterOrdered()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 1);
st.Insert(B("foo.bar.B"), 2);
st.Insert(B("foo.bar.C"), 3);
st.Insert(B("foo.baz.A"), 11);
st.Insert(B("foo.baz.B"), 22);
st.Insert(B("foo.baz.C"), 33);
st.Insert(B("foo.bar"), 42);
var checkValMap = new Dictionary<string, int>
{
["foo.bar"] = 42,
["foo.bar.A"] = 1,
["foo.bar.B"] = 2,
["foo.bar.C"] = 3,
["foo.baz.A"] = 11,
["foo.baz.B"] = 22,
["foo.baz.C"] = 33,
};
var checkOrder = new[]
{
"foo.bar",
"foo.bar.A",
"foo.bar.B",
"foo.bar.C",
"foo.baz.A",
"foo.baz.B",
"foo.baz.C",
};
var received = new List<string>();
st.IterOrdered((subject, val) =>
{
var subjectStr = System.Text.Encoding.Latin1.GetString(subject);
received.Add(subjectStr);
val.ShouldBe(checkValMap[subjectStr]);
return true;
});
received.Count.ShouldBe(checkOrder.Length);
for (var i = 0; i < checkOrder.Length; i++)
received[i].ShouldBe(checkOrder[i]);
// Make sure we can terminate early.
var count = 0;
st.IterOrdered((_, _) =>
{
count++;
return count != 4;
});
count.ShouldBe(4);
}
// -------------------------------------------------------------------------
// TestSubjectTreeIterFast
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeIterFast()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 1);
st.Insert(B("foo.bar.B"), 2);
st.Insert(B("foo.bar.C"), 3);
st.Insert(B("foo.baz.A"), 11);
st.Insert(B("foo.baz.B"), 22);
st.Insert(B("foo.baz.C"), 33);
st.Insert(B("foo.bar"), 42);
var checkValMap = new Dictionary<string, int>
{
["foo.bar.A"] = 1,
["foo.bar.B"] = 2,
["foo.bar.C"] = 3,
["foo.baz.A"] = 11,
["foo.baz.B"] = 22,
["foo.baz.C"] = 33,
["foo.bar"] = 42,
};
var received = 0;
st.IterFast((subject, val) =>
{
received++;
var subjectStr = System.Text.Encoding.Latin1.GetString(subject);
checkValMap.ShouldContainKey(subjectStr);
val.ShouldBe(checkValMap[subjectStr]);
return true;
});
received.ShouldBe(checkValMap.Count);
// Early termination.
received = 0;
st.IterFast((_, _) =>
{
received++;
return received != 4;
});
received.ShouldBe(4);
}
// -------------------------------------------------------------------------
// TestSubjectTreeEmpty
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeEmpty()
{
var st = new SubjectTree<int>();
st.Empty().ShouldBeTrue();
st.Insert(B("foo"), 1);
st.Empty().ShouldBeFalse();
st.Delete(B("foo"));
st.Empty().ShouldBeTrue();
}
// -------------------------------------------------------------------------
// TestSizeOnEmptyTree
// -------------------------------------------------------------------------
[Fact]
public void TestSizeOnEmptyTree()
{
var st = new SubjectTree<int>();
st.Size().ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestSubjectTreeNilNoPanic (nil/null safety)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeNullNoPanic()
{
var st = new SubjectTree<int>();
// Operations on empty tree should not throw.
st.Size().ShouldBe(0);
st.Empty().ShouldBeTrue();
var (_, f1) = st.Find(B("foo"));
f1.ShouldBeFalse();
var (_, f2) = st.Delete(B("foo"));
f2.ShouldBeFalse();
// Match on empty tree.
var count = 0;
st.Match(B(">"), (_, _) => { count++; return true; });
count.ShouldBe(0);
// MatchUntil on empty tree.
var completed = st.MatchUntil(B(">"), (_, _) => { count++; return true; });
completed.ShouldBeTrue();
// Iter on empty tree.
st.IterOrdered((_, _) => { count++; return true; });
st.IterFast((_, _) => { count++; return true; });
count.ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestSubjectTreeMatchUntil
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeMatchUntil()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 1);
st.Insert(B("foo.bar.B"), 2);
st.Insert(B("foo.bar.C"), 3);
st.Insert(B("foo.baz.A"), 11);
st.Insert(B("foo.baz.B"), 22);
st.Insert(B("foo.baz.C"), 33);
st.Insert(B("foo.bar"), 42);
// Early stop terminates traversal.
var n = 0;
var completed = st.MatchUntil(B("foo.>"), (_, _) =>
{
n++;
return n < 3;
});
n.ShouldBe(3);
completed.ShouldBeFalse();
// Match that completes normally.
n = 0;
completed = st.MatchUntil(B("foo.bar"), (_, _) =>
{
n++;
return true;
});
n.ShouldBe(1);
completed.ShouldBeTrue();
// Stop after 4 (more than available in "foo.baz.*").
n = 0;
completed = st.MatchUntil(B("foo.baz.*"), (_, _) =>
{
n++;
return n < 4;
});
n.ShouldBe(3);
completed.ShouldBeTrue();
}
// -------------------------------------------------------------------------
// TestSubjectTreeGSLIntersect (basic lazy intersect equivalent)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeLazyIntersect()
{
// Build two trees and verify that inserting matching keys from both yields correct count.
var tl = new SubjectTree<int>();
var tr = new SubjectTree<int>();
tl.Insert(B("foo.bar"), 1);
tl.Insert(B("foo.baz"), 2);
tl.Insert(B("other"), 3);
tr.Insert(B("foo.bar"), 10);
tr.Insert(B("foo.baz"), 20);
// Manually intersect: iterate smaller tree, find in larger.
var matches = new List<(string key, int vl, int vr)>();
tl.IterFast((key, vl) =>
{
var (vr, found) = tr.Find(key);
if (found)
matches.Add((System.Text.Encoding.Latin1.GetString(key), vl, vr));
return true;
});
matches.Count.ShouldBe(2);
}
// -------------------------------------------------------------------------
// TestSubjectTreePrefixMismatch
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreePrefixMismatch()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 11);
st.Insert(B("foo.bar.B"), 22);
st.Insert(B("foo.bar.C"), 33);
// This will force a split.
st.Insert(B("foo.foo.A"), 44);
var (v1, f1) = st.Find(B("foo.bar.A"));
f1.ShouldBeTrue();
v1.ShouldBe(11);
var (v2, f2) = st.Find(B("foo.bar.B"));
f2.ShouldBeTrue();
v2.ShouldBe(22);
var (v3, f3) = st.Find(B("foo.bar.C"));
f3.ShouldBeTrue();
v3.ShouldBe(33);
var (v4, f4) = st.Find(B("foo.foo.A"));
f4.ShouldBeTrue();
v4.ShouldBe(44);
}
// -------------------------------------------------------------------------
// TestSubjectTreeNodesAndPaths
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeNodesAndPaths()
{
var st = new SubjectTree<int>();
void Check(string subj)
{
var (val, found) = st.Find(B(subj));
found.ShouldBeTrue();
val.ShouldBe(22);
}
st.Insert(B("foo.bar.A"), 22);
st.Insert(B("foo.bar.B"), 22);
st.Insert(B("foo.bar.C"), 22);
st.Insert(B("foo.bar"), 22);
Check("foo.bar.A");
Check("foo.bar.B");
Check("foo.bar.C");
Check("foo.bar");
// Deletion that involves shrinking / prefix adjustment.
st.Delete(B("foo.bar"));
Check("foo.bar.A");
Check("foo.bar.B");
Check("foo.bar.C");
}
// -------------------------------------------------------------------------
// TestSubjectTreeRandomTrack (basic random insert/find)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeRandomTrack()
{
var st = new SubjectTree<int>();
var tracked = new Dictionary<string, bool>();
var rng = new Random(42);
for (var i = 0; i < 200; i++)
{
var tokens = rng.Next(1, 5);
var parts = new List<string>();
for (var t = 0; t < tokens; t++)
{
var len = rng.Next(2, 7);
var chars = new char[len];
for (var c = 0; c < len; c++)
chars[c] = (char)('a' + rng.Next(26));
parts.Add(new string(chars));
}
var subj = string.Join(".", parts);
if (tracked.ContainsKey(subj)) continue;
tracked[subj] = true;
st.Insert(B(subj), 1);
}
foreach (var subj in tracked.Keys)
{
var (_, found) = st.Find(B(subj));
found.ShouldBeTrue($"Subject '{subj}' not found after insert");
}
st.Size().ShouldBe(tracked.Count);
}
// -------------------------------------------------------------------------
// TestSubjectTreeNode48 (detailed node48 operations)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeNode48Operations()
{
var st = new SubjectTree<int>();
// Insert 26 single-char subjects (no prefix — goes directly to node48).
for (var i = 0; i < 26; i++)
st.Insert(new[] { (byte)('A' + i) }, 22);
st._root.ShouldBeOfType<SubjectTreeNode48<int>>();
st._root!.NumChildren.ShouldBe(26);
st.Delete(new[] { (byte)'B' });
st._root.NumChildren.ShouldBe(25);
st.Delete(new[] { (byte)'Z' });
st._root.NumChildren.ShouldBe(24);
// Remaining subjects should still be findable.
for (var i = 0; i < 26; i++)
{
var ch = (byte)('A' + i);
if (ch == (byte)'B' || ch == (byte)'Z') continue;
var (_, found) = st.Find(new[] { ch });
found.ShouldBeTrue();
}
}
// -------------------------------------------------------------------------
// TestSubjectTreeMatchTsepSecondThenPartial (bug regression)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeMatchTsepSecondThenPartial()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.xxxxx.foo1234.zz"), 22);
st.Insert(B("foo.yyy.foo123.zz"), 22);
st.Insert(B("foo.yyybar789.zz"), 22);
st.Insert(B("foo.yyy.foo12345.zz"), 22);
st.Insert(B("foo.yyy.foo12345.yy"), 22);
st.Insert(B("foo.yyy.foo123456789.zz"), 22);
MatchCount(st, "foo.*.foo123456789.*").ShouldBe(1);
MatchCount(st, "foo.*.*.zzz.foo.>").ShouldBe(0);
}
}

View File

@@ -0,0 +1,56 @@
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
/// <summary>
/// Tests for <see cref="ProcessStatsProvider"/>, mirroring pse_test.go.
/// The Go tests compare against `ps` command output — the .NET tests verify
/// that values are within reasonable bounds since Process gives us the same data
/// through a managed API without needing external command comparison.
/// </summary>
public sealed class ProcessStatsProviderTests
{
[Fact]
public async Task PSEmulationCPU_ShouldReturnReasonableValue()
{
// Mirror: TestPSEmulationCPU
// Allow one sampling cycle to complete.
await Task.Delay(TimeSpan.FromSeconds(2));
ProcessStatsProvider.ProcUsage(out var pcpu, out _, out _);
// CPU % should be non-negative and at most 100% × processor count.
pcpu.ShouldBeGreaterThanOrEqualTo(0);
pcpu.ShouldBeLessThanOrEqualTo(100.0 * Environment.ProcessorCount);
}
[Fact]
public void PSEmulationMem_ShouldReturnReasonableValue()
{
// Mirror: TestPSEmulationMem
ProcessStatsProvider.ProcUsage(out _, out var rss, out var vss);
// RSS should be at least 1 MB (any .NET process uses far more).
rss.ShouldBeGreaterThan(1024L * 1024L);
// VSS should be at least as large as RSS.
vss.ShouldBeGreaterThanOrEqualTo(rss);
}
[Fact]
public async Task PSEmulationWin_ShouldCacheAndRefresh()
{
// Mirror: TestPSEmulationWin (caching behaviour validation)
ProcessStatsProvider.ProcUsage(out _, out var rss1, out _);
ProcessStatsProvider.ProcUsage(out _, out var rss2, out _);
// Two immediate calls should return the same cached value.
rss1.ShouldBe(rss2);
// After a sampling interval, values should still be valid.
await Task.Delay(TimeSpan.FromSeconds(2));
ProcessStatsProvider.ProcUsage(out _, out var rssAfter, out _);
rssAfter.ShouldBeGreaterThan(0);
}
}

View File

@@ -0,0 +1,798 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using System.Text;
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Protocol;
namespace ZB.MOM.NatsNet.Server.Tests.Protocol;
/// <summary>
/// Tests for the NATS protocol parser.
/// Mirrors Go parser_test.go — 17 test functions.
/// </summary>
public class ProtocolParserTests
{
// =====================================================================
// Test helpers — mirrors Go dummyClient/dummyRouteClient
// =====================================================================
private static ParseContext DummyClient() => new()
{
Kind = ClientKind.Client,
MaxControlLine = ServerConstants.MaxControlLineSize,
MaxPayload = -1,
HasHeaders = false,
};
private static ParseContext DummyRouteClient() => new()
{
Kind = ClientKind.Router,
MaxControlLine = ServerConstants.MaxControlLineSize,
MaxPayload = -1,
};
private static TestProtocolHandler DummyHandler() => new();
// =====================================================================
// TestParsePing — Go test ID 2598
// =====================================================================
[Fact]
public void ParsePing_ByteByByte()
{
var c = DummyClient();
var h = DummyHandler();
c.State.ShouldBe(ParserState.OpStart);
var ping = "PING\r\n"u8.ToArray();
ProtocolParser.Parse(c, h, ping[..1]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpP);
ProtocolParser.Parse(c, h, ping[1..2]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPi);
ProtocolParser.Parse(c, h, ping[2..3]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPin);
ProtocolParser.Parse(c, h, ping[3..4]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPing);
ProtocolParser.Parse(c, h, ping[4..5]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPing);
ProtocolParser.Parse(c, h, ping[5..6]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
h.PingCount.ShouldBe(1);
// Full message
ProtocolParser.Parse(c, h, ping).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
h.PingCount.ShouldBe(2);
// Should tolerate spaces
var pingSpaces = "PING \r"u8.ToArray();
ProtocolParser.Parse(c, h, pingSpaces).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPing);
c.State = ParserState.OpStart;
var pingSpaces2 = "PING \r \n"u8.ToArray();
ProtocolParser.Parse(c, h, pingSpaces2).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
}
// =====================================================================
// TestParsePong — Go test ID 2599
// =====================================================================
[Fact]
public void ParsePong_ByteByByte()
{
var c = DummyClient();
var h = DummyHandler();
c.State.ShouldBe(ParserState.OpStart);
var pong = "PONG\r\n"u8.ToArray();
ProtocolParser.Parse(c, h, pong[..1]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpP);
ProtocolParser.Parse(c, h, pong[1..2]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPo);
ProtocolParser.Parse(c, h, pong[2..3]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPon);
ProtocolParser.Parse(c, h, pong[3..4]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPong);
ProtocolParser.Parse(c, h, pong[4..5]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPong);
ProtocolParser.Parse(c, h, pong[5..6]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
h.PongCount.ShouldBe(1);
// Full message
ProtocolParser.Parse(c, h, pong).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
h.PongCount.ShouldBe(2);
// Should tolerate spaces
var pongSpaces = "PONG \r"u8.ToArray();
ProtocolParser.Parse(c, h, pongSpaces).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPong);
c.State = ParserState.OpStart;
var pongSpaces2 = "PONG \r \n"u8.ToArray();
ProtocolParser.Parse(c, h, pongSpaces2).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
}
// =====================================================================
// TestParseConnect — Go test ID 2600
// =====================================================================
[Fact]
public void ParseConnect_ParsesCorrectly()
{
var c = DummyClient();
var h = DummyHandler();
var connect = Encoding.ASCII.GetBytes(
"CONNECT {\"verbose\":false,\"pedantic\":true,\"tls_required\":false}\r\n");
ProtocolParser.Parse(c, h, connect).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
h.ConnectArgs.ShouldNotBeNull();
// Check saved state: arg start should be 8 (after "CONNECT ")
c.ArgStart.ShouldBe(connect.Length); // After full parse, ArgStart is past the end
}
// =====================================================================
// TestParseSub — Go test ID 2601
// =====================================================================
[Fact]
public void ParseSub_SetsState()
{
var c = DummyClient();
var h = DummyHandler();
var sub = "SUB foo 1\r"u8.ToArray();
ProtocolParser.Parse(c, h, sub).ShouldBeNull();
c.State.ShouldBe(ParserState.SubArg);
// The arg buffer should have been set up for split buffer
c.ArgBuf.ShouldNotBeNull();
Encoding.ASCII.GetString(c.ArgBuf!).ShouldBe("foo 1");
}
// =====================================================================
// TestParsePub — Go test ID 2602
// =====================================================================
[Fact]
public void ParsePub_ParsesSubjectReplySize()
{
var c = DummyClient();
var h = DummyHandler();
// Simple PUB
var pub = "PUB foo 5\r\nhello\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo");
c.Pa.Reply.ShouldBeNull();
c.Pa.Size.ShouldBe(5);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// PUB with reply
pub = "PUB foo.bar INBOX.22 11\r\nhello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("INBOX.22");
c.Pa.Size.ShouldBe(11);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// Data larger than expected size
pub = "PUB foo.bar 11\r\nhello world hello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldNotBeNull();
c.MsgBuf.ShouldBeNull();
}
// =====================================================================
// TestParsePubSizeOverflow — Go test ID 2603
// =====================================================================
[Fact]
public void ParsePubSizeOverflow_ReturnsError()
{
var c = DummyClient();
var h = DummyHandler();
var pub = Encoding.ASCII.GetBytes(
"PUB foo 3333333333333333333333333333333333333333333333333333333333333333\r\n");
ProtocolParser.Parse(c, h, pub).ShouldNotBeNull();
}
// =====================================================================
// TestParsePubArg — Go test ID 2604
// =====================================================================
[Theory]
[MemberData(nameof(PubArgTestCases))]
public void ProcessPub_ParsesArgsCorrectly(string arg, string subject, string reply, int size, string szb)
{
var c = DummyClient();
var err = ProtocolParser.ProcessPub(c, Encoding.ASCII.GetBytes(arg));
err.ShouldBeNull();
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe(subject);
if (string.IsNullOrEmpty(reply))
c.Pa.Reply.ShouldBeNull();
else
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe(reply);
Encoding.ASCII.GetString(c.Pa.SizeBytes!).ShouldBe(szb);
c.Pa.Size.ShouldBe(size);
}
public static TheoryData<string, string, string, int, string> PubArgTestCases => new()
{
{ "a 2", "a", "", 2, "2" },
{ "a 222", "a", "", 222, "222" },
{ "foo 22", "foo", "", 22, "22" },
{ " foo 22", "foo", "", 22, "22" },
{ "foo 22 ", "foo", "", 22, "22" },
{ "foo 22", "foo", "", 22, "22" },
{ " foo 22 ", "foo", "", 22, "22" },
{ " foo 22 ", "foo", "", 22, "22" },
{ "foo bar 22", "foo", "bar", 22, "22" },
{ " foo bar 22", "foo", "bar", 22, "22" },
{ "foo bar 22 ", "foo", "bar", 22, "22" },
{ "foo bar 22", "foo", "bar", 22, "22" },
{ " foo bar 22 ", "foo", "bar", 22, "22" },
{ " foo bar 22 ", "foo", "bar", 22, "22" },
{ " foo bar 2222 ", "foo", "bar", 2222, "2222" },
{ " foo 2222 ", "foo", "", 2222, "2222" },
{ "a\t2", "a", "", 2, "2" },
{ "a\t222", "a", "", 222, "222" },
{ "foo\t22", "foo", "", 22, "22" },
{ "\tfoo\t22", "foo", "", 22, "22" },
{ "foo\t22\t", "foo", "", 22, "22" },
{ "foo\t\t\t22", "foo", "", 22, "22" },
{ "\tfoo\t22\t", "foo", "", 22, "22" },
{ "\tfoo\t\t\t22\t", "foo", "", 22, "22" },
{ "foo\tbar\t22", "foo", "bar", 22, "22" },
{ "\tfoo\tbar\t22", "foo", "bar", 22, "22" },
{ "foo\tbar\t22\t", "foo", "bar", 22, "22" },
{ "foo\t\tbar\t\t22", "foo", "bar", 22, "22" },
{ "\tfoo\tbar\t22\t", "foo", "bar", 22, "22" },
{ "\t \tfoo\t \t \tbar\t \t22\t \t", "foo", "bar", 22, "22" },
{ "\t\tfoo\t\t\tbar\t\t2222\t\t", "foo", "bar", 2222, "2222" },
{ "\t \tfoo\t \t \t\t\t2222\t \t", "foo", "", 2222, "2222" },
};
// =====================================================================
// TestParsePubBadSize — Go test ID 2605
// =====================================================================
[Fact]
public void ProcessPub_BadSize_ReturnsError()
{
var c = DummyClient();
c.MaxPayload = 32768;
var err = ProtocolParser.ProcessPub(c, "foo 2222222222222222"u8.ToArray());
err.ShouldNotBeNull();
}
// =====================================================================
// TestParseHeaderPub — Go test ID 2606
// =====================================================================
[Fact]
public void ParseHeaderPub_ParsesSubjectReplyHdrSize()
{
var c = DummyClient();
c.HasHeaders = true;
var h = DummyHandler();
// Simple HPUB
var hpub = "HPUB foo 12 17\r\nname:derek\r\nHELLO\r"u8.ToArray();
ProtocolParser.Parse(c, h, hpub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo");
c.Pa.Reply.ShouldBeNull();
c.Pa.HeaderSize.ShouldBe(12);
Encoding.ASCII.GetString(c.Pa.HeaderBytes!).ShouldBe("12");
c.Pa.Size.ShouldBe(17);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// HPUB with reply
hpub = "HPUB foo INBOX.22 12 17\r\nname:derek\r\nHELLO\r"u8.ToArray();
ProtocolParser.Parse(c, h, hpub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("INBOX.22");
c.Pa.HeaderSize.ShouldBe(12);
Encoding.ASCII.GetString(c.Pa.HeaderBytes!).ShouldBe("12");
c.Pa.Size.ShouldBe(17);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// HPUB with hdr=0
hpub = "HPUB foo INBOX.22 0 5\r\nHELLO\r"u8.ToArray();
ProtocolParser.Parse(c, h, hpub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("INBOX.22");
c.Pa.HeaderSize.ShouldBe(0);
Encoding.ASCII.GetString(c.Pa.HeaderBytes!).ShouldBe("0");
c.Pa.Size.ShouldBe(5);
}
// =====================================================================
// TestParseHeaderPubArg — Go test ID 2607
// =====================================================================
[Theory]
[MemberData(nameof(HeaderPubArgTestCases))]
public void ProcessHeaderPub_ParsesArgsCorrectly(
string arg, string subject, string reply, int hdr, int size, string szb)
{
var c = DummyClient();
c.HasHeaders = true;
var err = ProtocolParser.ProcessHeaderPub(c, Encoding.ASCII.GetBytes(arg), null);
err.ShouldBeNull();
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe(subject);
if (string.IsNullOrEmpty(reply))
c.Pa.Reply.ShouldBeNull();
else
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe(reply);
Encoding.ASCII.GetString(c.Pa.SizeBytes!).ShouldBe(szb);
c.Pa.HeaderSize.ShouldBe(hdr);
c.Pa.Size.ShouldBe(size);
}
public static TheoryData<string, string, string, int, int, string> HeaderPubArgTestCases => new()
{
{ "a 2 4", "a", "", 2, 4, "4" },
{ "a 22 222", "a", "", 22, 222, "222" },
{ "foo 3 22", "foo", "", 3, 22, "22" },
{ " foo 1 22", "foo", "", 1, 22, "22" },
{ "foo 0 22 ", "foo", "", 0, 22, "22" },
{ "foo 0 22", "foo", "", 0, 22, "22" },
{ " foo 1 22 ", "foo", "", 1, 22, "22" },
{ " foo 3 22 ", "foo", "", 3, 22, "22" },
{ "foo bar 1 22", "foo", "bar", 1, 22, "22" },
{ " foo bar 11 22", "foo", "bar", 11, 22, "22" },
{ "foo bar 11 22 ", "foo", "bar", 11, 22, "22" },
{ "foo bar 11 22", "foo", "bar", 11, 22, "22" },
{ " foo bar 11 22 ", "foo", "bar", 11, 22, "22" },
{ " foo bar 11 22 ", "foo", "bar", 11, 22, "22" },
{ " foo bar 22 2222 ", "foo", "bar", 22, 2222, "2222" },
{ " foo 1 2222 ", "foo", "", 1, 2222, "2222" },
{ "a\t2\t22", "a", "", 2, 22, "22" },
{ "a\t2\t\t222", "a", "", 2, 222, "222" },
{ "foo\t2 22", "foo", "", 2, 22, "22" },
{ "\tfoo\t11\t 22", "foo", "", 11, 22, "22" },
{ "foo\t11\t22\t", "foo", "", 11, 22, "22" },
{ "foo\t\t\t11 22", "foo", "", 11, 22, "22" },
{ "\tfoo\t11\t \t 22\t", "foo", "", 11, 22, "22" },
{ "\tfoo\t\t\t11 22\t", "foo", "", 11, 22, "22" },
{ "foo\tbar\t2 22", "foo", "bar", 2, 22, "22" },
{ "\tfoo\tbar\t11\t22", "foo", "bar", 11, 22, "22" },
{ "foo\tbar\t11\t\t22\t ", "foo", "bar", 11, 22, "22" },
{ "foo\t\tbar\t\t11\t\t\t22", "foo", "bar", 11, 22, "22" },
{ "\tfoo\tbar\t11\t22\t", "foo", "bar", 11, 22, "22" },
{ "\t \tfoo\t \t \tbar\t \t11\t 22\t \t", "foo", "bar", 11, 22, "22" },
{ "\t\tfoo\t\t\tbar\t\t22\t\t\t2222\t\t", "foo", "bar", 22, 2222, "2222" },
{ "\t \tfoo\t \t \t\t\t11\t\t 2222\t \t", "foo", "", 11, 2222, "2222" },
};
// =====================================================================
// TestParseRoutedHeaderMsg — Go test ID 2608
// =====================================================================
[Fact]
public void ParseRoutedHeaderMsg_ParsesCorrectly()
{
var c = DummyRouteClient();
var h = DummyHandler();
// hdr > size should error
var pub = "HMSG $foo foo 10 8\r\nXXXhello\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldNotBeNull();
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// Simple HMSG
pub = "HMSG $foo foo 3 8\r\nXXXhello\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$foo");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo");
c.Pa.Reply.ShouldBeNull();
c.Pa.HeaderSize.ShouldBe(3);
c.Pa.Size.ShouldBe(8);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// HMSG with reply
pub = "HMSG $G foo.bar INBOX.22 3 14\r\nOK:hello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$G");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("INBOX.22");
c.Pa.HeaderSize.ShouldBe(3);
c.Pa.Size.ShouldBe(14);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// HMSG with + reply and queue
pub = "HMSG $G foo.bar + reply baz 3 14\r\nOK:hello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$G");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("reply");
c.Pa.Queues.ShouldNotBeNull();
c.Pa.Queues!.Count.ShouldBe(1);
Encoding.ASCII.GetString(c.Pa.Queues[0]).ShouldBe("baz");
c.Pa.HeaderSize.ShouldBe(3);
c.Pa.Size.ShouldBe(14);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// HMSG with | queue (no reply)
pub = "HMSG $G foo.bar | baz 3 14\r\nOK:hello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$G");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("");
c.Pa.Queues.ShouldNotBeNull();
c.Pa.Queues!.Count.ShouldBe(1);
Encoding.ASCII.GetString(c.Pa.Queues[0]).ShouldBe("baz");
c.Pa.HeaderSize.ShouldBe(3);
c.Pa.Size.ShouldBe(14);
}
// =====================================================================
// TestParseRouteMsg — Go test ID 2609
// =====================================================================
[Fact]
public void ParseRouteMsg_ParsesCorrectly()
{
var c = DummyRouteClient();
var h = DummyHandler();
// MSG from route should error (must use RMSG)
var pub = "MSG $foo foo 5\r\nhello\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldNotBeNull();
// Reset
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// RMSG simple
pub = "RMSG $foo foo 5\r\nhello\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$foo");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo");
c.Pa.Reply.ShouldBeNull();
c.Pa.Size.ShouldBe(5);
// Clear
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// RMSG with reply
pub = "RMSG $G foo.bar INBOX.22 11\r\nhello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$G");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("INBOX.22");
c.Pa.Size.ShouldBe(11);
// Clear
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// RMSG with + reply and queue
pub = "RMSG $G foo.bar + reply baz 11\r\nhello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$G");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("reply");
c.Pa.Queues.ShouldNotBeNull();
c.Pa.Queues!.Count.ShouldBe(1);
Encoding.ASCII.GetString(c.Pa.Queues[0]).ShouldBe("baz");
// Clear
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// RMSG with | queue (no reply)
pub = "RMSG $G foo.bar | baz 11\r\nhello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$G");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("");
c.Pa.Queues.ShouldNotBeNull();
c.Pa.Queues!.Count.ShouldBe(1);
Encoding.ASCII.GetString(c.Pa.Queues[0]).ShouldBe("baz");
}
// =====================================================================
// TestParseMsgSpace — Go test ID 2610
// =====================================================================
[Fact]
public void ParseMsgSpace_ErrorsCorrectly()
{
// MSG <SPC> from route should error
var c = DummyRouteClient();
var h = DummyHandler();
ProtocolParser.Parse(c, h, "MSG \r\n"u8.ToArray()).ShouldNotBeNull();
// M from client should error
c = DummyClient();
ProtocolParser.Parse(c, h, "M"u8.ToArray()).ShouldNotBeNull();
}
// =====================================================================
// TestShouldFail — Go test ID 2611
// =====================================================================
[Theory]
[MemberData(nameof(ShouldFailClientProtos))]
public void ShouldFail_ClientProtos(string proto)
{
var c = DummyClient();
var h = DummyHandler();
ProtocolParser.Parse(c, h, Encoding.ASCII.GetBytes(proto)).ShouldNotBeNull();
}
public static TheoryData<string> ShouldFailClientProtos => new()
{
"xxx",
"Px", "PIx", "PINx", " PING",
"POx", "PONx",
"+x", "+Ox",
"-x", "-Ex", "-ERx", "-ERRx",
"Cx", "COx", "CONx", "CONNx", "CONNEx", "CONNECx", "CONNECT \r\n",
"PUx", "PUB foo\r\n", "PUB \r\n", "PUB foo bar \r\n",
"PUB foo 2\r\nok \r\n", "PUB foo 2\r\nok\r \n",
"Sx", "SUx", "SUB\r\n", "SUB \r\n", "SUB foo\r\n",
"SUB foo bar baz 22\r\n",
"Ux", "UNx", "UNSx", "UNSUx", "UNSUBx", "UNSUBUNSUB 1\r\n", "UNSUB_2\r\n",
"UNSUB_UNSUB_UNSUB 2\r\n", "UNSUB_\t2\r\n", "UNSUB\r\n", "UNSUB \r\n",
"UNSUB \t \r\n",
"Ix", "INx", "INFx", "INFO \r\n",
};
[Theory]
[MemberData(nameof(ShouldFailRouterProtos))]
public void ShouldFail_RouterProtos(string proto)
{
var c = DummyClient();
c.Kind = ClientKind.Router;
var h = DummyHandler();
ProtocolParser.Parse(c, h, Encoding.ASCII.GetBytes(proto)).ShouldNotBeNull();
}
public static TheoryData<string> ShouldFailRouterProtos => new()
{
"Mx", "MSx", "MSGx", "MSG \r\n",
};
// =====================================================================
// TestProtoSnippet — Go test ID 2612
// =====================================================================
[Fact]
public void ProtoSnippet_MatchesGoOutput()
{
var sample = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"u8.ToArray();
var tests = new (int Start, string Expected)[]
{
(0, "\"abcdefghijklmnopqrstuvwxyzABCDEF\""),
(1, "\"bcdefghijklmnopqrstuvwxyzABCDEFG\""),
(2, "\"cdefghijklmnopqrstuvwxyzABCDEFGH\""),
(3, "\"defghijklmnopqrstuvwxyzABCDEFGHI\""),
(4, "\"efghijklmnopqrstuvwxyzABCDEFGHIJ\""),
(5, "\"fghijklmnopqrstuvwxyzABCDEFGHIJK\""),
(6, "\"ghijklmnopqrstuvwxyzABCDEFGHIJKL\""),
(7, "\"hijklmnopqrstuvwxyzABCDEFGHIJKLM\""),
(8, "\"ijklmnopqrstuvwxyzABCDEFGHIJKLMN\""),
(9, "\"jklmnopqrstuvwxyzABCDEFGHIJKLMNO\""),
(10, "\"klmnopqrstuvwxyzABCDEFGHIJKLMNOP\""),
(11, "\"lmnopqrstuvwxyzABCDEFGHIJKLMNOPQ\""),
(12, "\"mnopqrstuvwxyzABCDEFGHIJKLMNOPQR\""),
(13, "\"nopqrstuvwxyzABCDEFGHIJKLMNOPQRS\""),
(14, "\"opqrstuvwxyzABCDEFGHIJKLMNOPQRST\""),
(15, "\"pqrstuvwxyzABCDEFGHIJKLMNOPQRSTU\""),
(16, "\"qrstuvwxyzABCDEFGHIJKLMNOPQRSTUV\""),
(17, "\"rstuvwxyzABCDEFGHIJKLMNOPQRSTUVW\""),
(18, "\"stuvwxyzABCDEFGHIJKLMNOPQRSTUVWX\""),
(19, "\"tuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY\""),
(20, "\"uvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\""),
(21, "\"vwxyzABCDEFGHIJKLMNOPQRSTUVWXY\""),
(22, "\"wxyzABCDEFGHIJKLMNOPQRSTUVWXY\""),
(23, "\"xyzABCDEFGHIJKLMNOPQRSTUVWXY\""),
(24, "\"yzABCDEFGHIJKLMNOPQRSTUVWXY\""),
(25, "\"zABCDEFGHIJKLMNOPQRSTUVWXY\""),
(26, "\"ABCDEFGHIJKLMNOPQRSTUVWXY\""),
(27, "\"BCDEFGHIJKLMNOPQRSTUVWXY\""),
(28, "\"CDEFGHIJKLMNOPQRSTUVWXY\""),
(29, "\"DEFGHIJKLMNOPQRSTUVWXY\""),
(30, "\"EFGHIJKLMNOPQRSTUVWXY\""),
(31, "\"FGHIJKLMNOPQRSTUVWXY\""),
(32, "\"GHIJKLMNOPQRSTUVWXY\""),
(33, "\"HIJKLMNOPQRSTUVWXY\""),
(34, "\"IJKLMNOPQRSTUVWXY\""),
(35, "\"JKLMNOPQRSTUVWXY\""),
(36, "\"KLMNOPQRSTUVWXY\""),
(37, "\"LMNOPQRSTUVWXY\""),
(38, "\"MNOPQRSTUVWXY\""),
(39, "\"NOPQRSTUVWXY\""),
(40, "\"OPQRSTUVWXY\""),
(41, "\"PQRSTUVWXY\""),
(42, "\"QRSTUVWXY\""),
(43, "\"RSTUVWXY\""),
(44, "\"STUVWXY\""),
(45, "\"TUVWXY\""),
(46, "\"UVWXY\""),
(47, "\"VWXY\""),
(48, "\"WXY\""),
(49, "\"XY\""),
(50, "\"Y\""),
(51, "\"\""),
(52, "\"\""),
(53, "\"\""),
(54, "\"\""),
};
foreach (var (start, expected) in tests)
{
var got = ProtocolParser.ProtoSnippet(start, ServerConstants.ProtoSnippetSize, sample);
got.ShouldBe(expected, $"start={start}");
}
}
// =====================================================================
// TestParseOK — Go test ID 2613 (mapped from Go TestParseOK)
// =====================================================================
[Fact]
public void ParseOK_ByteByByte()
{
var c = DummyClient();
var h = DummyHandler();
c.State.ShouldBe(ParserState.OpStart);
var ok = "+OK\r\n"u8.ToArray();
ProtocolParser.Parse(c, h, ok[..1]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPlus);
ProtocolParser.Parse(c, h, ok[1..2]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPlusO);
ProtocolParser.Parse(c, h, ok[2..3]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPlusOk);
ProtocolParser.Parse(c, h, ok[3..4]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPlusOk);
ProtocolParser.Parse(c, h, ok[4..5]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
}
// =====================================================================
// TestMaxControlLine — Go test ID 2614
// =====================================================================
[Theory]
[InlineData(ClientKind.Client, true)]
[InlineData(ClientKind.Leaf, false)]
[InlineData(ClientKind.Router, false)]
[InlineData(ClientKind.Gateway, false)]
public void MaxControlLine_EnforcedForClientOnly(ClientKind kind, bool shouldFail)
{
var pub = "PUB foo.bar.baz 2\r\nok\r\n"u8.ToArray();
var c = new ParseContext
{
Kind = kind,
MaxControlLine = 8, // Very small limit
MaxPayload = -1,
};
var h = DummyHandler();
// For non-client kinds, we need to set up the OP appropriately
// Routes use RMSG not PUB, but PUB is fine for testing mcl enforcement
// since the state machine handles it the same way.
var err = ProtocolParser.Parse(c, h, pub);
if (shouldFail)
{
err.ShouldNotBeNull();
ErrorContextHelper.ErrorIs(err, ServerErrors.ErrMaxControlLine).ShouldBeTrue();
}
else
{
// Non-client kinds don't enforce max control line
err.ShouldBeNull();
}
}
// =====================================================================
// TestProtocolHandler — stub handler for tests
// =====================================================================
private sealed class TestProtocolHandler : IProtocolHandler
{
public bool IsMqtt => false;
public bool Trace => false;
public bool HasMappings => false;
public bool IsAwaitingAuth => false;
public bool TryRegisterNoAuthUser() => true; // Allow all
public bool IsGatewayInboundNotConnected => false;
public int PingCount { get; private set; }
public int PongCount { get; private set; }
public byte[]? ConnectArgs { get; private set; }
public Exception? ProcessConnect(byte[] arg) { ConnectArgs = arg; return null; }
public Exception? ProcessInfo(byte[] arg) => null;
public void ProcessPing() => PingCount++;
public void ProcessPong() => PongCount++;
public void ProcessErr(string arg) { }
public Exception? ProcessClientSub(byte[] arg) => null;
public Exception? ProcessClientUnsub(byte[] arg) => null;
public Exception? ProcessRemoteSub(byte[] arg, bool isLeaf) => null;
public Exception? ProcessRemoteUnsub(byte[] arg, bool isLeafUnsub) => null;
public Exception? ProcessGatewayRSub(byte[] arg) => null;
public Exception? ProcessGatewayRUnsub(byte[] arg) => null;
public Exception? ProcessLeafSub(byte[] arg) => null;
public Exception? ProcessLeafUnsub(byte[] arg) => null;
public Exception? ProcessAccountSub(byte[] arg) => null;
public void ProcessAccountUnsub(byte[] arg) { }
public void ProcessInboundMsg(byte[] msg) { }
public bool SelectMappedSubject() => false;
public void TraceInOp(string name, byte[]? arg) { }
public void TraceMsg(byte[] msg) { }
public void SendErr(string msg) { }
public void AuthViolation() { }
public void CloseConnection(int reason) { }
public string KindString() => "CLIENT";
}
}