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.Gateways; using NATS.Server.Subscriptions; namespace NATS.Server.Tests.Gateways; /// /// Gateway message forwarding, reply mapping, queue subscription delivery, /// and cross-cluster pub/sub tests. /// Ported from golang/nats-server/server/gateway_test.go. /// public class GatewayForwardingTests { // ── Basic Message Forwarding ──────────────────────────────────────── // Go: TestGatewayBasic server/gateway_test.go:399 [Fact] public async Task Message_published_on_local_arrives_at_remote_subscriber() { await using var fixture = await ForwardingFixture.StartAsync(); await using var subscriber = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await subscriber.ConnectAsync(); await using var publisher = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Local.Port}", }); await publisher.ConnectAsync(); await using var sub = await subscriber.SubscribeCoreAsync("fwd.test"); await subscriber.PingAsync(); await fixture.WaitForRemoteInterestOnLocalAsync("fwd.test"); await publisher.PublishAsync("fwd.test", "hello-world"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var msg = await sub.Msgs.ReadAsync(timeout.Token); msg.Data.ShouldBe("hello-world"); } // Go: TestGatewayBasic server/gateway_test.go:399 [Fact] public async Task Message_published_on_remote_arrives_at_local_subscriber() { await using var fixture = await ForwardingFixture.StartAsync(); await using var subscriber = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Local.Port}", }); await subscriber.ConnectAsync(); await using var publisher = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await publisher.ConnectAsync(); await using var sub = await subscriber.SubscribeCoreAsync("fwd.reverse"); await subscriber.PingAsync(); await fixture.WaitForRemoteInterestOnRemoteAsync("fwd.reverse"); await publisher.PublishAsync("fwd.reverse", "reverse-msg"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var msg = await sub.Msgs.ReadAsync(timeout.Token); msg.Data.ShouldBe("reverse-msg"); } // Go: TestGatewayMsgSentOnlyOnce server/gateway_test.go:2993 [Fact] public async Task Message_forwarded_only_once_to_remote_subscriber() { await using var fixture = await ForwardingFixture.StartAsync(); await using var subscriber = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await subscriber.ConnectAsync(); await using var publisher = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Local.Port}", }); await publisher.ConnectAsync(); await using var sub = await subscriber.SubscribeCoreAsync("once.test"); await subscriber.PingAsync(); await fixture.WaitForRemoteInterestOnLocalAsync("once.test"); await publisher.PublishAsync("once.test", "exactly-once"); await publisher.PingAsync(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); var msg = await sub.Msgs.ReadAsync(timeout.Token); msg.Data.ShouldBe("exactly-once"); // Wait and verify no duplicates await Task.Delay(300); using var noMoreTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); await Should.ThrowAsync(async () => await sub.Msgs.ReadAsync(noMoreTimeout.Token)); } // Go: TestGatewaySendsToNonLocalSubs server/gateway_test.go:3140 [Fact] public async Task Message_without_local_subscriber_forwarded_to_remote() { await using var fixture = await ForwardingFixture.StartAsync(); await using var subscriber = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await subscriber.ConnectAsync(); await using var publisher = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Local.Port}", }); await publisher.ConnectAsync(); // Subscribe only on remote, no local subscriber await using var sub = await subscriber.SubscribeCoreAsync("only.remote"); await subscriber.PingAsync(); await fixture.WaitForRemoteInterestOnLocalAsync("only.remote"); await publisher.PublishAsync("only.remote", "no-local"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var msg = await sub.Msgs.ReadAsync(timeout.Token); msg.Data.ShouldBe("no-local"); } // Go: TestGatewayDoesntSendBackToItself server/gateway_test.go:2150 [Fact] public async Task Both_local_and_remote_subscribers_receive_message_published_locally() { await using var fixture = await ForwardingFixture.StartAsync(); await using var remoteConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await remoteConn.ConnectAsync(); await using var localConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Local.Port}", }); await localConn.ConnectAsync(); await using var remoteSub = await remoteConn.SubscribeCoreAsync("both.test"); await remoteConn.PingAsync(); await using var localSub = await localConn.SubscribeCoreAsync("both.test"); await localConn.PingAsync(); await fixture.WaitForRemoteInterestOnLocalAsync("both.test"); await localConn.PublishAsync("both.test", "shared"); await localConn.PingAsync(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var localMsg = await localSub.Msgs.ReadAsync(timeout.Token); localMsg.Data.ShouldBe("shared"); var remoteMsg = await remoteSub.Msgs.ReadAsync(timeout.Token); remoteMsg.Data.ShouldBe("shared"); } // ── Wildcard Subject Forwarding ───────────────────────────────────── // Go: TestGatewaySubjectInterest server/gateway_test.go:1972 [Fact] public async Task Wildcard_subscription_receives_matching_gateway_messages() { await using var fixture = await ForwardingFixture.StartAsync(); await using var subscriber = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await subscriber.ConnectAsync(); await using var publisher = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Local.Port}", }); await publisher.ConnectAsync(); await using var sub = await subscriber.SubscribeCoreAsync("wc.>"); await subscriber.PingAsync(); await fixture.WaitForRemoteInterestOnLocalAsync("wc.test.one"); await publisher.PublishAsync("wc.test.one", "wildcard-msg"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var msg = await sub.Msgs.ReadAsync(timeout.Token); msg.Subject.ShouldBe("wc.test.one"); msg.Data.ShouldBe("wildcard-msg"); } // Go: TestGatewaySubjectInterest server/gateway_test.go:1972 [Fact] public async Task Partial_wildcard_subscription_receives_gateway_messages() { await using var fixture = await ForwardingFixture.StartAsync(); await using var subscriber = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await subscriber.ConnectAsync(); await using var publisher = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Local.Port}", }); await publisher.ConnectAsync(); await using var sub = await subscriber.SubscribeCoreAsync("orders.*"); await subscriber.PingAsync(); await fixture.WaitForRemoteInterestOnLocalAsync("orders.created"); await publisher.PublishAsync("orders.created", "order-1"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var msg = await sub.Msgs.ReadAsync(timeout.Token); msg.Subject.ShouldBe("orders.created"); msg.Data.ShouldBe("order-1"); } // ── Reply Subject Mapping (_GR_. Prefix) ──────────────────────────── // Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165 [Fact] public void Reply_mapper_adds_gr_prefix_with_cluster_id() { var mapped = ReplyMapper.ToGatewayReply("_INBOX.abc", "CLUSTER-A"); mapped.ShouldNotBeNull(); mapped.ShouldStartWith("_GR_."); mapped.ShouldContain("CLUSTER-A"); } // Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165 [Fact] public void Reply_mapper_restores_original_reply() { var original = "_INBOX.abc123"; var mapped = ReplyMapper.ToGatewayReply(original, "C1"); mapped.ShouldNotBeNull(); ReplyMapper.TryRestoreGatewayReply(mapped, out var restored).ShouldBeTrue(); restored.ShouldBe(original); } // Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165 [Fact] public void Reply_mapper_handles_nested_gr_prefixes() { var original = "_INBOX.reply1"; var once = ReplyMapper.ToGatewayReply(original, "CLUSTER-A"); var twice = ReplyMapper.ToGatewayReply(once, "CLUSTER-B"); ReplyMapper.TryRestoreGatewayReply(twice!, out var restored).ShouldBeTrue(); restored.ShouldBe(original); } // Go: TestGatewayClientsDontReceiveMsgsOnGWPrefix server/gateway_test.go:5586 [Fact] public void Reply_mapper_returns_null_for_null_input() { var result = ReplyMapper.ToGatewayReply(null, "CLUSTER"); result.ShouldBeNull(); } // Go: TestGatewayClientsDontReceiveMsgsOnGWPrefix server/gateway_test.go:5586 [Fact] public void Reply_mapper_returns_empty_for_empty_input() { var result = ReplyMapper.ToGatewayReply("", "CLUSTER"); result.ShouldBe(""); } // Go: TestGatewayClientsDontReceiveMsgsOnGWPrefix server/gateway_test.go:5586 [Fact] public void Has_gateway_reply_prefix_detects_gr_prefix() { ReplyMapper.HasGatewayReplyPrefix("_GR_.CLUSTER.inbox").ShouldBeTrue(); ReplyMapper.HasGatewayReplyPrefix("_INBOX.abc").ShouldBeFalse(); ReplyMapper.HasGatewayReplyPrefix(null).ShouldBeFalse(); ReplyMapper.HasGatewayReplyPrefix("").ShouldBeFalse(); } // Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165 [Fact] public void Restore_returns_false_for_non_gr_subject() { ReplyMapper.TryRestoreGatewayReply("_INBOX.abc", out _).ShouldBeFalse(); } // Go: TestGatewayReplyMapTracking server/gateway_test.go:6017 [Fact] public void Restore_returns_false_for_malformed_gr_subject() { // _GR_. with no cluster separator ReplyMapper.TryRestoreGatewayReply("_GR_.nodot", out _).ShouldBeFalse(); } // Go: TestGatewayReplyMapTracking server/gateway_test.go:6017 [Fact] public void Restore_returns_false_for_gr_prefix_with_nothing_after_separator() { ReplyMapper.TryRestoreGatewayReply("_GR_.CLUSTER.", out _).ShouldBeFalse(); } // ── Queue Subscription Forwarding ─────────────────────────────────── // Go: TestGatewayQueueSub server/gateway_test.go:2265 [Fact] public async Task Queue_subscription_interest_tracked_on_remote() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G")); subList.HasRemoteInterest("$G", "foo").ShouldBeTrue(); subList.MatchRemote("$G", "foo").Count.ShouldBe(1); } // Go: TestGatewayQueueSub server/gateway_test.go:2265 [Fact] public async Task Queue_subscription_with_multiple_groups_all_tracked() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G")); subList.ApplyRemoteSub(new RemoteSubscription("foo", "baz", "gw1", "$G")); subList.MatchRemote("$G", "foo").Count.ShouldBe(2); } // Go: TestGatewayQueueSub server/gateway_test.go:2265 [Fact] public async Task Queue_sub_removal_clears_remote_interest() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G")); subList.HasRemoteInterest("$G", "foo").ShouldBeTrue(); subList.ApplyRemoteSub(RemoteSubscription.Removal("foo", "bar", "gw1", "$G")); subList.HasRemoteInterest("$G", "foo").ShouldBeFalse(); } // ── GatewayManager Forwarding ─────────────────────────────────────── // Go: TestGatewayBasic server/gateway_test.go:399 [Fact] public async Task Gateway_manager_forward_message_increments_js_counter() { var manager = new GatewayManager( new GatewayOptions { Name = "GW", Host = "127.0.0.1", Port = 0 }, new ServerStats(), "S1", _ => { }, _ => { }, NullLogger.Instance); await manager.ForwardJetStreamClusterMessageAsync( new GatewayMessage("$JS.CLUSTER.test", null, "x"u8.ToArray()), default); manager.ForwardedJetStreamClusterMessages.ShouldBe(1); } // Go: TestGatewayBasic server/gateway_test.go:399 [Fact] public async Task Gateway_manager_forward_js_message_multiple_times() { var manager = new GatewayManager( new GatewayOptions { Name = "GW", Host = "127.0.0.1", Port = 0 }, new ServerStats(), "S1", _ => { }, _ => { }, NullLogger.Instance); for (int i = 0; i < 5; i++) { await manager.ForwardJetStreamClusterMessageAsync( new GatewayMessage("$JS.CLUSTER.test", null, "x"u8.ToArray()), default); } manager.ForwardedJetStreamClusterMessages.ShouldBe(5); } // ── Multiple Messages ─────────────────────────────────────────────── // Go: TestGatewayMsgSentOnlyOnce server/gateway_test.go:2993 [Fact] public async Task Multiple_messages_forwarded_across_gateway() { await using var fixture = await ForwardingFixture.StartAsync(); await using var subscriber = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await subscriber.ConnectAsync(); await using var publisher = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Local.Port}", }); await publisher.ConnectAsync(); await using var sub = await subscriber.SubscribeCoreAsync("multi.test"); await subscriber.PingAsync(); await fixture.WaitForRemoteInterestOnLocalAsync("multi.test"); const int count = 10; for (int i = 0; i < count; i++) { await publisher.PublishAsync("multi.test", $"msg-{i}"); } await publisher.PingAsync(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var received = new List(); for (int i = 0; i < count; i++) { var msg = await sub.Msgs.ReadAsync(timeout.Token); received.Add(msg.Data!); } received.Count.ShouldBe(count); for (int i = 0; i < count; i++) { received.ShouldContain($"msg-{i}"); } } // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 // Verifies that a message published on local with a reply-to subject is forwarded // to the remote with the reply-to intact, allowing manual request-reply across gateway. [Fact] public async Task Message_with_reply_to_forwarded_across_gateway() { await using var fixture = await ForwardingFixture.StartAsync(); await using var remoteConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await remoteConn.ConnectAsync(); // Subscribe on remote for requests await using var sub = await remoteConn.SubscribeCoreAsync("svc.request"); await remoteConn.PingAsync(); await fixture.WaitForRemoteInterestOnLocalAsync("svc.request"); // Publish from local with a reply-to subject via raw socket using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await sock.ConnectAsync(IPAddress.Loopback, fixture.Local.Port); var infoBuf = new byte[4096]; _ = await sock.ReceiveAsync(infoBuf); // read INFO await sock.SendAsync(Encoding.ASCII.GetBytes( "CONNECT {}\r\nPUB svc.request _INBOX.reply123 12\r\nrequest-data\r\nPING\r\n")); // Wait for PONG to confirm the message was processed var pongBuf = new byte[4096]; var pongTotal = new StringBuilder(); using var pongCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!pongTotal.ToString().Contains("PONG")) { var n = await sock.ReceiveAsync(pongBuf, SocketFlags.None, pongCts.Token); if (n == 0) break; pongTotal.Append(Encoding.ASCII.GetString(pongBuf, 0, n)); } using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var msg = await sub.Msgs.ReadAsync(timeout.Token); msg.Data.ShouldBe("request-data"); msg.ReplyTo.ShouldNotBeNullOrEmpty(); } // ── Account Scoped Forwarding ─────────────────────────────────────── // Go: TestGatewayAccountInterest server/gateway_test.go:1794 [Fact] public async Task Messages_forwarded_within_same_account_only() { var users = new User[] { new() { Username = "user_a", Password = "pass", Account = "ACCT_A" }, new() { Username = "user_b", Password = "pass", Account = "ACCT_B" }, }; await using var fixture = await ForwardingFixture.StartWithUsersAsync(users); await using var remoteA = new NatsConnection(new NatsOpts { Url = $"nats://user_a:pass@127.0.0.1:{fixture.Remote.Port}", }); await remoteA.ConnectAsync(); await using var remoteB = new NatsConnection(new NatsOpts { Url = $"nats://user_b:pass@127.0.0.1:{fixture.Remote.Port}", }); await remoteB.ConnectAsync(); await using var publisherA = new NatsConnection(new NatsOpts { Url = $"nats://user_a:pass@127.0.0.1:{fixture.Local.Port}", }); await publisherA.ConnectAsync(); await using var subA = await remoteA.SubscribeCoreAsync("acct.test"); await using var subB = await remoteB.SubscribeCoreAsync("acct.test"); await remoteA.PingAsync(); await remoteB.PingAsync(); await fixture.WaitForRemoteInterestOnLocalAsync("ACCT_A", "acct.test"); await publisherA.PublishAsync("acct.test", "for-account-a"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var msgA = await subA.Msgs.ReadAsync(timeout.Token); msgA.Data.ShouldBe("for-account-a"); // Account B should not receive using var noMsgTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); await Should.ThrowAsync(async () => await subB.Msgs.ReadAsync(noMsgTimeout.Token)); } // Go: TestGatewayAccountInterest server/gateway_test.go:1794 [Fact] public async Task Non_matching_subject_not_forwarded_after_interest_established() { await using var fixture = await ForwardingFixture.StartAsync(); await using var subscriber = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await subscriber.ConnectAsync(); await using var publisher = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Local.Port}", }); await publisher.ConnectAsync(); // Subscribe to a specific subject await using var sub = await subscriber.SubscribeCoreAsync("specific.topic"); await subscriber.PingAsync(); await fixture.WaitForRemoteInterestOnLocalAsync("specific.topic"); // Publish to a different subject await publisher.PublishAsync("other.topic", "should-not-arrive"); await publisher.PingAsync(); await Task.Delay(300); using var noMsgTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); await Should.ThrowAsync(async () => await sub.Msgs.ReadAsync(noMsgTimeout.Token)); } // Go: TestGatewayNoCrashOnInvalidSubject server/gateway_test.go:6279 [Fact] public void GatewayMessage_record_stores_all_fields() { var payload = new byte[] { 1, 2, 3 }; var msg = new GatewayMessage("test.subject", "_INBOX.reply", payload, "MYACCT"); msg.Subject.ShouldBe("test.subject"); msg.ReplyTo.ShouldBe("_INBOX.reply"); msg.Payload.Length.ShouldBe(3); msg.Account.ShouldBe("MYACCT"); } // Go: TestGatewayBasic server/gateway_test.go:399 [Fact] public void GatewayMessage_defaults_account_to_global() { var msg = new GatewayMessage("test.subject", null, new byte[] { }); msg.Account.ShouldBe("$G"); } // ── Interest-Only Mode and ShouldForwardInterestOnly ──────────────── // Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934 [Fact] public void Should_forward_interest_only_returns_true_when_interest_exists() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "A")); GatewayManager.ShouldForwardInterestOnly(subList, "A", "orders.created").ShouldBeTrue(); } // Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934 [Fact] public void Should_forward_interest_only_returns_false_without_interest() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "A")); GatewayManager.ShouldForwardInterestOnly(subList, "A", "payments.created").ShouldBeFalse(); } // Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934 [Fact] public void Should_forward_interest_only_for_different_account_returns_false() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "A")); GatewayManager.ShouldForwardInterestOnly(subList, "B", "orders.created").ShouldBeFalse(); } // Go: TestGatewaySubjectInterest server/gateway_test.go:1972 [Fact] public void Should_forward_with_wildcard_interest() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("test.*", null, "gw1", "$G")); GatewayManager.ShouldForwardInterestOnly(subList, "$G", "test.one").ShouldBeTrue(); GatewayManager.ShouldForwardInterestOnly(subList, "$G", "test.two").ShouldBeTrue(); GatewayManager.ShouldForwardInterestOnly(subList, "$G", "other.one").ShouldBeFalse(); } // Go: TestGatewaySubjectInterest server/gateway_test.go:1972 [Fact] public void Should_forward_with_fwc_interest() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("events.>", null, "gw1", "$G")); GatewayManager.ShouldForwardInterestOnly(subList, "$G", "events.a.b.c").ShouldBeTrue(); GatewayManager.ShouldForwardInterestOnly(subList, "$G", "other.x").ShouldBeFalse(); } } /// /// Shared fixture for forwarding tests that need two running server clusters. /// internal sealed class ForwardingFixture : IAsyncDisposable { private readonly CancellationTokenSource _localCts; private readonly CancellationTokenSource _remoteCts; private ForwardingFixture(NatsServer local, NatsServer remote, CancellationTokenSource localCts, CancellationTokenSource remoteCts) { Local = local; Remote = remote; _localCts = localCts; _remoteCts = remoteCts; } public NatsServer Local { get; } public NatsServer Remote { get; } public static Task StartAsync() => StartWithUsersAsync(null); public static async Task StartWithUsersAsync(IReadOnlyList? users) { var localOptions = new NatsOptions { Host = "127.0.0.1", Port = 0, Users = users, Gateway = new GatewayOptions { Name = "LOCAL", Host = "127.0.0.1", Port = 0, }, }; var local = new NatsServer(localOptions, NullLoggerFactory.Instance); var localCts = new CancellationTokenSource(); _ = local.StartAsync(localCts.Token); await local.WaitForReadyAsync(); var remoteOptions = new NatsOptions { Host = "127.0.0.1", Port = 0, Users = users, Gateway = new GatewayOptions { Name = "REMOTE", Host = "127.0.0.1", Port = 0, Remotes = [local.GatewayListen!], }, }; var remote = new NatsServer(remoteOptions, NullLoggerFactory.Instance); var remoteCts = new CancellationTokenSource(); _ = remote.StartAsync(remoteCts.Token); await remote.WaitForReadyAsync(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested && (local.Stats.Gateways == 0 || remote.Stats.Gateways == 0)) await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); return new ForwardingFixture(local, remote, localCts, remoteCts); } public async Task WaitForRemoteInterestOnLocalAsync(string subject) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested) { if (Local.HasRemoteInterest(subject)) return; await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); } throw new TimeoutException($"Timed out waiting for remote interest on '{subject}'."); } public async Task WaitForRemoteInterestOnLocalAsync(string account, string subject) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested) { if (Local.HasRemoteInterest(account, subject)) return; await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); } throw new TimeoutException($"Timed out waiting for remote interest {account}:{subject}."); } public async Task WaitForRemoteInterestOnRemoteAsync(string subject) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested) { if (Remote.HasRemoteInterest(subject)) return; await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); } throw new TimeoutException($"Timed out waiting for remote interest on '{subject}'."); } public async ValueTask DisposeAsync() { await _localCts.CancelAsync(); await _remoteCts.CancelAsync(); Local.Dispose(); Remote.Dispose(); _localCts.Dispose(); _remoteCts.Dispose(); } }