From 953baa0be257a0ae51b2c84bfe201d08c181e720 Mon Sep 17 00:00:00 2001 From: juanpacostaaa Date: Fri, 27 Mar 2026 14:30:25 -0700 Subject: [PATCH 1/5] Added logging when successfully manage to reconnect to tunnel relay, and fixed event logging of reconnect failed vs cancelled to warn correctly. --- cs/src/Connections/TunnelRelayConnection.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/cs/src/Connections/TunnelRelayConnection.cs b/cs/src/Connections/TunnelRelayConnection.cs index c71937e3..cd005917 100644 --- a/cs/src/Connections/TunnelRelayConnection.cs +++ b/cs/src/Connections/TunnelRelayConnection.cs @@ -532,6 +532,7 @@ protected virtual void FinishConnecting(SshDisconnectReason reason, Exception? d // Since we have successfully connected after all, clean it up. DisconnectReason = SshDisconnectReason.None; DisconnectException = null; + Trace.TraceInformation($"Connection to {ConnectionRole} tunnel relay restored."); } ConnectionStatus = ConnectionStatus.Connected; @@ -632,10 +633,17 @@ await this.connector.ConnectSessionAsync( { if (Tunnel != null) { - var connectFailedEvent = new TunnelEvent($"{ConnectionRole}_reconnect_failed"); - connectFailedEvent.Severity = TunnelEvent.Error; - connectFailedEvent.Details = ex.ToString(); - ManagementClient?.ReportEvent(Tunnel, connectFailedEvent); + bool isCancelled = DisposeToken.IsCancellationRequested && + (ex is ObjectDisposedException || ex is OperationCanceledException); + + var eventName = isCancelled + ? $"{ConnectionRole}_reconnect_cancelled" + : $"{ConnectionRole}_reconnect_failed"; + + var reconnectEvent = new TunnelEvent(eventName); + reconnectEvent.Severity = isCancelled ? TunnelEvent.Warning : TunnelEvent.Error; + reconnectEvent.Details = ex.ToString(); + ManagementClient?.ReportEvent(Tunnel, reconnectEvent); } // Tracing of the exception has already been done by ConnectSessionAsync. From 61d61bd5e7338d932c519942711439735e0f4fd1 Mon Sep 17 00:00:00 2001 From: juanpacostaaa Date: Fri, 27 Mar 2026 14:32:53 -0700 Subject: [PATCH 2/5] Added testing to ensure cancelled reconnect vs failed reconnect are reported correctly --- .../Mocks/MockTunnelManagementClient.cs | 3 + .../TunnelHostAndClientTests.cs | 88 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs b/cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs index 5ffc450c..ee55fba7 100644 --- a/cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs +++ b/cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs @@ -386,10 +386,13 @@ public Task CreateOrUpdateTunnelPortAsync(Tunnel tunnel, TunnelPort throw new NotImplementedException(); } + public List ReportedEvents { get; } = new(); + public void ReportEvent( Tunnel tunnel, TunnelEvent tunnelEvent, TunnelRequestOptions options = null) { + ReportedEvents.Add(tunnelEvent); } } diff --git a/cs/test/TunnelsSDK.Test/TunnelHostAndClientTests.cs b/cs/test/TunnelsSDK.Test/TunnelHostAndClientTests.cs index 855241bf..d9b33f9f 100644 --- a/cs/test/TunnelsSDK.Test/TunnelHostAndClientTests.cs +++ b/cs/test/TunnelsSDK.Test/TunnelHostAndClientTests.cs @@ -1710,4 +1710,92 @@ private static Task ThrowNotAWebSocket(HttpStatusCode statusCode) wse.Data["HttpStatusCode"] = statusCode; throw wse; } + + [Fact] + public async Task DisposeDuringReconnectReportsCancelledEvent() + { + var managementClient = new MockTunnelManagementClient + { + HostRelayUri = MockHostRelayUri, + }; + + var tunnel = CreateRelayTunnel(addClientEndpoint: false); + await managementClient.CreateTunnelAsync(tunnel, options: null, default); + var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); + + // Connect the host + var multiChannelStream = await ConnectRelayHostAsync(relayHost, tunnel); + Assert.Equal(ConnectionStatus.Connected, relayHost.ConnectionStatus); + + // Make the reconnect hang so we can dispose while it's in flight. + var reconnectStarted = new TaskCompletionSource(); + ((MockTunnelRelayStreamFactory)relayHost.StreamFactory).StreamFactory = async (accessToken) => + { + reconnectStarted.TrySetResult(); + // Block indefinitely until cancelled + await Task.Delay(-1, CancellationToken.None); + throw new InvalidOperationException("Should not reach here"); + }; + + // Drop the connection to trigger reconnection + await this.serverStream.DisposeAsync(); + + // Wait for the reconnect attempt to start + await relayHost.WaitForConnectionStatusAsync( + ConnectionStatus.Connecting, cancellationToken: TimeoutToken); + + // Dispose the host while reconnect is in flight + await relayHost.DisposeAsync(); + Assert.Equal(ConnectionStatus.Disconnected, relayHost.ConnectionStatus); + + // Verify the reconnect event was reported as "cancelled" (warning), not "failed" (error) + var reconnectCancelledEvent = managementClient.ReportedEvents.FirstOrDefault( + e => e.Name?.Contains("reconnect_cancelled") == true); + var reconnectFailedEvent = managementClient.ReportedEvents.FirstOrDefault( + e => e.Name?.Contains("reconnect_failed") == true); + + Assert.NotNull(reconnectCancelledEvent); + Assert.Equal(TunnelEvent.Warning, reconnectCancelledEvent.Severity); + Assert.Null(reconnectFailedEvent); + } + + [Fact] + public async Task ReconnectFailureReportsFailedEvent() + { + var managementClient = new MockTunnelManagementClient + { + HostRelayUri = MockHostRelayUri, + }; + + var tunnel = CreateRelayTunnel(addClientEndpoint: false); + await managementClient.CreateTunnelAsync(tunnel, options: null, default); + var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); + + // Connect the host + var multiChannelStream = await ConnectRelayHostAsync(relayHost, tunnel); + Assert.Equal(ConnectionStatus.Connected, relayHost.ConnectionStatus); + + // Make every reconnect attempt fail with a non-retryable error + ((MockTunnelRelayStreamFactory)relayHost.StreamFactory).StreamFactory = (accessToken) => + { + throw new InvalidOperationException("Simulated non-recoverable failure"); + }; + + // Drop the connection to trigger reconnection + await this.serverStream.DisposeAsync(); + + // Wait until host is fully disconnected (reconnect exhausted) + await relayHost.WaitForConnectionStatusAsync( + ConnectionStatus.Disconnected, cancellationToken: TimeoutToken); + + // Verify the reconnect event was reported as "failed" (error), not "cancelled" (warning) + var reconnectFailedEvent = managementClient.ReportedEvents.FirstOrDefault( + e => e.Name?.Contains("reconnect_failed") == true); + var reconnectCancelledEvent = managementClient.ReportedEvents.FirstOrDefault( + e => e.Name?.Contains("reconnect_cancelled") == true); + + Assert.NotNull(reconnectFailedEvent); + Assert.Equal(TunnelEvent.Error, reconnectFailedEvent.Severity); + Assert.Null(reconnectCancelledEvent); + } } From 646c7281bad29b90d40e6799b686999d272605db Mon Sep 17 00:00:00 2001 From: juanpacostaaa Date: Mon, 30 Mar 2026 10:55:55 -0700 Subject: [PATCH 3/5] Fixed infinite hanging of DisposeDuringReconnectReportsCancelledEvent by corecting use of cancellation token --- .../Mocks/MockTunnelRelayStreamFactory.cs | 2 +- cs/test/TunnelsSDK.Test/TunnelHostAndClientTests.cs | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/cs/test/TunnelsSDK.Test/Mocks/MockTunnelRelayStreamFactory.cs b/cs/test/TunnelsSDK.Test/Mocks/MockTunnelRelayStreamFactory.cs index 16bb782b..d691c732 100644 --- a/cs/test/TunnelsSDK.Test/Mocks/MockTunnelRelayStreamFactory.cs +++ b/cs/test/TunnelsSDK.Test/Mocks/MockTunnelRelayStreamFactory.cs @@ -29,7 +29,7 @@ public MockTunnelRelayStreamFactory(string connectionType, Stream stream = null) Assert.NotNull(accessToken); Assert.Contains(this.connectionType, subprotocols); - var stream = await StreamFactory(accessToken); + var stream = await StreamFactory(accessToken).WaitAsync(cancellation); return (stream, this.connectionType); } } diff --git a/cs/test/TunnelsSDK.Test/TunnelHostAndClientTests.cs b/cs/test/TunnelsSDK.Test/TunnelHostAndClientTests.cs index d9b33f9f..f4664ea2 100644 --- a/cs/test/TunnelsSDK.Test/TunnelHostAndClientTests.cs +++ b/cs/test/TunnelsSDK.Test/TunnelHostAndClientTests.cs @@ -1732,8 +1732,8 @@ public async Task DisposeDuringReconnectReportsCancelledEvent() ((MockTunnelRelayStreamFactory)relayHost.StreamFactory).StreamFactory = async (accessToken) => { reconnectStarted.TrySetResult(); - // Block indefinitely until cancelled - await Task.Delay(-1, CancellationToken.None); + // Block indefinitely; the mock will cancel via WaitAsync(cancellation) + await Task.Delay(-1); throw new InvalidOperationException("Should not reach here"); }; @@ -1743,6 +1743,7 @@ public async Task DisposeDuringReconnectReportsCancelledEvent() // Wait for the reconnect attempt to start await relayHost.WaitForConnectionStatusAsync( ConnectionStatus.Connecting, cancellationToken: TimeoutToken); + await reconnectStarted.Task.WaitAsync(TimeoutToken); // Dispose the host while reconnect is in flight await relayHost.DisposeAsync(); @@ -1788,6 +1789,10 @@ public async Task ReconnectFailureReportsFailedEvent() await relayHost.WaitForConnectionStatusAsync( ConnectionStatus.Disconnected, cancellationToken: TimeoutToken); + // DisposeAsync awaits the reconnectTask, ensuring ReconnectAsync's catch + // block (which reports the event) has completed before we check. + await relayHost.DisposeAsync(); + // Verify the reconnect event was reported as "failed" (error), not "cancelled" (warning) var reconnectFailedEvent = managementClient.ReportedEvents.FirstOrDefault( e => e.Name?.Contains("reconnect_failed") == true); From e19cfcbf42a0cea2293feedf9f79b9d2bd4d65a2 Mon Sep 17 00:00:00 2001 From: juanpacostaaa Date: Mon, 30 Mar 2026 14:50:45 -0700 Subject: [PATCH 4/5] Fixed required net6.0 installation for test proj --- .pipelines/cs-build-steps.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.pipelines/cs-build-steps.yaml b/.pipelines/cs-build-steps.yaml index 3624ce15..4f1136c4 100644 --- a/.pipelines/cs-build-steps.yaml +++ b/.pipelines/cs-build-steps.yaml @@ -4,6 +4,12 @@ parameters: default: '' steps: +- task: UseDotNet@2 + displayName: "Install DotNet 6.0 Runtime" + inputs: + packageType: 'runtime' + version: '6.x' + - task: UseDotNet@2 displayName: "Install DotNet SDK" inputs: From 317bea07f2e01b3a5d3e2ba7d2538a35d7bdcd2c Mon Sep 17 00:00:00 2001 From: juanpacostaaa Date: Mon, 30 Mar 2026 15:05:49 -0700 Subject: [PATCH 5/5] test: verify PR pipeline triggers --- cs/NuGet.config | 1 + 1 file changed, 1 insertion(+) diff --git a/cs/NuGet.config b/cs/NuGet.config index 3697f3ea..7c38df72 100644 --- a/cs/NuGet.config +++ b/cs/NuGet.config @@ -7,3 +7,4 @@ +// trigger test