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
58 changes: 58 additions & 0 deletions src/DiffEngine.Tests/ViewerProtocolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,64 @@ public async Task AnUnresponsiveOwnerTimesOutRatherThanHanging()
listener.Stop();
}
}
/// <summary>
/// An owner that answers with an error is not an absent one. Collapsing the two into false
/// meant a refused inline was read as "nobody is there", so a second viewer was launched, it
/// could not bind the port, and the snapshot was reported as Queued while being held by
/// nothing at all.
/// </summary>
[Test]
public async Task ARefusedExchangeIsToldApartFromAnAbsentOwner()
{
await Assert.That(ViewerServer.TryBind(0, out var bound)).IsTrue();
using var server = bound!;
using var cancel = new CancelSource();
var listening = server.Listen(_ => ViewerResponse.Error("no thanks"), cancel.Token);

try
{
var refused = await ViewerClient.SendAsync(new(ViewerVerb.List), default, server.Port);

await Assert.That(refused).IsEqualTo(SendOutcome.Refused);
}
finally
{
await cancel.CancelAsync();
await Wait(listening);
}
}

[Test]
public async Task AnAcceptedExchangeReportsAccepted()
{
await Assert.That(ViewerServer.TryBind(0, out var bound)).IsTrue();
using var server = bound!;
using var cancel = new CancelSource();
var listening = server.Listen(_ => ViewerResponse.Success("fine"), cancel.Token);

try
{
await Assert.That(await ViewerClient.SendAsync(new(ViewerVerb.List), default, server.Port))
.IsEqualTo(SendOutcome.Accepted);
}
finally
{
await cancel.CancelAsync();
await Wait(listening);
}
}

[Test]
public async Task AnAbsentOwnerReportsNoOwner()
{
ViewerServer.TryBind(0, out var server);
var port = server!.Port;
server.Dispose();

await Assert.That(await ViewerClient.SendAsync(new(ViewerVerb.List), default, port, TimeSpan.FromSeconds(2)))
.IsEqualTo(SendOutcome.NoOwner);
}

[Test]
public async Task AnAbsentOwnerIsNotAnError()
{
Expand Down
17 changes: 15 additions & 2 deletions src/DiffEngine/DiffRunner_Inline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ public enum InlineResult
Disabled,

/// <summary>
/// No DiffEngineViewer could be resolved. Callers that want a fallback should use it here.
/// Nothing has the snapshot. No DiffEngineViewer could be resolved, or the owner of the queue
/// declined the payload - which is the same thing from the caller's side, since in both cases
/// the snapshot is pending nowhere. Callers that want a fallback should use it here.
/// </summary>
NoViewerFound
}
Expand Down Expand Up @@ -61,11 +63,22 @@ public static async Task<InlineResult> AddInlineAsync(InlinePatch patch, Cancel
// Onto the payload rather than onto the patch, which belongs to the caller and may be
// held or sent again
var payload = InlinePatchFile.Build(patch, patch.Framework ?? RuntimeMoniker.Current);
if (await ViewerClient.TrySendAsync(new(ViewerVerb.Inline, Body: payload), cancel))
var outcome = await ViewerClient.SendAsync(new(ViewerVerb.Inline, Body: payload), cancel);
if (outcome == SendOutcome.Accepted)
{
return InlineResult.Queued;
}

if (outcome == SendOutcome.Refused)
{
// An owner that is there and said no - an older one that does not understand the
// payload, or a handler that threw. Launching a second viewer cannot change that
// answer, and it would bind nothing, so reporting Queued would say the snapshot is
// somewhere when it is nowhere. Report it as no viewer, which is the answer that
// makes the caller stage the files instead
return InlineResult.NoViewerFound;
}

var launched = await ViewerLauncher.LaunchAsync(patch, payload, cancel);
return launched ? InlineResult.Queued : InlineResult.NoViewerFound;
}
Expand Down
49 changes: 45 additions & 4 deletions src/DiffEngine/Protocol/ViewerClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ namespace DiffEngine;
/// caller turns into a launch (DiffEngine), "nothing pending" (the tray), or "the owner has gone"
/// (an attached viewer).
/// </summary>
/// <summary>
/// What came back from an exchange with the queue owner. Three outcomes rather than two, because
/// "nobody is there" and "the owner said no" call for opposite responses: the first is fixed by
/// launching a viewer, and the second is not.
/// </summary>
enum SendOutcome
{
/// <summary>
/// Nobody answered. No owner, or one present but unresponsive - the caller cannot tell, and
/// for its purposes they are the same.
/// </summary>
NoOwner,

/// <summary>
/// The owner answered and took it.
/// </summary>
Accepted,

/// <summary>
/// The owner answered and declined it: a version it does not understand, or a handler that
/// threw. Launching another viewer will not change that answer.
/// </summary>
Refused
}

static class ViewerClient
{
public const int DefaultPort = 3493;
Expand Down Expand Up @@ -114,6 +139,18 @@ public static bool TrySend(
/// </para>
/// </summary>
public static async Task<bool> TrySendAsync(
ViewerMessage message,
Cancel cancel,
int? port = null,
TimeSpan? wait = null) =>
await SendAsync(message, cancel, port, wait) == SendOutcome.Accepted;

/// <summary>
/// As <see cref="TrySendAsync" />, but says which of the two failures happened. A caller that
/// would launch a viewer on absence needs that: launching one because the owner refused the
/// payload leaves two processes and still no snapshot.
/// </summary>
public static async Task<SendOutcome> SendAsync(
ViewerMessage message,
Cancel cancel,
int? port = null,
Expand Down Expand Up @@ -154,8 +191,12 @@ public static async Task<bool> TrySendAsync(
#else
var text = await reader.ReadToEndAsync();
#endif
return ViewerResponse.TryParse(text, out var response) &&
response.Ok;
if (!ViewerResponse.TryParse(text, out var response))
{
return SendOutcome.NoOwner;
}

return response.Ok ? SendOutcome.Accepted : SendOutcome.Refused;
}
// The deadline, rather than the caller cancelling. Whatever the abort surfaced as - a
// cancellation, a closed socket, a torn down stream - the owner is present but not
Expand All @@ -169,13 +210,13 @@ public static async Task<bool> TrySendAsync(
Trace.WriteLine(
$"Timed out after {timeToWait} waiting for the inline queue owner on port {endpointPort}. " +
$"Verb: {message.Verb}. The owner is present but unresponsive. {exception.GetType().Name}");
return false;
return SendOutcome.NoOwner;
}
// Cancellation is the caller's business; a missing owner is not.
catch (Exception exception)
when (exception is not OperationCanceledException && Ignorable(exception))
{
return false;
return SendOutcome.NoOwner;
}
}

Expand Down
Loading