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 028355a..0df176f 100644 Binary files a/porting.db and b/porting.db differ