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: 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 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. 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/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 855241bf..f4664ea2 100644 --- a/cs/test/TunnelsSDK.Test/TunnelHostAndClientTests.cs +++ b/cs/test/TunnelsSDK.Test/TunnelHostAndClientTests.cs @@ -1710,4 +1710,97 @@ 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; the mock will cancel via WaitAsync(cancellation) + await Task.Delay(-1); + 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); + await reconnectStarted.Task.WaitAsync(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); + + // 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); + var reconnectCancelledEvent = managementClient.ReportedEvents.FirstOrDefault( + e => e.Name?.Contains("reconnect_cancelled") == true); + + Assert.NotNull(reconnectFailedEvent); + Assert.Equal(TunnelEvent.Error, reconnectFailedEvent.Severity); + Assert.Null(reconnectCancelledEvent); + } }