feat: port session 10 — Server Core Runtime, Accept Loops & Listeners

Ports server/server.go lines 2577–4782 (~1,881 Go LOC), implementing ~97
features (IDs 3051–3147) across three new partial-class files.

New files:
- NatsServer.Lifecycle.cs: Shutdown, WaitForShutdown, RemoveClient,
  SendLDMToClients, LameDuckMode, LDMClientByID, rate-limit logging,
  DisconnectClientByID, SendAsyncInfoToClients
- NatsServer.Listeners.cs: AcceptLoop, GetServerListener, InProcessConn,
  AcceptConnections, GenerateInfoJson, CopyInfo, CreateClient/Ex/InProcess,
  StartMonitoring (HTTP/HTTPS), AddConnectURLs/RemoveConnectURLs,
  TlsVersion/TlsVersionFromString, GetClientConnectURLs, ResolveHostPorts,
  PortsInfo/PortFile/LogPorts, ReadyForListeners, GetRandomIP, AcceptError
- Internal/WaitGroup.cs: Go-style WaitGroup using TaskCompletionSource

Modified:
- Auth/AuthTypes.cs: Account now implements INatsAccount (stub)
- NatsServerTypes.cs: ServerInfo.ShallowClone(), removed duplicate RefCountedUrlSet
- NatsServer.cs: _info promoted to internal for test access
- Properties/AssemblyInfo.cs: InternalsVisibleTo(DynamicProxyGenAssembly2)
- ServerTests.cs: 20 new session-10 unit tests (GenerateInfoJson, TlsVersion,
  CopyInfo, GetRandomIP — Test IDs 2895, 2906)

All 565 unit tests + 1 integration test pass.
This commit is contained in:
Joseph Doherty
2026-02-26 15:08:23 -05:00
parent 0df93c23b0
commit 06779a1f77
13 changed files with 2539 additions and 21 deletions

View File

@@ -12,11 +12,16 @@
// limitations under the License.
//
// Adapted from server/server_test.go in the NATS server Go source.
// Session 09: standalone unit tests for NatsServer helpers.
// Session 0910: standalone unit tests for NatsServer helpers.
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
using Xunit;
using ZB.MOM.NatsNet.Server.Auth;
namespace ZB.MOM.NatsNet.Server.Tests;
@@ -249,3 +254,215 @@ public sealed class ServerTests
public void NeedsCompression_S2Fast_ReturnsTrue()
=> NatsServer.NeedsCompression(CompressionMode.S2Fast).ShouldBeTrue();
}
// =============================================================================
// Session 10: Listeners, INFO JSON, TLS helpers, GetRandomIP
// =============================================================================
/// <summary>
/// Tests for session 10 features: GenerateInfoJson, TlsVersion helpers,
/// CopyInfo, and GetRandomIP.
/// </summary>
public sealed class ServerListenersTests
{
// =========================================================================
// GenerateInfoJson (feature 3069) — Test ID 2906
// Mirrors Go TestServerJsonMarshalNestedStructsPanic (guards against
// marshaller panics with nested/nullable structs).
// =========================================================================
[Fact]
public void GenerateInfoJson_MinimalInfo_ProducesInfoFrame()
{
var info = new ServerInfo { Id = "TEST", Version = "1.0.0", Host = "0.0.0.0", Port = 4222 };
var bytes = NatsServer.GenerateInfoJson(info);
var text = Encoding.UTF8.GetString(bytes);
text.ShouldStartWith("INFO {");
text.ShouldEndWith("}\r\n");
}
[Fact]
public void GenerateInfoJson_WithConnectUrls_IncludesUrls()
{
var info = new ServerInfo
{
Id = "TEST",
Version = "1.0.0",
ClientConnectUrls = ["nats://127.0.0.1:4222", "nats://127.0.0.1:4223"],
};
var text = Encoding.UTF8.GetString(NatsServer.GenerateInfoJson(info));
text.ShouldContain("connect_urls");
text.ShouldContain("4222");
}
[Fact]
public void GenerateInfoJson_WithSubjectPermissions_DoesNotThrow()
{
// Mirrors Go TestServerJsonMarshalNestedStructsPanic — guards against
// JSON marshaller failures with nested nullable structs.
var info = new ServerInfo
{
Id = "TEST",
Version = "1.0.0",
Import = new SubjectPermission { Allow = ["foo.>"], Deny = ["bar"] },
Export = new SubjectPermission { Allow = ["pub.>"] },
};
var bytes = NatsServer.GenerateInfoJson(info);
bytes.ShouldNotBeEmpty();
// Strip the "INFO " prefix (5 bytes) and the trailing "\r\n" (2 bytes) to get pure JSON.
var json = Encoding.UTF8.GetString(bytes, 5, bytes.Length - 7);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("import").ValueKind.ShouldBe(JsonValueKind.Object);
}
// =========================================================================
// TlsVersion helpers (features 30793080)
// =========================================================================
[Theory]
[InlineData(0x0301u, "1.0")]
[InlineData(0x0302u, "1.1")]
[InlineData(0x0303u, "1.2")]
[InlineData(0x0304u, "1.3")]
public void TlsVersion_KnownCodes_ReturnsVersionString(uint ver, string expected)
=> NatsServer.TlsVersion((ushort)ver).ShouldBe(expected);
[Fact]
public void TlsVersion_UnknownCode_ReturnsUnknownLabel()
=> NatsServer.TlsVersion(0xFFFF).ShouldStartWith("Unknown");
[Theory]
[InlineData("1.0", (ushort)0x0301)]
[InlineData("1.1", (ushort)0x0302)]
[InlineData("1.2", (ushort)0x0303)]
[InlineData("1.3", (ushort)0x0304)]
public void TlsVersionFromString_KnownStrings_ReturnsCode(string input, ushort expected)
{
var (ver, err) = NatsServer.TlsVersionFromString(input);
err.ShouldBeNull();
ver.ShouldBe(expected);
}
[Fact]
public void TlsVersionFromString_UnknownString_ReturnsError()
{
var (_, err) = NatsServer.TlsVersionFromString("9.9");
err.ShouldNotBeNull();
}
// =========================================================================
// CopyInfo (feature 3069)
// =========================================================================
[Fact]
public void CopyInfo_DeepCopiesSlices()
{
var (s, err) = NatsServer.NewServer(new ServerOptions());
err.ShouldBeNull();
s.ShouldNotBeNull();
s!._info.ClientConnectUrls = ["nats://127.0.0.1:4222"];
var copy = s.CopyInfo();
copy.ClientConnectUrls.ShouldNotBeNull();
copy.ClientConnectUrls!.ShouldBe(["nats://127.0.0.1:4222"]);
// Mutating original slice shouldn't affect the copy.
s._info.ClientConnectUrls = ["nats://10.0.0.1:4222"];
copy.ClientConnectUrls[0].ShouldBe("nats://127.0.0.1:4222");
}
// =========================================================================
// GetRandomIP (feature 3141) — Test ID 2895
// Mirrors Go TestGetRandomIP.
// =========================================================================
private static NatsServer MakeServer()
{
var (s, err) = NatsServer.NewServer(new ServerOptions());
err.ShouldBeNull();
return s!;
}
[Fact]
public async Task GetRandomIP_NoPort_ReturnsFormatError()
{
var s = MakeServer();
var resolver = Substitute.For<INetResolver>();
var (_, err) = await s.GetRandomIP(resolver, "noport");
err.ShouldNotBeNull();
err!.Message.ShouldContain("port");
}
[Fact]
public async Task GetRandomIP_ResolverThrows_PropagatesError()
{
var s = MakeServer();
var resolver = Substitute.For<INetResolver>();
resolver.LookupHostAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("on purpose"));
var (_, err) = await s.GetRandomIP(resolver, "localhost:4222");
err.ShouldNotBeNull();
err!.Message.ShouldContain("on purpose");
}
[Fact]
public async Task GetRandomIP_EmptyIps_ReturnsFallbackUrl()
{
var s = MakeServer();
var resolver = Substitute.For<INetResolver>();
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Array.Empty<string>()));
var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222");
err.ShouldBeNull();
addr.ShouldBe("localhost:4222");
}
[Fact]
public async Task GetRandomIP_SingleIp_ReturnsMappedAddress()
{
var s = MakeServer();
var resolver = Substitute.For<INetResolver>();
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new[] { "1.2.3.4" }));
var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222");
err.ShouldBeNull();
addr.ShouldBe("1.2.3.4:4222");
}
[Fact]
public async Task GetRandomIP_MultipleIps_AllSelectedWithinRange()
{
var s = MakeServer();
var resolver = Substitute.For<INetResolver>();
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new[] { "1.2.3.4", "2.2.3.4", "3.2.3.4" }));
var dist = new int[3];
for (int i = 0; i < 100; i++)
{
var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222");
err.ShouldBeNull();
dist[int.Parse(addr[..1]) - 1]++;
}
// Each IP should appear at least once and no single IP should dominate.
foreach (var d in dist)
d.ShouldBeGreaterThan(0);
}
[Fact]
public async Task GetRandomIP_ExcludedIp_NeverReturned()
{
var s = MakeServer();
var resolver = Substitute.For<INetResolver>();
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new[] { "1.2.3.4", "2.2.3.4", "3.2.3.4" }));
var excluded = new HashSet<string> { "1.2.3.4:4222" };
for (int i = 0; i < 100; i++)
{
var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222", excluded);
err.ShouldBeNull();
addr.ShouldNotBe("1.2.3.4:4222");
}
}
}