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