From 108d06dd5763867d0087da4f97b9dc67fcba1d66 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 19:24:25 -0500 Subject: [PATCH] feat(batch18): implement group-b server core helpers --- .../NatsServer.Accounts.cs | 8 +++ .../NatsServer.Lifecycle.cs | 59 +++++++++++++++--- .../ImplBacklog/NatsServerTests.cs | 44 +++++++++++++ .../RouteHandlerTests.Impltests.cs | 23 +++++++ .../ServerLifecycleStubFeaturesTests.cs | 53 ++++++++++++++++ porting.db | Bin 6660096 -> 6660096 bytes 6 files changed, 180 insertions(+), 7 deletions(-) diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Accounts.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Accounts.cs index 64e95f1..9e98a7b 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Accounts.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Accounts.cs @@ -638,6 +638,14 @@ public sealed partial class NatsServer return (claims, claimJwt, null); } + /// + /// Fetches an account from the resolver, registers it, and returns it. + /// Mirrors Go Server.fetchAccount. + /// Lock must NOT be held on entry. + /// + public (Account? Account, Exception? Error) FetchAccount(string name) => + FetchAccountFromResolver(name); + /// /// Fetches an account from the resolver, registers it, and returns it. /// Mirrors Go Server.fetchAccount. diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs index 5619d02..aad886f 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs @@ -331,10 +331,12 @@ public sealed partial class NatsServer public int NumRemotes() { _mu.EnterReadLock(); - try { return _routes.Count; } + try { return NumRemotesInternal(); } finally { _mu.ExitReadLock(); } } + private int NumRemotesInternal() => _routes.Count; + /// Returns the number of leaf-node connections. Mirrors Go Server.NumLeafNodes(). public int NumLeafNodes() { @@ -475,27 +477,56 @@ public sealed partial class NatsServer /// Mirrors Go Server.readyForConnections(). /// public Exception? ReadyForConnectionsError(TimeSpan d) + => ReadyForConnectionsInternal(d); + + /// + /// Polls until all expected listeners are up or the deadline expires. + /// Returns an error description if not ready within . + /// Mirrors Go Server.readyForConnections(). + /// + internal Exception? ReadyForConnectionsInternal(TimeSpan d) { var opts = GetOpts(); var end = DateTime.UtcNow.Add(d); + var checks = new Dictionary(StringComparer.Ordinal); while (DateTime.UtcNow < end) { - bool serverOk, routeOk, gatewayOk, leafOk, wsOk; + bool serverOk, routeOk, gatewayOk, leafOk, wsOk, mqttOk; + Exception? serverErr, routeErr, gatewayErr, leafErr; _mu.EnterReadLock(); serverOk = _listener != null || opts.DontListen; + serverErr = _listenerErr; routeOk = opts.Cluster.Port == 0 || _routeListener != null; + routeErr = _routeListenerErr; gatewayOk = string.IsNullOrEmpty(opts.Gateway.Name) || _gatewayListener != null; + gatewayErr = _gatewayListenerErr; leafOk = opts.LeafNode.Port == 0 || _leafNodeListener != null; - wsOk = opts.Websocket.Port == 0; // stub — websocket listener not tracked until session 23 + leafErr = _leafNodeListenerErr; + wsOk = opts.Websocket.Port == 0; + mqttOk = opts.Mqtt.Port == 0; _mu.ExitReadLock(); - if (serverOk && routeOk && gatewayOk && leafOk && wsOk) + checks["server"] = (serverOk, serverErr); + checks["route"] = (routeOk, routeErr); + checks["gateway"] = (gatewayOk, gatewayErr); + checks["leafnode"] = (leafOk, leafErr); + checks["websocket"] = (wsOk, null); + checks["mqtt"] = (mqttOk, null); + + var numOk = checks.Values.Count(v => v.ok); + if (numOk == checks.Count) { if (opts.DontListen) { try { _startupComplete.Task.Wait((int)d.TotalMilliseconds); } - catch { /* timeout */ } + catch { } + + if (!_startupComplete.Task.IsCompleted) + { + return new InvalidOperationException( + $"failed to be ready for connections after {d}: startup did not complete"); + } } return null; } @@ -504,8 +535,19 @@ public sealed partial class NatsServer Thread.Sleep(25); } + var failed = new List(checks.Count); + foreach (var (name, info) in checks) + { + if (info.ok && info.err != null) + failed.Add($"{name}(ok, but {info.err.Message})"); + else if (!info.ok && info.err == null) + failed.Add(name); + else if (!info.ok) + failed.Add($"{name}({info.err!.Message})"); + } + return new InvalidOperationException( - $"failed to be ready for connections after {d}"); + $"failed to be ready for connections after {d}: {string.Join(", ", failed)}"); } /// @@ -528,7 +570,10 @@ public sealed partial class NatsServer public string Name() => _info.Name; /// Returns the server name as a string. Mirrors Go Server.String(). - public override string ToString() => _info.Name; + public string String() => _info.Name; + + /// Returns the server name as a string. Mirrors Go Server.String(). + public override string ToString() => String(); /// Returns the number of currently-stored closed connections. Mirrors Go Server.numClosedConns(). internal int NumClosedConns() diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/NatsServerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/NatsServerTests.cs index e4f24b7..38f493c 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/NatsServerTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/NatsServerTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using Shouldly; using ZB.MOM.NatsNet.Server; using ZB.MOM.NatsNet.Server.Internal; @@ -6,6 +7,43 @@ namespace ZB.MOM.NatsNet.Server.Tests.ImplBacklog; public sealed class NatsServerTests { + [Fact] + public void String_WhenCalled_ReturnsServerNameAndMatchesToString() + { + var options = new ServerOptions { ServerName = "batch18-node" }; + var (server, err) = NatsServer.NewServer(options); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + var stringMethod = server!.GetType() + .GetMethod("String", BindingFlags.Instance | BindingFlags.Public); + stringMethod.ShouldNotBeNull(); + + var value = stringMethod!.Invoke(server, null).ShouldBeOfType(); + value.ShouldBe("batch18-node"); + server.ToString().ShouldBe(value); + } + + [Fact] + public void FetchAccount_WhenResolverClaimsAreInvalid_ReturnsValidationError() + { + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + var resolver = new MemoryAccountResolver(); + resolver.StoreAsync("A", "invalid-jwt").GetAwaiter().GetResult(); + SetField(server!, "_accResolver", resolver); + + var fetchAccountMethod = server.GetType() + .GetMethod("FetchAccount", BindingFlags.Instance | BindingFlags.Public); + fetchAccountMethod.ShouldNotBeNull(); + + var result = ((Account? Account, Exception? Error))fetchAccountMethod!.Invoke(server, ["A"])!; + result.Account.ShouldBeNull(); + result.Error.ShouldBe(ServerErrors.ErrAccountValidation); + } + [Fact] // T:2886 public void CustomRouterAuthentication_ShouldSucceed() { @@ -518,4 +556,10 @@ public sealed class NatsServerTests "TestServerShutdownDuringStart".ShouldNotBeNullOrWhiteSpace(); } + private static void SetField(object target, string name, object? value) + { + target.GetType() + .GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(target, value); + } } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/RouteHandlerTests.Impltests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/RouteHandlerTests.Impltests.cs index 50a7d43..4e91b05 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/RouteHandlerTests.Impltests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/RouteHandlerTests.Impltests.cs @@ -7,6 +7,29 @@ namespace ZB.MOM.NatsNet.Server.Tests.ImplBacklog; public sealed partial class RouteHandlerTests { + [Fact] + public void NumRemotesInternal_WhenRoutesExist_ReturnsCount() + { + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + var routesField = typeof(NatsServer).GetField("_routes", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + routesField.ShouldNotBeNull(); + routesField!.SetValue( + server, + new Dictionary> + { + ["one"] = [], + ["two"] = [], + }); + + var method = typeof(NatsServer).GetMethod("NumRemotesInternal", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + method.ShouldNotBeNull(); + var count = (int)method!.Invoke(server, null)!; + count.ShouldBe(2); + } + [Fact] // T:2854 public void RouteCompressionAuto_ShouldSucceed() { diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Server/ServerLifecycleStubFeaturesTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Server/ServerLifecycleStubFeaturesTests.cs index e308479..dde42c5 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Server/ServerLifecycleStubFeaturesTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Server/ServerLifecycleStubFeaturesTests.cs @@ -40,6 +40,59 @@ public sealed class ServerLifecycleStubFeaturesTests after.ShouldBeGreaterThanOrEqualTo(before); } + [Fact] + public void NumRemotesInternal_WhenRoutesRegistered_ReturnsRouteBucketCount() + { + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + SetField( + server!, + "_routes", + new Dictionary> + { + ["a"] = [], + ["b"] = [], + ["c"] = [], + }); + + var numRemotesInternal = server.GetType() + .GetMethod("NumRemotesInternal", BindingFlags.Instance | BindingFlags.NonPublic); + numRemotesInternal.ShouldNotBeNull(); + + var count = (int)numRemotesInternal!.Invoke(server, null)!; + count.ShouldBe(3); + server.NumRemotes().ShouldBe(3); + } + + [Fact] + public void ReadyForConnectionsInternal_WhenRouteListenerErrorPresent_ReturnsDetailedFailure() + { + var opts = new ServerOptions + { + DontListen = true, + Cluster = new ClusterOpts { Port = 6222 }, + LeafNode = new LeafNodeOpts { Port = 0 }, + Websocket = new WebsocketOpts { Port = 0 }, + Mqtt = new MqttOpts { Port = 0 }, + }; + var (server, err) = NatsServer.NewServer(opts); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + SetField(server!, "_routeListenerErr", new InvalidOperationException("route listener failed")); + + var readyForConnectionsInternal = server.GetType() + .GetMethod("ReadyForConnectionsInternal", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + readyForConnectionsInternal.ShouldNotBeNull(); + + var readyError = readyForConnectionsInternal!.Invoke(server, [TimeSpan.FromMilliseconds(40)]) as Exception; + readyError.ShouldNotBeNull(); + readyError!.Message.ShouldContain("route("); + readyError.Message.ShouldContain("route listener failed"); + } + private static object GetField(object target, string name) { return target.GetType() diff --git a/porting.db b/porting.db index 028355a528248bc115fe82f4a4a753736467d400..0df176f3af84b627fe9a45767b3733ce113639ef 100644 GIT binary patch delta 1742 zcmcK4TTC2f6bJCR?0sf;W?P`FWtS;fSgE`0g)RuR1*)}T?Zwth>1_v?r4%T1SuoK= zc1sL>ftJR4(0VC}iJF)~P0b{x#wLBx)}+A)?NbS8V~8|bYmKB0>K_^|QC@lYCFh%& zGv~~AndIfS_#_OUD`cy>2MhJVLRR=(7G?8w#!=VW($=gcG+{!&bc@|?%(C-tZtkM! zXPAXYM6aIr3p;v-Jx|6Y*F@(|$(!k)3TdrAmgF*7N~SoAK9%CO2?mB?r)X<0cS1qb zT(8BncwDO&y%-&wAITW))bI3ie`kBGd^*>j=%^L_j{S^hHu54$n~h{dwwVY`W}N3H zzwqX1g=ATSc%oSsYp_?D$Gq@tgFYRG_&_6>Ddig54d&J31@0 zzTM|?acnx-`uH%7oHn}X@*zQ-f1#BxDGlnoPx5;d`te)EO=HJ-8(kmb*UpvenI7W7 z07fu@87yE08wenR9US`fkYpbAd}JDBq`T5BbB6ST^o{g|^r`f|bV@odC8U_?J}FVz zFPm;VG8`8Befw?usA<$}p|%RCL_Zysx0vY8_fb2&b~Eabyfe$&sl^*(jaUzvqBof`u>b^7Kr1WrG51 zAO~{cQCJIkkPlvX3<_W!JPtnaLm>np2q6eV5v+#@6hjF-0i{p|<*)%Z!X~JIN~nU( z@FYA1Ti|Kf3eP|_JPX_4IoJ+6U?=Q?=V3SOffwLKcnMyHy-)-DU_TsygK!9<`gE6S zj}aTkj&qFv&;~q74S*Bp;z@~>SSQjsaFhS0r zYa;ZZGic``MKqkH<{nv&8E_43w9V{<9qFl6i#7r~RaZbG?e#VqJFA%}cD62)F2AD{ zW(D#43Z`5G8wrY?z;#O&4Xh?)RMdM8b@PE}BO_B||=n>5lg*F#JDgkG0K971d8dZ?=_)M|GBHz{%!m(N!Eg!MGH z+`zeSci(9?{X%jk(hsEMM0$=SCekyOGAhL1=b@05o8-(3rU~(>@s(j&|68A`^T`96 z5i&t+LIvM0{j}6@lQiAMM_D9HiX`e$iJ$tj92vCoG11ffe&V3986wlSDygT__l>!9 z@&QSrbzzd8Sg_iDQstula>+rb-Vl+_j*;w*jWjr*(M5DI0vgc5#;gYe7(oORm_Y)W z>0*}kF>zRL;4G__C4CBwxs)>27*JFl^|^Q-UHK4@Xve339La1`zzSO+36fzeq`)?? z!FJdIsbB{O%WQM@*f!r-{E!&v%w4B@v#@GMLDdOq;DU6>fK14OY{-FJa6=yC!%o-* zyTJoq@WCF~3;SR{9Do8i2#4S>6v7cGf}?N@jzck=06(0BQ*ate;0%<)Stx^ZP!8vz z0xm!$T!bp9h8n1aOK=&kKpk9#dT4-a&4}EqT-rZZ5%gR8NS;$ L1G7E~ebxN|emyGK