From 75d7646a62e4db16ffd92a749ee24e7ca173f2a8 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 22 Sep 2026 16:07:07 +1000 Subject: [PATCH] Observe a connect given up on ViewerClient.IsOwned and the synchronous TrySend wait on ConnectAsync for a bounded time, inside a using. A connect given up on is still pending when the client is disposed, 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. That is routine rather than rare. The launch gate probes IsOwned while a viewer it has just started is still binding, and on Windows a connect to a port nothing is listening on is not refused at once, so the half second wait expires. Running ViewerLaunchTests.AcceptAllOverALongQueue printed two of them, and any test process that launches the viewer gets the same, fatally in a host that treats unobserved task exceptions as fatal. Both sites now connect through one helper, which observes the abandoned task with a continuation. The timeouts, and what Found records, are unchanged. AConnectGivenUpOnIsObserved gives up on five connects with a zero wait, which abandons them whatever the platform does with a closed port, then collects and asserts nothing was reported. It fails on the old code on net10 and net48. --- src/DiffEngine.Tests/ViewerProtocolTests.cs | 55 +++++++++++++++++++++ src/DiffEngine/Protocol/ViewerClient.cs | 28 ++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) 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.