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