using System.Net; using System.Net.Sockets; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using NATS.Client.Core; using NATS.Server.Auth; using NATS.Server.Configuration; using NATS.Server.Routes; using NATS.Server.Subscriptions; using NATS.Server.TestUtilities; namespace NATS.Server.Clustering.Tests.Routes; /// /// Tests for route subscription propagation: RS+/RS-, wildcard subs, queue subs, /// unsubscribe propagation, and account-scoped interest. /// Ported from Go: server/routes_test.go. /// public class RouteSubscriptionTests { // -- Helpers -- private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartServerAsync( NatsOptions opts) { var server = new NatsServer(opts, NullLoggerFactory.Instance); var cts = new CancellationTokenSource(); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); return (server, cts); } private static NatsOptions MakeClusterOpts(string? clusterName = null, string? seed = null) { return new NatsOptions { Host = "127.0.0.1", Port = 0, Cluster = new ClusterOptions { Name = clusterName ?? Guid.NewGuid().ToString("N"), Host = "127.0.0.1", Port = 0, Routes = seed is null ? [] : [seed], }, }; } private static async Task WaitForRouteFormation(NatsServer a, NatsServer b, int timeoutMs = 5000) { using var timeout = new CancellationTokenSource(timeoutMs); while (!timeout.IsCancellationRequested && (Interlocked.Read(ref a.Stats.Routes) == 0 || Interlocked.Read(ref b.Stats.Routes) == 0)) { await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); } } private static async Task WaitForCondition(Func predicate, int timeoutMs = 5000) { using var cts = new CancellationTokenSource(timeoutMs); while (!cts.IsCancellationRequested) { if (predicate()) return; await Task.Delay(20, cts.Token).ContinueWith(_ => { }, TaskScheduler.Default); } throw new TimeoutException("Condition not met."); } private static async Task DisposeServers(params (NatsServer Server, CancellationTokenSource Cts)[] servers) { foreach (var (server, cts) in servers) { await cts.CancelAsync(); server.Dispose(); cts.Dispose(); } } // -- Tests: RS+ propagation -- // Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (plain sub) [Fact] public async Task Plain_subscription_propagates_remote_interest() { var cluster = Guid.NewGuid().ToString("N"); var a = await StartServerAsync(MakeClusterOpts(cluster)); var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); try { await WaitForRouteFormation(a.Server, b.Server); await using var nc = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{a.Server.Port}", }); await nc.ConnectAsync(); await using var sub = await nc.SubscribeCoreAsync("sub.test"); await nc.PingAsync(); await WaitForCondition(() => b.Server.HasRemoteInterest("sub.test")); b.Server.HasRemoteInterest("sub.test").ShouldBeTrue(); } finally { await DisposeServers(a, b); } } // Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (wildcard * sub) [Fact] public async Task Wildcard_star_subscription_propagates_remote_interest() { var cluster = Guid.NewGuid().ToString("N"); var a = await StartServerAsync(MakeClusterOpts(cluster)); var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); try { await WaitForRouteFormation(a.Server, b.Server); await using var nc = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{a.Server.Port}", }); await nc.ConnectAsync(); await using var sub = await nc.SubscribeCoreAsync("wildcard.*"); await nc.PingAsync(); await WaitForCondition(() => b.Server.HasRemoteInterest("wildcard.test")); b.Server.HasRemoteInterest("wildcard.test").ShouldBeTrue(); b.Server.HasRemoteInterest("wildcard.other").ShouldBeTrue(); b.Server.HasRemoteInterest("no.match").ShouldBeFalse(); } finally { await DisposeServers(a, b); } } // Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (wildcard > sub) [Fact] public async Task Wildcard_gt_subscription_propagates_remote_interest() { var cluster = Guid.NewGuid().ToString("N"); var a = await StartServerAsync(MakeClusterOpts(cluster)); var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); try { await WaitForRouteFormation(a.Server, b.Server); await using var nc = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{a.Server.Port}", }); await nc.ConnectAsync(); await using var sub = await nc.SubscribeCoreAsync("events.>"); await nc.PingAsync(); await WaitForCondition(() => b.Server.HasRemoteInterest("events.a")); b.Server.HasRemoteInterest("events.a").ShouldBeTrue(); b.Server.HasRemoteInterest("events.a.b.c").ShouldBeTrue(); b.Server.HasRemoteInterest("other.a").ShouldBeFalse(); } finally { await DisposeServers(a, b); } } // Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (unsub) [Fact] public async Task Unsubscribe_removes_remote_interest() { var cluster = Guid.NewGuid().ToString("N"); var a = await StartServerAsync(MakeClusterOpts(cluster)); var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); try { await WaitForRouteFormation(a.Server, b.Server); await using var nc = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{a.Server.Port}", }); await nc.ConnectAsync(); var sub = await nc.SubscribeCoreAsync("unsub.test"); await nc.PingAsync(); await WaitForCondition(() => b.Server.HasRemoteInterest("unsub.test")); b.Server.HasRemoteInterest("unsub.test").ShouldBeTrue(); await sub.DisposeAsync(); await nc.PingAsync(); // Wait for interest to be removed await WaitForCondition(() => !b.Server.HasRemoteInterest("unsub.test")); b.Server.HasRemoteInterest("unsub.test").ShouldBeFalse(); } finally { await DisposeServers(a, b); } } [Fact] public async Task Removing_one_subject_keeps_other_remote_interest_intact() { var cluster = Guid.NewGuid().ToString("N"); var a = await StartServerAsync(MakeClusterOpts(cluster)); var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); try { await WaitForRouteFormation(a.Server, b.Server); await using var nc = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{a.Server.Port}", }); await nc.ConnectAsync(); await using var sub1 = await nc.SubscribeCoreAsync("multi.one"); await using var sub2 = await nc.SubscribeCoreAsync("multi.two"); await nc.PingAsync(); await WaitForCondition(() => b.Server.HasRemoteInterest("multi.one") && b.Server.HasRemoteInterest("multi.two")); b.Server.HasRemoteInterest("multi.one").ShouldBeTrue(); b.Server.HasRemoteInterest("multi.two").ShouldBeTrue(); await sub1.DisposeAsync(); await nc.PingAsync(); await WaitForCondition(() => !b.Server.HasRemoteInterest("multi.one")); b.Server.HasRemoteInterest("multi.one").ShouldBeFalse(); b.Server.HasRemoteInterest("multi.two").ShouldBeTrue(); await sub2.DisposeAsync(); await nc.PingAsync(); await WaitForCondition(() => !b.Server.HasRemoteInterest("multi.two")); b.Server.HasRemoteInterest("multi.two").ShouldBeFalse(); } finally { await DisposeServers(a, b); } } // Go: RS+ wire protocol parsing (low-level) [Fact] public async Task RSplus_frame_registers_remote_interest_via_wire() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await remote.ConnectAsync(IPAddress.Loopback, port); using var routeSock = await listener.AcceptSocketAsync(); await using var route = new RouteConnection(routeSock); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); _ = await ReadLineAsync(remote, timeout.Token); await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); await handshakeTask; using var subList = new SubList(); route.RemoteSubscriptionReceived = sub => { subList.ApplyRemoteSub(sub); return Task.CompletedTask; }; route.StartFrameLoop(timeout.Token); await WriteLineAsync(remote, "RS+ $G foo.bar", timeout.Token); await WaitForCondition(() => subList.HasRemoteInterest("foo.bar")); subList.HasRemoteInterest("foo.bar").ShouldBeTrue(); } // Go: RS- wire protocol parsing (low-level) [Fact] public async Task RSminus_frame_removes_remote_interest_via_wire() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await remote.ConnectAsync(IPAddress.Loopback, port); using var routeSock = await listener.AcceptSocketAsync(); await using var route = new RouteConnection(routeSock); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); _ = await ReadLineAsync(remote, timeout.Token); await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); await handshakeTask; using var subList = new SubList(); route.RemoteSubscriptionReceived = sub => { subList.ApplyRemoteSub(sub); return Task.CompletedTask; }; route.StartFrameLoop(timeout.Token); await WriteLineAsync(remote, "RS+ $G foo.*", timeout.Token); await WaitForCondition(() => subList.HasRemoteInterest("foo.bar")); await WriteLineAsync(remote, "RS- $G foo.*", timeout.Token); await WaitForCondition(() => !subList.HasRemoteInterest("foo.bar")); subList.HasRemoteInterest("foo.bar").ShouldBeFalse(); } // Go: RS+ with queue group [Fact] public async Task RSplus_with_queue_group_registers_remote_interest() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await remote.ConnectAsync(IPAddress.Loopback, port); using var routeSock = await listener.AcceptSocketAsync(); await using var route = new RouteConnection(routeSock); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); _ = await ReadLineAsync(remote, timeout.Token); await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); await handshakeTask; RemoteSubscription? received = null; route.RemoteSubscriptionReceived = sub => { received = sub; return Task.CompletedTask; }; route.StartFrameLoop(timeout.Token); await WriteLineAsync(remote, "RS+ $G foo.bar myqueue", timeout.Token); await WaitForCondition(() => received != null); received.ShouldNotBeNull(); received!.Subject.ShouldBe("foo.bar"); received.Queue.ShouldBe("myqueue"); } // Go: RS+ with account scope [Fact] public async Task RSplus_with_account_scope_registers_interest_in_account() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await remote.ConnectAsync(IPAddress.Loopback, port); using var routeSock = await listener.AcceptSocketAsync(); await using var route = new RouteConnection(routeSock); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); _ = await ReadLineAsync(remote, timeout.Token); await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); await handshakeTask; using var subList = new SubList(); route.RemoteSubscriptionReceived = sub => { subList.ApplyRemoteSub(sub); return Task.CompletedTask; }; route.StartFrameLoop(timeout.Token); await WriteLineAsync(remote, "RS+ ACCT_A orders.created", timeout.Token); await WaitForCondition(() => subList.HasRemoteInterest("ACCT_A", "orders.created")); subList.HasRemoteInterest("ACCT_A", "orders.created").ShouldBeTrue(); } // Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 [Fact] public async Task Queue_subscription_propagates_across_route() { var cluster = Guid.NewGuid().ToString("N"); var a = await StartServerAsync(MakeClusterOpts(cluster)); var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); try { await WaitForRouteFormation(a.Server, b.Server); using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await sock.ConnectAsync(IPAddress.Loopback, a.Server.Port); _ = await ReadLineAsync(sock, default); await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo queue1 1\r\nPING\r\n")); await SocketTestHelper.ReadUntilAsync(sock, "PONG"); await WaitForCondition(() => b.Server.HasRemoteInterest("foo")); b.Server.HasRemoteInterest("foo").ShouldBeTrue(); sock.Dispose(); } finally { await DisposeServers(a, b); } } // Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (queue unsub) [Fact] public async Task Queue_subscription_delivery_picks_one_per_group() { var cluster = Guid.NewGuid().ToString("N"); var optsA = MakeClusterOpts(cluster); optsA.Cluster!.PoolSize = 1; var a = await StartServerAsync(optsA); var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!); optsB.Cluster!.PoolSize = 1; var b = await StartServerAsync(optsB); try { await WaitForRouteFormation(a.Server, b.Server); await using var nc1 = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{a.Server.Port}", }); await nc1.ConnectAsync(); await using var nc2 = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{a.Server.Port}", }); await nc2.ConnectAsync(); await using var sub1 = await nc1.SubscribeCoreAsync("queue.test", queueGroup: "grp"); await using var sub2 = await nc2.SubscribeCoreAsync("queue.test", queueGroup: "grp"); await nc1.PingAsync(); await nc2.PingAsync(); await WaitForCondition(() => b.Server.HasRemoteInterest("queue.test")); await using var publisher = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{b.Server.Port}", }); await publisher.ConnectAsync(); // Send 10 messages. Each should go to exactly one queue member. for (var i = 0; i < 10; i++) await publisher.PublishAsync("queue.test", $"qmsg-{i}"); // Collect messages from both subscribers var received = 0; using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); async Task CollectMessages(INatsSub sub) { try { while (!timeout.IsCancellationRequested) { _ = await sub.Msgs.ReadAsync(timeout.Token); Interlocked.Increment(ref received); } } catch (OperationCanceledException) { } } var t1 = CollectMessages(sub1); var t2 = CollectMessages(sub2); // Wait for all messages await WaitForCondition(() => Volatile.Read(ref received) >= 10, 5000); // Total received should be exactly 10 (one per message) Volatile.Read(ref received).ShouldBe(10); } finally { await DisposeServers(a, b); } } // Go: Interest propagation for multiple subjects [Fact] public async Task Multiple_subjects_propagate_independently() { var cluster = Guid.NewGuid().ToString("N"); var a = await StartServerAsync(MakeClusterOpts(cluster)); var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); try { await WaitForRouteFormation(a.Server, b.Server); await using var nc = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{a.Server.Port}", }); await nc.ConnectAsync(); await using var sub1 = await nc.SubscribeCoreAsync("alpha"); await using var sub2 = await nc.SubscribeCoreAsync("beta"); await nc.PingAsync(); await WaitForCondition(() => b.Server.HasRemoteInterest("alpha") && b.Server.HasRemoteInterest("beta")); b.Server.HasRemoteInterest("alpha").ShouldBeTrue(); b.Server.HasRemoteInterest("beta").ShouldBeTrue(); b.Server.HasRemoteInterest("gamma").ShouldBeFalse(); } finally { await DisposeServers(a, b); } } // Go: RS+ account scope with NatsClient auth [Fact] public async Task Account_scoped_subscription_propagates_remote_interest() { var users = new User[] { new() { Username = "user_a", Password = "pass", Account = "A" }, new() { Username = "user_b", Password = "pass", Account = "B" }, }; var cluster = Guid.NewGuid().ToString("N"); var optsA = new NatsOptions { Host = "127.0.0.1", Port = 0, Users = users, Cluster = new ClusterOptions { Name = cluster, Host = "127.0.0.1", Port = 0, }, }; var a = await StartServerAsync(optsA); var optsB = new NatsOptions { Host = "127.0.0.1", Port = 0, Users = users, Cluster = new ClusterOptions { Name = cluster, Host = "127.0.0.1", Port = 0, Routes = [a.Server.ClusterListen!], }, }; var b = await StartServerAsync(optsB); try { await WaitForRouteFormation(a.Server, b.Server); await using var nc = new NatsConnection(new NatsOpts { Url = $"nats://user_a:pass@127.0.0.1:{a.Server.Port}", }); await nc.ConnectAsync(); await using var sub = await nc.SubscribeCoreAsync("acct.sub"); await nc.PingAsync(); await WaitForCondition(() => b.Server.HasRemoteInterest("A", "acct.sub")); b.Server.HasRemoteInterest("A", "acct.sub").ShouldBeTrue(); // Account B should NOT have interest b.Server.HasRemoteInterest("B", "acct.sub").ShouldBeFalse(); } finally { await DisposeServers(a, b); } } // Go: TestRoutePerAccount server/routes_test.go:2539 [Fact] public async Task Account_scoped_messages_do_not_leak_to_other_accounts() { var users = new User[] { new() { Username = "ua", Password = "p", Account = "A" }, new() { Username = "ub", Password = "p", Account = "B" }, }; var cluster = Guid.NewGuid().ToString("N"); var optsA = new NatsOptions { Host = "127.0.0.1", Port = 0, Users = users, Cluster = new ClusterOptions { Name = cluster, Host = "127.0.0.1", Port = 0, }, }; var a = await StartServerAsync(optsA); var optsB = new NatsOptions { Host = "127.0.0.1", Port = 0, Users = users, Cluster = new ClusterOptions { Name = cluster, Host = "127.0.0.1", Port = 0, Routes = [a.Server.ClusterListen!], }, }; var b = await StartServerAsync(optsB); try { await WaitForRouteFormation(a.Server, b.Server); // Subscribe in account A on server B await using var subA = new NatsConnection(new NatsOpts { Url = $"nats://ua:p@127.0.0.1:{b.Server.Port}", }); await subA.ConnectAsync(); await using var sub = await subA.SubscribeCoreAsync("isolation.test"); await subA.PingAsync(); // Subscribe in account B on server B await using var subB = new NatsConnection(new NatsOpts { Url = $"nats://ub:p@127.0.0.1:{b.Server.Port}", }); await subB.ConnectAsync(); await using var subBSub = await subB.SubscribeCoreAsync("isolation.test"); await subB.PingAsync(); await WaitForCondition(() => a.Server.HasRemoteInterest("A", "isolation.test")); // Publish in account A from server A await using var pub = new NatsConnection(new NatsOpts { Url = $"nats://ua:p@127.0.0.1:{a.Server.Port}", }); await pub.ConnectAsync(); await pub.PublishAsync("isolation.test", "for-account-a"); // Account A subscriber should receive the message using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token); msg.Data.ShouldBe("for-account-a"); // Account B subscriber should NOT receive it using var leakTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); await Should.ThrowAsync(async () => await subBSub.Msgs.ReadAsync(leakTimeout.Token)); } finally { await DisposeServers(a, b); } } // Go: Subscriber disconnect removes interest [Fact] public async Task Client_disconnect_removes_remote_interest() { var cluster = Guid.NewGuid().ToString("N"); var optsA = MakeClusterOpts(cluster); optsA.Cluster!.PoolSize = 1; var a = await StartServerAsync(optsA); var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!); optsB.Cluster!.PoolSize = 1; var b = await StartServerAsync(optsB); try { await WaitForRouteFormation(a.Server, b.Server); var nc = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{a.Server.Port}", }); await nc.ConnectAsync(); var sub = await nc.SubscribeCoreAsync("disconnect.test"); await nc.PingAsync(); await WaitForCondition(() => b.Server.HasRemoteInterest("disconnect.test")); b.Server.HasRemoteInterest("disconnect.test").ShouldBeTrue(); // Unsubscribe and disconnect the client await sub.DisposeAsync(); await nc.PingAsync(); await nc.DisposeAsync(); // Interest should be removed (give extra time for propagation) await WaitForCondition(() => !b.Server.HasRemoteInterest("disconnect.test"), 15000); b.Server.HasRemoteInterest("disconnect.test").ShouldBeFalse(); } finally { await DisposeServers(a, b); } } // Go: Interest idempotency [Fact] public async Task Duplicate_subscription_on_same_subject_does_not_double_count() { var cluster = Guid.NewGuid().ToString("N"); var a = await StartServerAsync(MakeClusterOpts(cluster)); var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); try { await WaitForRouteFormation(a.Server, b.Server); await using var nc1 = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{a.Server.Port}", }); await nc1.ConnectAsync(); await using var nc2 = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{a.Server.Port}", }); await nc2.ConnectAsync(); await using var sub1 = await nc1.SubscribeCoreAsync("dup.test"); await using var sub2 = await nc2.SubscribeCoreAsync("dup.test"); await nc1.PingAsync(); await nc2.PingAsync(); await WaitForCondition(() => b.Server.HasRemoteInterest("dup.test")); // Publish from B; should be delivered to both local subscribers on A await using var pub = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{b.Server.Port}", }); await pub.ConnectAsync(); await pub.PublishAsync("dup.test", "to-both"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var msg1 = await sub1.Msgs.ReadAsync(timeout.Token); var msg2 = await sub2.Msgs.ReadAsync(timeout.Token); msg1.Data.ShouldBe("to-both"); msg2.Data.ShouldBe("to-both"); } finally { await DisposeServers(a, b); } } // Go: Wildcard delivery [Fact] public async Task Wildcard_subscription_delivers_matching_messages_across_route() { var cluster = Guid.NewGuid().ToString("N"); var a = await StartServerAsync(MakeClusterOpts(cluster)); var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); try { await WaitForRouteFormation(a.Server, b.Server); await using var nc = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{a.Server.Port}", }); await nc.ConnectAsync(); await using var sub = await nc.SubscribeCoreAsync("data.>"); await nc.PingAsync(); await WaitForCondition(() => b.Server.HasRemoteInterest("data.sensor.1")); await using var pub = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{b.Server.Port}", }); await pub.ConnectAsync(); await pub.PublishAsync("data.sensor.1", "reading"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var msg = await sub.Msgs.ReadAsync(timeout.Token); msg.Subject.ShouldBe("data.sensor.1"); msg.Data.ShouldBe("reading"); } finally { await DisposeServers(a, b); } } // Go: No messages for non-matching subjects [Fact] public async Task Non_matching_subject_not_forwarded_across_route() { var cluster = Guid.NewGuid().ToString("N"); var a = await StartServerAsync(MakeClusterOpts(cluster)); var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); try { await WaitForRouteFormation(a.Server, b.Server); await using var nc = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{a.Server.Port}", }); await nc.ConnectAsync(); await using var sub = await nc.SubscribeCoreAsync("specific.topic"); await nc.PingAsync(); await WaitForCondition(() => b.Server.HasRemoteInterest("specific.topic")); await using var pub = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{b.Server.Port}", }); await pub.ConnectAsync(); // Publish to a non-matching subject await pub.PublishAsync("other.topic", "should-not-arrive"); // Publish to the matching subject await pub.PublishAsync("specific.topic", "should-arrive"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var msg = await sub.Msgs.ReadAsync(timeout.Token); msg.Data.ShouldBe("should-arrive"); } finally { await DisposeServers(a, b); } } // -- Wire-level helpers -- private static async Task ReadLineAsync(Socket socket, CancellationToken ct) { var bytes = new List(64); var single = new byte[1]; using var cts = ct.CanBeNone() ? new CancellationTokenSource(TimeSpan.FromSeconds(5)) : null; var effectiveCt = cts?.Token ?? ct; while (true) { var read = await socket.ReceiveAsync(single, SocketFlags.None, effectiveCt); if (read == 0) break; if (single[0] == (byte)'\n') break; if (single[0] != (byte)'\r') bytes.Add(single[0]); } return Encoding.ASCII.GetString([.. bytes]); } private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct) => socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask(); } file static class CancellationTokenExtensions { public static bool CanBeNone(this CancellationToken ct) => ct == default; }