using System.Diagnostics; using System.Net; using System.Net.Security; using System.Net.Sockets; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using MQTTnet; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// /// Covers 's two Task-3 responsibilities: the pure /// options assembly (TLS / CA pin / /// credentials / protocol knobs) and the bounded /// deadline. /// public sealed class MqttConnectionTests { private const string Host = "broker.example.test"; // --------------------------------------------------------------------------------- // Bounded connect (the R2-01 frozen-peer lesson: no unbounded wait on a dead broker) // --------------------------------------------------------------------------------- /// /// A broker that completes the TCP handshake but never answers CONNACK is the shape that /// actually wedges a client — a refused port returns ECONNREFUSED instantly and would pass /// whether or not any deadline logic existed. /// [Fact] public async Task ConnectAsync_BlackholeBroker_FailsWithinDeadline_NoHang() { using var blackhole = new BlackholeBroker(); var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 2, }; await using var conn = new MqttConnection(opts, driverId: "t", logger: null); var sw = Stopwatch.StartNew(); await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); sw.Stop(); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); conn.IsConnected.ShouldBeFalse(); } /// /// Falsifiability control for the linked-CTS wiring specifically: the configured connect /// deadline is 60 s, so the only thing that can end this call inside 10 s is the caller's /// token actually reaching MQTTnet. Drop the CreateLinkedTokenSource and this test /// hangs for a minute. /// [Fact] public async Task ConnectAsync_CallerCancellation_ObservedLongBeforeConnectDeadline() { using var blackhole = new BlackholeBroker(); var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 60, }; await using var conn = new MqttConnection(opts, driverId: "t", logger: null); using var caller = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); var sw = Stopwatch.StartNew(); await Should.ThrowAsync(async () => await conn.ConnectAsync(caller.Token)); sw.Stop(); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); } /// /// The deadline leg is distinguishable from a caller cancellation — a timed-out connect /// surfaces , not a bare , /// so an operator can tell "broker never answered" from "we were asked to stop". /// [Fact] public async Task ConnectAsync_DeadlineElapsed_ThrowsTimeoutException() { using var blackhole = new BlackholeBroker(); var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1, }; await using var conn = new MqttConnection(opts, driverId: "t", logger: null); await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); } /// /// Task 4's reconnect loop retries connect on the SAME instance, reusing the /// IMqttClient held across failures. A failed attempt must therefore leave the /// connection usable rather than poisoned. /// [Fact] public async Task ConnectAsync_CanBeRetriedOnTheSameInstanceAfterAFailedAttempt() { using var blackhole = new BlackholeBroker(); var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1, }; await using var conn = new MqttConnection(opts, driverId: "t", logger: null); await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); // Second attempt reaches the broker again and fails the same way — not ObjectDisposed, // not a null client, not a silently-swallowed no-op. await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); conn.IsConnected.ShouldBeFalse(); } /// /// Connecting a disposed instance is an — the same /// classification the in-flight dispose race folds into. Dispose is idempotent. /// [Fact] public async Task ConnectAsync_AfterDispose_ThrowsObjectDisposed_AndDisposeIsIdempotent() { var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 1 }; var conn = new MqttConnection(opts, driverId: "t", logger: null); await conn.DisposeAsync(); await conn.DisposeAsync(); await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); } /// Never log or throw credentials — the plan's cross-cutting secrets rule. [Fact] public async Task ConnectAsync_Failure_DoesNotLeakPasswordIntoExceptionMessage() { const string secret = "sup3r-s3cret-broker-pw"; using var blackhole = new BlackholeBroker(); var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1, Username = "svc", Password = secret, }; await using var conn = new MqttConnection(opts, driverId: "t", logger: null); var ex = await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); ex.ToString().ShouldNotContain(secret); } // --------------------------------------------------------------------------------- // BuildClientOptions — pure assembly, no I/O // --------------------------------------------------------------------------------- [Fact] public void BuildClientOptions_UseTlsWithCaPin_SetsTlsAndInstallsChainValidator() { var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, AllowUntrustedServerCertificate = false, CaCertificatePath = "/tmp/ca.pem", }; var tls = BuildTls(opts); tls.UseTls.ShouldBeTrue(); tls.TargetHost.ShouldBe(Host); tls.AllowUntrustedCertificates.ShouldBeFalse(); tls.IgnoreCertificateChainErrors.ShouldBeFalse(); tls.CertificateValidationHandler.ShouldNotBeNull(); } /// /// The CA file is read lazily inside the validation callback, never by the builder — so a /// path that does not exist yet must not throw at build time, and must fail the *connection* /// closed when the handshake finally asks. /// [Fact] public void BuildClientOptions_CaPinPathMissing_BuildsWithoutIo_AndValidatorFailsClosed() { var missing = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-no-such-ca-{Guid.NewGuid():N}.pem"); File.Exists(missing).ShouldBeFalse(); var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = missing }; var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions; tls.CertificateValidationHandler.ShouldNotBeNull(); using var stranger = SelfSignedLeaf(Host); tls.CertificateValidationHandler(ValidationArgs(built, stranger)).ShouldBeFalse(); } /// An untrusted, unpinned server certificate is rejected — the pin actually validates. [Fact] public void BuildClientOptions_CaPin_RejectsCertificateNotIssuedByThePinnedCa() { using var ca = IssueCa(issuer: null, "otopcua-test-ca", validDays: 30); var caPath = WritePem(ca); try { var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath }; var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions; using var stranger = SelfSignedLeaf(Host); tls.CertificateValidationHandler!(ValidationArgs(built, stranger)).ShouldBeFalse(); } finally { File.Delete(caPath); } } /// /// The accept path. Without this every certificate test asserts rejection, so deleting the /// return true — or getting TrustMode / CustomTrustStore wrong — would /// leave the suite green while no broker could ever connect. /// [Fact] public void BuildClientOptions_CaPin_AcceptsLeafIssuedByThePinnedCa() { using var ca = IssueCa(issuer: null, "otopcua-test-ca", validDays: 30); using var leaf = IssueLeaf(ca, Host, validDays: 20); var caPath = WritePem(ca); try { var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath }; var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions; tls.CertificateValidationHandler!(ValidationArgs(built, leaf)).ShouldBeTrue(); } finally { File.Delete(caPath); } } /// /// The common real-world broker topology: the leaf is signed by an intermediate that chains /// to the pinned root, and the intermediate is delivered in the handshake rather than being /// installed locally. Only the pinned ROOT is written to the CA file — the intermediate must /// be picked up from what the broker presented. /// [Fact] public void BuildClientOptions_CaPin_AcceptsLeafBehindAnIntermediateThatTheBrokerPresented() { using var root = IssueCa(issuer: null, "otopcua-test-root", validDays: 30); using var intermediate = IssueCa(root, "otopcua-test-intermediate", validDays: 25); using var leaf = IssueLeaf(intermediate, Host, validDays: 20); var caPath = WritePem(root); try { var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath }; var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions; tls.CertificateValidationHandler!(ValidationArgs(built, leaf, intermediate)).ShouldBeTrue(); } finally { File.Delete(caPath); } } /// Default posture: OS trust store, no custom handler, escape hatch off. [Fact] public void BuildClientOptions_TlsWithoutCaPin_LeavesOsTrustStoreValidationInPlace() { var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true }; var tls = BuildTls(opts); tls.UseTls.ShouldBeTrue(); tls.CertificateValidationHandler.ShouldBeNull(); tls.AllowUntrustedCertificates.ShouldBeFalse(); tls.IgnoreCertificateChainErrors.ShouldBeFalse(); tls.IgnoreCertificateRevocationErrors.ShouldBeFalse(); } /// The dev/on-prem escape hatch only opens when explicitly asked for. [Fact] public void BuildClientOptions_AllowUntrustedServerCertificate_OpensEscapeHatch() { var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, AllowUntrustedServerCertificate = true, }; var tls = BuildTls(opts); tls.AllowUntrustedCertificates.ShouldBeTrue(); tls.IgnoreCertificateChainErrors.ShouldBeTrue(); tls.CertificateValidationHandler.ShouldNotBeNull(); } [Fact] public void BuildClientOptions_UseTlsFalse_KeepsTlsOffAndInstallsNoValidator() { var opts = new MqttDriverOptions { Host = Host, Port = 1883, UseTls = false, AllowUntrustedServerCertificate = true, CaCertificatePath = "/tmp/ca.pem", }; var tls = BuildTls(opts); tls.UseTls.ShouldBeFalse(); tls.CertificateValidationHandler.ShouldBeNull(); tls.AllowUntrustedCertificates.ShouldBeFalse(); } [Fact] public void BuildClientOptions_MapsProtocolAndSessionKnobs() { var opts = new MqttDriverOptions { Host = Host, Port = 8883, ClientId = "otopcua-node-1", ProtocolVersion = MqttProtocolVersion.V311, CleanSession = false, KeepAliveSeconds = 45, ConnectTimeoutSeconds = 7, }; var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); built.ProtocolVersion.ShouldBe(MQTTnet.Formatter.MqttProtocolVersion.V311); built.ClientId.ShouldBe("otopcua-node-1"); built.CleanSession.ShouldBeFalse(); built.KeepAlivePeriod.ShouldBe(TimeSpan.FromSeconds(45)); built.Timeout.ShouldBe(TimeSpan.FromSeconds(7)); var tcp = built.ChannelOptions.ShouldBeOfType(); tcp.RemoteEndpoint.ShouldBeOfType().Host.ShouldBe(Host); tcp.RemoteEndpoint.ShouldBeOfType().Port.ShouldBe(8883); } [Fact] public void BuildClientOptions_ClientIdSuffix_IsAppended() { var opts = new MqttDriverOptions { Host = Host, ClientId = "otopcua" }; MqttConnection.BuildClientOptions(opts, clientIdSuffix: "-browse").ClientId.ShouldBe("otopcua-browse"); } [Fact] public void BuildClientOptions_NoUsername_SendsNoCredentials() => MqttConnection.BuildClientOptions(new MqttDriverOptions { Host = Host }, clientIdSuffix: null) .Credentials.ShouldBeNull(); [Fact] public void BuildClientOptions_Username_SendsCredentials() { var opts = new MqttDriverOptions { Host = Host, Username = "svc", Password = "pw" }; var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); built.Credentials.ShouldNotBeNull(); built.Credentials.GetUserName(built).ShouldBe("svc"); } /// The ctor must not touch the network — the universal-browser throwaway-instance pattern. [Fact] public void Ctor_IsConnectionFree() { var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false }; var conn = new MqttConnection(opts, driverId: "t", logger: null); conn.IsConnected.ShouldBeFalse(); } // --------------------------------------------------------------------------------- // Task 4 — NextBackoff (pure): bounded, monotone, cannot hot-loop, cannot overflow // --------------------------------------------------------------------------------- [Theory] [InlineData(0, 1)] [InlineData(1, 2)] [InlineData(2, 4)] [InlineData(3, 8)] [InlineData(4, 16)] [InlineData(5, 30)] // 32 → clamped [InlineData(10, 30)] public void NextBackoff_IsExponentialClampedToMax(int attempt, int expectedSeconds) => MqttConnection.NextBackoff(attempt, minSeconds: 1, maxSeconds: 30) .ShouldBe(TimeSpan.FromSeconds(expectedSeconds)); /// /// A shift-based implementation (min << attempt) wraps: 1 << 32 is /// 1, not 4294967296, so a broker that has been down for half an hour would suddenly /// be hammered once a second. The calculator must saturate, not wrap. /// [Fact] public void NextBackoff_HugeAttemptCount_SaturatesAtMax_NeverWrapsBackToMin() { foreach (var attempt in new[] { 31, 32, 33, 40, 62, 63, 64, 1_000, int.MaxValue }) { MqttConnection.NextBackoff(attempt, minSeconds: 1, maxSeconds: 30) .ShouldBe(TimeSpan.FromSeconds(30), $"attempt {attempt}"); } } [Fact] public void NextBackoff_IsMonotoneNonDecreasing_AndAlwaysWithinMinMax() { var previous = TimeSpan.Zero; for (var attempt = 0; attempt <= 64; attempt++) { var backoff = MqttConnection.NextBackoff(attempt, minSeconds: 2, maxSeconds: 30); backoff.ShouldBeGreaterThanOrEqualTo(previous, $"attempt {attempt} went backwards"); backoff.ShouldBeGreaterThanOrEqualTo(TimeSpan.FromSeconds(2), $"attempt {attempt} below min"); backoff.ShouldBeLessThanOrEqualTo(TimeSpan.FromSeconds(30), $"attempt {attempt} above max"); previous = backoff; } } /// /// A misconfigured max < min must not collapse the floor — the floor is the only /// thing standing between a dead broker and a hot reconnect loop. /// [Fact] public void NextBackoff_MaxBelowMin_HonoursMin_SoTheLoopCannotGoHot() => MqttConnection.NextBackoff(0, minSeconds: 10, maxSeconds: 5).ShouldBe(TimeSpan.FromSeconds(10)); [Fact] public void NextBackoff_NonPositiveAttempt_IsTheFirstAttemptDelay() { MqttConnection.NextBackoff(0, minSeconds: 3, maxSeconds: 30).ShouldBe(TimeSpan.FromSeconds(3)); MqttConnection.NextBackoff(-5, minSeconds: 3, maxSeconds: 30).ShouldBe(TimeSpan.FromSeconds(3)); } // --------------------------------------------------------------------------------- // Task 4 — the Reconnected callback (the load-bearing re-subscribe hook) // --------------------------------------------------------------------------------- [Fact] public async Task FireReconnected_InvokesEverySubscribedCallback() { await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); var first = 0; var second = 0; conn.Reconnected += () => { Interlocked.Increment(ref first); return Task.CompletedTask; }; conn.Reconnected += () => { Interlocked.Increment(ref second); return Task.CompletedTask; }; await conn.FireReconnectedAsync(); first.ShouldBe(1); second.ShouldBe(1); } /// /// Awaiting a multicast Func<Task> directly returns only the LAST handler's task /// and abandons the earlier ones — so one throwing subscriber would silently skip every /// subscriber behind it. The fan-out must walk the invocation list and still surface the /// failure, because a re-subscribe that failed is a connection that has gone dark. /// [Fact] public async Task FireReconnected_OneHandlerThrows_StillInvokesTheRest_AndSurfacesTheFailure() { await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); var behindTheThrower = 0; conn.Reconnected += () => throw new InvalidOperationException("SUBACK refused"); conn.Reconnected += () => { Interlocked.Increment(ref behindTheThrower); return Task.CompletedTask; }; await Should.ThrowAsync(async () => await conn.FireReconnectedAsync()); behindTheThrower.ShouldBe(1); } [Fact] public async Task FireReconnected_NoSubscribers_IsANoOp() { await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); await Should.NotThrowAsync(async () => await conn.FireReconnectedAsync()); } // --------------------------------------------------------------------------------- // Task 4 — State + LastMessage // --------------------------------------------------------------------------------- [Fact] public void State_AfterCtor_IsDisconnected() => new MqttConnection(LoopbackOpts(), driverId: "t", logger: null) .State.ShouldBe(MqttConnectionState.Disconnected); [Fact] public async Task State_AfterDispose_IsDisposed() { var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); await conn.DisposeAsync(); conn.State.ShouldBe(MqttConnectionState.Disposed); } /// /// Faulted is reserved for config that cannot succeed no matter how long we retry. A broker /// that is merely down is , indefinitely. /// [Fact] public async Task State_UnrecoverableProtocolConfig_IsFaulted() { var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 1, ProtocolVersion = (MqttProtocolVersion)99, }; await using var conn = new MqttConnection(opts, driverId: "t", logger: null); await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); conn.State.ShouldBe(MqttConnectionState.Faulted); } /// A broker that is simply unreachable is retryable, never Faulted. [Fact] public async Task State_UnreachableBroker_IsNotFaulted() { using var blackhole = new BlackholeBroker(); var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1, }; await using var conn = new MqttConnection(opts, driverId: "t", logger: null); await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); conn.State.ShouldNotBe(MqttConnectionState.Faulted); } [Fact] public void LastMessage_BeforeAnyTraffic_IsNull() { var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null); conn.LastMessageUtc.ShouldBeNull(); conn.LastMessageAge.ShouldBeNull(); } /// /// A failed FIRST connect is the caller's problem (driver-host resilience retries /// InitializeAsync) — it must not silently leave a background reconnect worker /// hammering an endpoint the caller has already given up on. /// [Fact] public async Task ConnectAsync_InitialAttemptFails_LeavesNoBackgroundReconnectStorm() { using var blackhole = new BlackholeBroker(); var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1, ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 1, }; await using var conn = new MqttConnection(opts, driverId: "t", logger: null); await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); var acceptedAfterTheAttempt = blackhole.AcceptedCount; await Task.Delay(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); blackhole.AcceptedCount.ShouldBe(acceptedAfterTheAttempt); } // --------------------------------------------------------------------------------- // Task 4 — the reconnect supervisor, against a real (minimal) broker // --------------------------------------------------------------------------------- /// /// THE load-bearing invariant. MQTT subscriptions do not survive a clean session, so a /// reconnect that completes without firing Reconnected leaves a healthy-looking /// connection that receives nothing, forever, with no error to show for it. Driven through /// the real socket → real MQTTnet DisconnectedAsync → real re-CONNECT path, not a /// test seam. /// [Fact] public async Task BrokerDropsTheConnection_ReconnectsAndFiresReconnected() { using var broker = new MiniBroker(); await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null); var resubscribes = 0; conn.Reconnected += () => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; }; await conn.ConnectAsync(CancellationToken.None); conn.State.ShouldBe(MqttConnectionState.Connected); conn.IsConnected.ShouldBeTrue(); Volatile.Read(ref resubscribes).ShouldBe(0, "the initial connect is not a reconnect"); broker.DropAll(); (await WaitUntilAsync(() => Volatile.Read(ref resubscribes) >= 1, TimeSpan.FromSeconds(30))) .ShouldBeTrue("the reconnect never fired Reconnected — subscriptions would be silently gone"); (await WaitUntilAsync(() => conn.IsConnected, TimeSpan.FromSeconds(10))).ShouldBeTrue(); conn.State.ShouldBe(MqttConnectionState.Connected); broker.AcceptedCount.ShouldBeGreaterThanOrEqualTo(2); } /// /// A broker that stays down keeps the supervisor in Reconnecting indefinitely (never /// Faulted) — and the backoff keeps the retry rate low enough that a handful of /// attempts, not hundreds, land in the observation window. /// [Fact] public async Task BrokerStaysDown_RetriesUnderBackoff_WithoutHotLooping() { using var broker = new MiniBroker(); var opts = BrokerOpts(broker.Port) with { ConnectTimeoutSeconds = 1, ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2, }; await using var conn = new MqttConnection(opts, driverId: "t", logger: null); await conn.ConnectAsync(CancellationToken.None); var acceptedWhileHealthy = broker.AcceptedCount; broker.Blackhole = true; // accepts TCP, never answers CONNACK again broker.DropAll(); (await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10))) .ShouldBeTrue(); await Task.Delay(TimeSpan.FromSeconds(8), TestContext.Current.CancellationToken); conn.State.ShouldBe(MqttConnectionState.Reconnecting, "a down broker is retryable, not Faulted"); var retries = broker.AcceptedCount - acceptedWhileHealthy; retries.ShouldBeGreaterThanOrEqualTo(1, "the supervisor gave up on a down broker"); retries.ShouldBeLessThan(15, $"backoff is not bounding the retry rate ({retries} attempts in ~8s)"); } /// /// Dispose during a reconnect backoff must stop the supervisor dead — no leaked task, no /// later attempt resurrecting a connection the caller has torn down. /// [Fact] public async Task Dispose_DuringReconnectBackoff_StopsTheSupervisor_NoFurtherAttempts() { using var broker = new MiniBroker(); var opts = BrokerOpts(broker.Port) with { ConnectTimeoutSeconds = 1, ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2, }; var conn = new MqttConnection(opts, driverId: "t", logger: null); await conn.ConnectAsync(CancellationToken.None); broker.Blackhole = true; broker.DropAll(); (await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10))) .ShouldBeTrue(); await conn.DisposeAsync(); var acceptedAtDispose = broker.AcceptedCount; conn.State.ShouldBe(MqttConnectionState.Disposed); conn.IsConnected.ShouldBeFalse(); await Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); broker.AcceptedCount.ShouldBe(acceptedAtDispose, "the supervisor outlived DisposeAsync"); } /// /// The T3 connect/dispose leak, reproduced at the exact interleaving the reviewer traced: /// the client has already CONNECTED when dispose flags the instance and blocks on the /// lifecycle gate this connect is holding — so dispose disposes nothing. Without the /// post-connect re-check the client stays live with an open socket that no later /// DisposeAsync can ever reach; the broker sees the leak as a connection that never /// closes. /// [Fact] public async Task DisposeRacingAnInFlightConnect_LeavesNoLiveBrokerConnection() { using var broker = new MiniBroker(); var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null); conn.AfterConnectHookForTests = async () => { _ = Task.Run(async () => await conn.DisposeAsync()); (await WaitUntilAsync(() => conn.State == MqttConnectionState.Disposed, TimeSpan.FromSeconds(5))) .ShouldBeTrue("dispose never flagged the instance — the race was not reproduced"); }; await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); (await WaitUntilAsync(() => broker.LiveConnections == 0, TimeSpan.FromSeconds(15))) .ShouldBeTrue($"a live broker connection leaked ({broker.LiveConnections} still open)"); conn.IsConnected.ShouldBeFalse(); } // --------------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------------- private static MqttDriverOptions LoopbackOpts() => new() { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 1 }; /// Options aimed at : plaintext v3.1.1, keep-alive parked out of the way. private static MqttDriverOptions BrokerOpts(int port) => new() { Host = "127.0.0.1", Port = port, UseTls = false, ProtocolVersion = MqttProtocolVersion.V311, CleanSession = true, KeepAliveSeconds = 300, ConnectTimeoutSeconds = 5, ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2, }; private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) { var deadline = Stopwatch.StartNew(); while (deadline.Elapsed < timeout) { if (condition()) { return true; } await Task.Delay(25); } return condition(); } private static MqttClientTlsOptions BuildTls(MqttDriverOptions opts) => MqttConnection.BuildClientOptions(opts, clientIdSuffix: null) .ChannelOptions.ShouldBeOfType().TlsOptions; private static readonly DateTimeOffset NotBefore = DateTimeOffset.UtcNow.AddDays(-1); /// A CA certificate, self-signed when is null. private static X509Certificate2 IssueCa(X509Certificate2? issuer, string commonName, int validDays) { using var key = RSA.Create(2048); var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); request.CertificateExtensions.Add( new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); if (issuer is null) { return request.CreateSelfSigned(NotBefore, NotBefore.AddDays(validDays)); } // Create() drops the private key; an intermediate needs it back to sign its own children. using var issued = request.Create(issuer, NotBefore, NotBefore.AddDays(validDays), NextSerial()); return issued.CopyWithPrivateKey(key); } /// A server (end-entity) certificate signed by . private static X509Certificate2 IssueLeaf(X509Certificate2 issuer, string commonName, int validDays) { using var key = RSA.Create(2048); var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); request.CertificateExtensions.Add( new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true)); request.CertificateExtensions.Add( new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.1")], false)); // serverAuth request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); return request.Create(issuer, NotBefore, NotBefore.AddDays(validDays), NextSerial()); } /// A self-signed leaf that chains to nothing — the "stranger" a pin must reject. private static X509Certificate2 SelfSignedLeaf(string commonName) { using var key = RSA.Create(2048); var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return request.CreateSelfSigned(NotBefore, NotBefore.AddDays(20)); } private static byte[] NextSerial() => RandomNumberGenerator.GetBytes(8); private static string WritePem(params X509Certificate2[] certificates) { var path = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-ca-{Guid.NewGuid():N}.pem"); File.WriteAllText(path, string.Join(Environment.NewLine, certificates.Select(c => c.ExportCertificatePem()))); return path; } /// /// Mimics what SslStream hands a validation callback: the presented leaf plus a chain /// carrying whatever intermediates the peer supplied. The chain is built (and allowed to /// fail) purely so ChainElements is populated the way the platform would populate it. /// private static MqttClientCertificateValidationEventArgs ValidationArgs( MqttClientOptions built, X509Certificate2 leaf, params X509Certificate2[] presentedIntermediates) { var chain = new X509Chain(); chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllFlags; foreach (var intermediate in presentedIntermediates) { chain.ChainPolicy.ExtraStore.Add(intermediate); } chain.Build(leaf); return new MqttClientCertificateValidationEventArgs( leaf, chain, SslPolicyErrors.RemoteCertificateChainErrors, built.ChannelOptions); } /// /// Accepts the TCP connection and then says nothing — the client sends CONNECT and waits /// forever for a CONNACK that never comes. This is the frozen-peer shape; a closed port is /// not (it refuses instantly and proves nothing about a deadline). /// private sealed class BlackholeBroker : IDisposable { private readonly List _accepted = []; private readonly CancellationTokenSource _cts = new(); private readonly TcpListener _listener; private int _acceptedCount; public BlackholeBroker() { _listener = new TcpListener(IPAddress.Loopback, 0); _listener.Start(); Port = ((IPEndPoint)_listener.LocalEndpoint).Port; _ = Task.Run(AcceptLoopAsync); } public int Port { get; } /// Total TCP connections accepted — how a caller detects a background retry storm. public int AcceptedCount => Volatile.Read(ref _acceptedCount); public void Dispose() { _cts.Cancel(); _listener.Stop(); lock (_accepted) { foreach (var client in _accepted) { client.Dispose(); } _accepted.Clear(); } _cts.Dispose(); } private async Task AcceptLoopAsync() { try { while (!_cts.IsCancellationRequested) { var client = await _listener.AcceptTcpClientAsync(_cts.Token); Interlocked.Increment(ref _acceptedCount); lock (_accepted) { _accepted.Add(client); } } } catch (Exception) { // Listener stopped / token cancelled — the fixture is going away. } } } /// /// The smallest broker that can complete an MQTT 3.1.1 handshake: accept TCP, answer CONNECT /// with a success CONNACK, answer PINGREQ with PINGRESP, and nothing else. That is enough for /// MQTTnet to report a real connection — which is what makes a real drop /// () drive a real DisconnectedAsync and a real reconnect, rather /// than a test seam standing in for one. /// private sealed class MiniBroker : IDisposable { private static readonly byte[] ConnAckAccepted = [0x20, 0x02, 0x00, 0x00]; private static readonly byte[] PingResp = [0xD0, 0x00]; private readonly CancellationTokenSource _cts = new(); private readonly HashSet _live = []; private readonly TcpListener _listener; private int _acceptedCount; public MiniBroker() { _listener = new TcpListener(IPAddress.Loopback, 0); _listener.Start(); Port = ((IPEndPoint)_listener.LocalEndpoint).Port; _ = Task.Run(AcceptLoopAsync); } public int Port { get; } /// When set, TCP is still accepted but CONNECT is never answered (frozen peer). public bool Blackhole { get; set; } /// Total TCP connections accepted since construction — counts reconnect attempts. public int AcceptedCount => Volatile.Read(ref _acceptedCount); /// /// Sockets the broker still has open. Drops to zero only once the peer actually closes, /// so a leaked-but-live client keeps this above zero. /// public int LiveConnections { get { lock (_live) { return _live.Count; } } } /// Kills every established session — the "broker went away" event. public void DropAll() { lock (_live) { foreach (var client in _live) { try { client.Close(); } catch (Exception) { // Already gone. } } _live.Clear(); } } public void Dispose() { _cts.Cancel(); _listener.Stop(); DropAll(); _cts.Dispose(); } private async Task AcceptLoopAsync() { try { while (!_cts.IsCancellationRequested) { var client = await _listener.AcceptTcpClientAsync(_cts.Token); Interlocked.Increment(ref _acceptedCount); lock (_live) { _live.Add(client); } _ = Task.Run(() => ServeAsync(client)); } } catch (Exception) { // Listener stopped / token cancelled — the fixture is going away. } } private async Task ServeAsync(TcpClient client) { var buffer = new byte[1024]; try { var stream = client.GetStream(); while (!_cts.IsCancellationRequested) { var read = await stream.ReadAsync(buffer, _cts.Token); if (read == 0) { break; // peer closed — this is how a disposed client stops counting as live } if (Blackhole) { continue; } var packetType = buffer[0] & 0xF0; if (packetType == 0x10) { await stream.WriteAsync(ConnAckAccepted, _cts.Token); } else if (packetType == 0xC0) { await stream.WriteAsync(PingResp, _cts.Token); } } } catch (Exception) { // Socket torn down by either side. } finally { lock (_live) { _live.Remove(client); } try { client.Dispose(); } catch (Exception) { // Already gone. } } } } }