diff --git a/src/NATS.Server/Routes/RouteConnection.cs b/src/NATS.Server/Routes/RouteConnection.cs
index e8e97ab..d97478b 100644
--- a/src/NATS.Server/Routes/RouteConnection.cs
+++ b/src/NATS.Server/Routes/RouteConnection.cs
@@ -30,6 +30,20 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
///
public int NegotiatedPoolSize { get; private set; }
+ ///
+ /// Returns true when pool size negotiation has completed and the remote peer
+ /// supports route pooling ( > 0).
+ /// Go reference: server/route.go — pool-capable route check.
+ ///
+ public bool SupportsPooling => NegotiatedPoolSize > 0;
+
+ ///
+ /// Returns true when this connection is a legacy (pre-pool) route, i.e. the
+ /// remote peer does not support route pooling ( == 0).
+ /// Go reference: server/route.go getRoutesExcludePool.
+ ///
+ public bool IsLegacyRoute => NegotiatedPoolSize == 0;
+
///
/// Negotiates the effective route pool size between local and remote peers.
/// Returns Math.Min(localPoolSize, remotePoolSize), but returns 0 if
diff --git a/src/NATS.Server/Routes/RouteManager.cs b/src/NATS.Server/Routes/RouteManager.cs
index 51f2008..a483a5c 100644
--- a/src/NATS.Server/Routes/RouteManager.cs
+++ b/src/NATS.Server/Routes/RouteManager.cs
@@ -166,27 +166,58 @@ public sealed class RouteManager : IAsyncDisposable
///
/// Returns the route connection responsible for the given account. Checks
- /// dedicated account routes first, then falls back to pool-based selection.
- /// Go reference: server/route.go — per-account dedicated route lookup.
+ /// dedicated account routes first, then falls back to pool-based selection,
+ /// and finally falls back to the first legacy (pre-pool) route.
+ /// Go reference: server/route.go — per-account dedicated route lookup,
+ /// getRoutesExcludePool (legacy fallback).
///
public RouteConnection? GetRouteForAccount(string account)
{
- // Check dedicated account routes first (Gap 13.2).
+ // 1st: Check dedicated account routes (Gap 13.2).
if (_accountRoutes.TryGetValue(account, out var dedicated))
return dedicated;
if (_routes.IsEmpty)
return null;
- var routes = _routes.Values.ToArray();
- if (routes.Length == 0)
- return null;
+ // 2nd: Try pool-based routes (current behavior).
+ var poolRoutes = _routes.Values.Where(r => r.SupportsPooling).ToArray();
+ if (poolRoutes.Length > 0)
+ {
+ var idx = ComputeRoutePoolIdx(poolRoutes.Length, account);
+ return poolRoutes[idx % poolRoutes.Length];
+ }
- var poolSize = routes.Length;
- var idx = ComputeRoutePoolIdx(poolSize, account);
- return routes[idx % routes.Length];
+ // 3rd: Fall back to legacy route when no pool routes exist (Gap 13.6).
+ return GetLegacyRoute();
}
+ ///
+ /// Returns the first route connection with
+ /// equal to true, or null if no legacy routes are registered.
+ /// Legacy routes are connections to pre-pool peers that did not negotiate a
+ /// pool size during the handshake.
+ /// Go reference: server/route.go getRoutesExcludePool.
+ ///
+ public RouteConnection? GetLegacyRoute()
+ => _routes.Values.FirstOrDefault(r => r.IsLegacyRoute);
+
+ ///
+ /// Returns all route connections with
+ /// equal to true. Returns an empty list when no legacy routes exist.
+ /// Go reference: server/route.go getRoutesExcludePool.
+ ///
+ public IReadOnlyList GetLegacyRoutes()
+ => _routes.Values.Where(r => r.IsLegacyRoute).ToList();
+
+ ///
+ /// Returns true when at least one registered route connection is a
+ /// legacy (pre-pool) route.
+ /// Go reference: server/route.go getRoutesExcludePool.
+ ///
+ public bool HasLegacyRoutes
+ => _routes.Values.Any(r => r.IsLegacyRoute);
+
///
/// Registers a dedicated route connection for a specific account. If a
/// previous connection was registered for the same account it is replaced.
diff --git a/tests/NATS.Server.Tests/Routes/NoPoolFallbackTests.cs b/tests/NATS.Server.Tests/Routes/NoPoolFallbackTests.cs
new file mode 100644
index 0000000..24a65b5
--- /dev/null
+++ b/tests/NATS.Server.Tests/Routes/NoPoolFallbackTests.cs
@@ -0,0 +1,217 @@
+// Reference: golang/nats-server/server/route.go getRoutesExcludePool — no-pool fallback for
+// backward compatibility with pre-pool peers (Gap 13.6).
+
+using System.Net;
+using System.Net.Sockets;
+using Microsoft.Extensions.Logging.Abstractions;
+using NATS.Server.Configuration;
+using NATS.Server.Routes;
+
+namespace NATS.Server.Tests.Routes;
+
+///
+/// Tests for no-pool (legacy) route fallback — Gap 13.6.
+/// Verifies that ,
+/// , and the new
+/// legacy-route helpers behave correctly, and that
+/// falls back to a legacy route
+/// when neither a dedicated nor a pool route exists.
+/// Go reference: server/route.go getRoutesExcludePool.
+///
+public class NoPoolFallbackTests : IDisposable
+{
+ private readonly List _listeners = [];
+ private readonly List _sockets = [];
+
+ public void Dispose()
+ {
+ foreach (var s in _sockets) s.Dispose();
+ foreach (var l in _listeners) l.Stop();
+ }
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ private static RouteManager CreateManager() =>
+ new(
+ new ClusterOptions { Host = "127.0.0.1", Port = 0, Routes = [] },
+ new ServerStats(),
+ Guid.NewGuid().ToString("N"),
+ _ => { },
+ _ => { },
+ NullLogger.Instance);
+
+ ///
+ /// Creates a backed by a connected loopback
+ /// socket pair so that the internal
+ /// can be constructed without throwing. Both sockets are tracked for disposal.
+ ///
+ private RouteConnection MakeConnection()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ _listeners.Add(listener);
+
+ var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ _sockets.Add(client);
+ client.Connect((IPEndPoint)listener.LocalEndpoint);
+
+ var server = listener.AcceptSocket();
+ _sockets.Add(server);
+
+ return new RouteConnection(server);
+ }
+
+ // -----------------------------------------------------------------------
+ // RouteConnection.SupportsPooling
+ // -----------------------------------------------------------------------
+
+ // Go reference: server/route.go — pool-capable route: NegotiatedPoolSize > 0.
+ [Fact]
+ public void SupportsPooling_WhenNegotiated_ReturnsTrue()
+ {
+ var conn = MakeConnection();
+ conn.SetNegotiatedPoolSize(3);
+ conn.SupportsPooling.ShouldBeTrue();
+ }
+
+ // Go reference: server/route.go — default state is pre-negotiation (no pooling).
+ [Fact]
+ public void SupportsPooling_Default_ReturnsFalse()
+ {
+ var conn = MakeConnection();
+ // NegotiatedPoolSize defaults to 0 — pooling not yet established.
+ conn.SupportsPooling.ShouldBeFalse();
+ }
+
+ // -----------------------------------------------------------------------
+ // RouteConnection.IsLegacyRoute
+ // -----------------------------------------------------------------------
+
+ // Go reference: server/route.go getRoutesExcludePool — NegotiatedPoolSize == 0 means legacy.
+ [Fact]
+ public void IsLegacyRoute_Default_ReturnsTrue()
+ {
+ var conn = MakeConnection();
+ // A freshly created connection has NegotiatedPoolSize == 0, making it legacy.
+ conn.IsLegacyRoute.ShouldBeTrue();
+ }
+
+ // Go reference: server/route.go — pool-negotiated connection is not legacy.
+ [Fact]
+ public void IsLegacyRoute_WhenNegotiated_ReturnsFalse()
+ {
+ var conn = MakeConnection();
+ conn.SetNegotiatedPoolSize(2);
+ conn.IsLegacyRoute.ShouldBeFalse();
+ }
+
+ // -----------------------------------------------------------------------
+ // RouteManager.GetLegacyRoute
+ // -----------------------------------------------------------------------
+
+ // Go reference: server/route.go getRoutesExcludePool — returns first legacy connection.
+ [Fact]
+ public void GetLegacyRoute_ReturnsLegacyConnection()
+ {
+ var manager = CreateManager();
+ var legacy = MakeConnection();
+ // NegotiatedPoolSize == 0 by default — this is a legacy route.
+ manager.RegisterRoute("server-legacy", legacy);
+
+ var result = manager.GetLegacyRoute();
+
+ result.ShouldBeSameAs(legacy);
+ }
+
+ // Go reference: server/route.go getRoutesExcludePool — null when all routes support pooling.
+ [Fact]
+ public void GetLegacyRoute_NoLegacy_ReturnsNull()
+ {
+ var manager = CreateManager();
+ var pooled = MakeConnection();
+ pooled.SetNegotiatedPoolSize(3);
+ manager.RegisterRoute("server-pooled", pooled);
+
+ var result = manager.GetLegacyRoute();
+
+ result.ShouldBeNull();
+ }
+
+ // -----------------------------------------------------------------------
+ // RouteManager.GetLegacyRoutes
+ // -----------------------------------------------------------------------
+
+ // Go reference: server/route.go getRoutesExcludePool — returns all legacy connections.
+ [Fact]
+ public void GetLegacyRoutes_ReturnsAllLegacy()
+ {
+ var manager = CreateManager();
+
+ var legacy1 = MakeConnection(); // NegotiatedPoolSize == 0 → legacy
+ var legacy2 = MakeConnection(); // NegotiatedPoolSize == 0 → legacy
+ var pooled = MakeConnection();
+ pooled.SetNegotiatedPoolSize(3);
+
+ manager.RegisterRoute("server-legacy-1", legacy1);
+ manager.RegisterRoute("server-legacy-2", legacy2);
+ manager.RegisterRoute("server-pooled", pooled);
+
+ var result = manager.GetLegacyRoutes();
+
+ result.Count.ShouldBe(2);
+ result.ShouldContain(legacy1);
+ result.ShouldContain(legacy2);
+ result.ShouldNotContain(pooled);
+ }
+
+ // -----------------------------------------------------------------------
+ // RouteManager.HasLegacyRoutes
+ // -----------------------------------------------------------------------
+
+ // Go reference: server/route.go — any legacy route present returns true.
+ [Fact]
+ public void HasLegacyRoutes_WhenPresent_ReturnsTrue()
+ {
+ var manager = CreateManager();
+ var legacy = MakeConnection(); // IsLegacyRoute == true (default)
+ manager.RegisterRoute("server-legacy", legacy);
+
+ manager.HasLegacyRoutes.ShouldBeTrue();
+ }
+
+ // Go reference: server/route.go — no legacy routes returns false.
+ [Fact]
+ public void HasLegacyRoutes_WhenAbsent_ReturnsFalse()
+ {
+ var manager = CreateManager();
+ var pooled = MakeConnection();
+ pooled.SetNegotiatedPoolSize(5);
+ manager.RegisterRoute("server-pooled", pooled);
+
+ manager.HasLegacyRoutes.ShouldBeFalse();
+ }
+
+ // -----------------------------------------------------------------------
+ // RouteManager.GetRouteForAccount — legacy fallback (Gap 13.6)
+ // -----------------------------------------------------------------------
+
+ // Go reference: server/route.go — when no dedicated or pool route exists,
+ // fall back to the first legacy route for the account.
+ [Fact]
+ public void GetRouteForAccount_FallsBackToLegacy()
+ {
+ var manager = CreateManager();
+
+ // Register only a legacy route (NegotiatedPoolSize == 0).
+ var legacy = MakeConnection();
+ manager.RegisterRoute("server-legacy", legacy);
+
+ // No dedicated account route and no pool-capable route registered.
+ // GetRouteForAccount must return the legacy connection.
+ var result = manager.GetRouteForAccount("$G");
+
+ result.ShouldBeSameAs(legacy);
+ }
+}