diff --git a/src/DiffEngine.Tests/ViewerProtocolTests.cs b/src/DiffEngine.Tests/ViewerProtocolTests.cs
index 8f681826..d136f18b 100644
--- a/src/DiffEngine.Tests/ViewerProtocolTests.cs
+++ b/src/DiffEngine.Tests/ViewerProtocolTests.cs
@@ -984,6 +984,61 @@ await Assert.That(await ViewerClient.SendAsync(new(ViewerVerb.List), default, po
.IsEqualTo(SendOutcome.NoOwner);
}
+ ///
+ /// A connect given up on is disposed while still pending, and the task behind it faults
+ /// afterwards with nobody left to observe it. The finalizer then reports it: in a test process
+ /// that launched a viewer, once per probe the launch gate made while the viewer was still
+ /// binding, and fatally in a host that treats unobserved task exceptions as fatal.
+ ///
+ /// A zero wait gives up on every connect, whatever the platform does with a port nothing is
+ /// listening on - Windows lets it hang, others refuse it, both only after the wait has returned.
+ /// Serialised with the other tests in this class, since the event is process wide.
+ ///
+ ///
+ [Test]
+ [NotInParallel]
+ public async Task AConnectGivenUpOnIsObserved()
+ {
+ ViewerServer.TryBind(0, out var server);
+ var port = server!.Port;
+ server.Dispose();
+ ViewerClient.ForgetUnowned();
+
+ var unobserved = new List();
+ void Record(object? sender, UnobservedTaskExceptionEventArgs args)
+ {
+ lock (unobserved)
+ {
+ unobserved.Add(args.Exception);
+ }
+ }
+
+ TaskScheduler.UnobservedTaskException += Record;
+ try
+ {
+ for (var attempt = 0; attempt < 5; attempt++)
+ {
+ ViewerClient.TrySend(new(ViewerVerb.List), out _, port, TimeSpan.Zero);
+ }
+
+ // Long enough for the abandoned connects to fault, then collected so their tasks
+ // are finalized, which is when an unobserved fault is reported
+ for (var pass = 0; pass < 5; pass++)
+ {
+ await Task.Delay(200);
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ }
+ }
+ finally
+ {
+ TaskScheduler.UnobservedTaskException -= Record;
+ ViewerClient.ForgetUnowned();
+ }
+
+ await Assert.That(unobserved).IsEmpty();
+ }
+
[Test]
public async Task AnAbsentOwnerIsNotAnError()
{
diff --git a/src/DiffEngine/Protocol/ViewerClient.cs b/src/DiffEngine/Protocol/ViewerClient.cs
index 9e091597..2b3b68a0 100644
--- a/src/DiffEngine/Protocol/ViewerClient.cs
+++ b/src/DiffEngine/Protocol/ViewerClient.cs
@@ -164,7 +164,7 @@ public static bool IsOwned(int? port = null)
try
{
using var client = new TcpClient();
- owned = client.ConnectAsync(IPAddress.Loopback, endpointPort).Wait(ShortTimeout);
+ owned = Connect(client, endpointPort, ShortTimeout);
}
catch (Exception exception)
when (Ignorable(exception))
@@ -223,7 +223,7 @@ public static bool TrySend(
try
{
using var client = new TcpClient();
- if (!client.ConnectAsync(IPAddress.Loopback, endpointPort).Wait(deadline))
+ if (!Connect(client, endpointPort, deadline))
{
Found(endpointPort, false);
return false;
@@ -385,6 +385,30 @@ public static async Task SendAsync(
}
}
+ ///
+ /// A connect waited on for at most . One given up on is still pending
+ /// when the caller disposes the client, and faults once that tears it down - or, where the
+ /// port refuses rather than hangs, when the refusal arrives - with nobody left to observe it.
+ /// The finalizer then reported it as an unobserved task exception: once per probe the launch
+ /// gate made while a viewer it had just started was still binding, in a test process that may
+ /// treat those as fatal. Observed here instead, since there is nothing to do with the fault.
+ ///
+ static bool Connect(TcpClient client, int port, TimeSpan wait)
+ {
+ var connecting = client.ConnectAsync(IPAddress.Loopback, port);
+ if (connecting.Wait(wait))
+ {
+ return true;
+ }
+
+ connecting.ContinueWith(
+ static _ => _.Exception,
+ Cancel.None,
+ TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+ return false;
+ }
+
///
/// Unblocks whatever the exchange is waiting on. Swallowing here rather than letting it out:
/// this runs on the timer that fired the deadline, where a throw has nowhere to go.