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;
}