Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/DiffEngine.Tests/ViewerProtocolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -984,6 +984,61 @@ await Assert.That(await ViewerClient.SendAsync(new(ViewerVerb.List), default, po
.IsEqualTo(SendOutcome.NoOwner);
}

/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// </summary>
[Test]
[NotInParallel]
public async Task AConnectGivenUpOnIsObserved()
{
ViewerServer.TryBind(0, out var server);
var port = server!.Port;
server.Dispose();
ViewerClient.ForgetUnowned();

var unobserved = new List<Exception>();
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()
{
Expand Down
28 changes: 26 additions & 2 deletions src/DiffEngine/Protocol/ViewerClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -385,6 +385,30 @@ public static async Task<SendOutcome> SendAsync(
}
}

/// <summary>
/// A connect waited on for at most <paramref name="wait"/>. 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.
/// </summary>
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;
}

/// <summary>
/// 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.
Expand Down
Loading