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.