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
2 changes: 2 additions & 0 deletions src/DiffEngine.Tests/InlineQueueClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@ ViewerResponse IQueueOwner.Listing(bool withPatches)
}
}

string? IQueueOwner.ListingTag() => null;

bool IQueueOwner.Has(string key)
{
lock (gate)
Expand Down
47 changes: 47 additions & 0 deletions src/DiffEngine.Tests/ViewerProtocolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,49 @@ static ViewerResponse RoundTrip(ViewerResponse response)
await Assert.That(RoundTrip(ViewerResponse.Success("Applied Tests.cs:42")).Written).IsNull();
}

/// <summary>
/// An attached viewer asks for the full listing five times a second, and it is every patch
/// serialized. Sent the tag of the one it holds, an owner whose queue has not changed since
/// answers that it has not, with nothing else. An owner with no tag to give, and a reader
/// sending none, get the listing in full as before tags.
/// </summary>
[Test]
public async Task AnUnchangedListingIsNotSentAgain()
{
static ViewerResponse RoundTrip(ViewerResponse response)
{
if (!ViewerResponse.TryParse(response.Build(), out var parsed))
{
throw new("Unreadable response.");
}

return parsed;
}

var owner = new FakeOwner((true, null))
{
Tag = "one"
};

var full = RoundTrip(ViewerMessageHandler.Handle(owner, new(ViewerVerb.ListFull)));
await Assert.That(full.Tag).IsEqualTo("one");
await Assert.That(full.Unchanged).IsFalse();

var same = RoundTrip(ViewerMessageHandler.Handle(owner, new(ViewerVerb.ListFull, Body: "one")));
await Assert.That(same.Unchanged).IsTrue();
await Assert.That(same.Tag).IsEqualTo("one");

owner.Tag = "two";
var changed = RoundTrip(ViewerMessageHandler.Handle(owner, new(ViewerVerb.ListFull, Body: "one")));
await Assert.That(changed.Unchanged).IsFalse();
await Assert.That(changed.Tag).IsEqualTo("two");

owner.Tag = null;
var untagged = RoundTrip(ViewerMessageHandler.Handle(owner, new(ViewerVerb.ListFull, Body: "two")));
await Assert.That(untagged.Unchanged).IsFalse();
await Assert.That(untagged.Tag).IsNull();
}

/// <summary>
/// A pending file with no tray running. The paths ride key and body rather than an encoded
/// payload, because that is all a tracked move or delete is.
Expand Down Expand Up @@ -592,6 +635,10 @@ public void TrackDelete(string file) =>

public ViewerResponse Listing(bool withPatches) => ViewerResponse.Listing([]);

public string? Tag { get; set; }

public string? ListingTag() => Tag;

public bool Has(string key) => true;

public (bool ok, string? message, bool written) Accept(string key, string? origin)
Expand Down
8 changes: 8 additions & 0 deletions src/DiffEngine/Protocol/IQueueOwner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ interface IQueueOwner
/// </summary>
ViewerResponse Listing(bool withPatches);

/// <summary>
/// Something that changes whenever the full listing would, and is cheap to take: the listing
/// is every patch serialized, and an attached viewer asks for it five times a second. Unique
/// to this owner, so a tag from an owner that has since restarted can never match. Null to
/// always answer in full.
/// </summary>
string? ListingTag();

bool Has(string key);

/// <summary>
Expand Down
24 changes: 23 additions & 1 deletion src/DiffEngine/Protocol/ViewerMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public static ViewerResponse Handle(IQueueOwner owner, ViewerMessage message)
case ViewerVerb.List:
return owner.Listing(false);
case ViewerVerb.ListFull:
return owner.Listing(true);
return ListFull(owner, message.Body);
case ViewerVerb.Accept:
case ViewerVerb.Discard:
return Act(owner, message.Key, message.Body, message.Verb);
Expand Down Expand Up @@ -134,6 +134,28 @@ static ViewerResponse Delete(IQueueOwner owner, string? file)
return ViewerResponse.Success();
}

/// <summary>
/// The body is the tag of the listing the reader already holds, if any. A reader that predates
/// tags sends none and gets the listing in full, as ever.
/// </summary>
static ViewerResponse ListFull(IQueueOwner owner, string? since)
{
// Taken before the listing is built and never after. A change landing in between then
// costs one more full listing next time, where the other order sent a listing older than
// the tag it went under, and the reader would have kept it until something else changed.
var tag = owner.ListingTag();
if (tag is not null &&
tag == since)
{
return ViewerResponse.UnchangedSince(tag);
}

return owner.Listing(true) with
{
Tag = tag
};
}

static ViewerResponse Act(IQueueOwner owner, string? key, string? body, ViewerVerb verb)
{
if (key is null)
Expand Down
44 changes: 43 additions & 1 deletion src/DiffEngine/Protocol/ViewerResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,28 @@ record ViewerResponse(
/// </summary>
public bool? Written { get; init; }

/// <summary>
/// On a full listing: what the owner's queue looked like when it was taken, for the reader to
/// send back on its next <see cref="ViewerVerb.ListFull"/>. Opaque, and only ever compared for
/// equality by the owner that made it. Null from an owner that predates it.
/// </summary>
public string? Tag { get; init; }

/// <summary>
/// The answer to a full listing whose tag still stands: nothing has changed, so nothing is
/// sent, and the reader keeps what it has. An attached viewer asks five times a second, and a
/// full listing is every patch base64'd twice by the owner, inside its gate, and parsed again
/// by the reader.
/// </summary>
public bool Unchanged { get; init; }

public static ViewerResponse UnchangedSince(string tag) =>
new(true, null, [])
{
Tag = tag,
Unchanged = true
};

public static ViewerResponse Success(string? message = null) =>
new(true, message, []);

Expand Down Expand Up @@ -123,6 +145,12 @@ public string Build()
builder.Append($"written: {(written ? "true" : "false")}\n");
}

ViewerPayload.Append(builder, "tag", Tag);
if (Unchanged)
{
builder.Append("unchanged: true\n");
}

foreach (var item in Items)
{
var status = item.Status is null ? "" : ViewerPayload.Encode(item.Status);
Expand Down Expand Up @@ -172,6 +200,8 @@ public static bool TryParse(string text, [NotNullWhen(true)] out ViewerResponse?
string? windowKey = null;
AcceptProgress? progress = null;
bool? written = null;
string? tag = null;
var unchanged = false;
var items = new List<ViewerResponseItem>();
var moves = new List<ViewerResponseMove>();
var deletes = new List<ViewerResponseDelete>();
Expand Down Expand Up @@ -208,6 +238,16 @@ public static bool TryParse(string text, [NotNullWhen(true)] out ViewerResponse?
case "written":
written = value == "true";
continue;
case "tag":
if (!ViewerPayload.TryDecode(value, out tag))
{
return false;
}

continue;
case "unchanged":
unchanged = value == "true";
continue;
case "message":
if (!ViewerPayload.TryDecode(value, out message))
{
Expand Down Expand Up @@ -289,7 +329,9 @@ public static bool TryParse(string text, [NotNullWhen(true)] out ViewerResponse?
Moves = moves,
Deletes = deletes,
Progress = progress,
Written = written
Written = written,
Tag = tag,
Unchanged = unchanged
};
return true;
}
Expand Down
27 changes: 27 additions & 0 deletions src/DiffEngineTray.Tests/TrayViewerSyncTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,33 @@ public async Task AViewerGroupAcceptCarriesOutItsDeletesOnceTheTrayWroteTheSnaps
await Assert.That(await File.ReadAllTextAsync(move.Target)).IsEqualTo("received");
}

/// <summary>
/// The tray answers an unchanged listing without it, but its tracked files change on their own
/// scan rather than through the queue, and a focus it has stashed only reaches a window on a
/// listing. Neither may be taken for unchanged.
/// </summary>
[Test]
public async Task ATrackedFileOrAStashedFocusIsNeverAnsweredUnchanged()
{
await using var pair = new TrayOwned();
var snapshot = pair.Queue(sample, 1);
var tag = pair.Send(new(ViewerVerb.ListFull)).Tag;
await Assert.That(pair.Send(new(ViewerVerb.ListFull, Body: tag)).Unchanged).IsTrue();

pair.AddDelete();
var withDelete = pair.Send(new(ViewerVerb.ListFull, Body: tag));
await Assert.That(withDelete.Unchanged).IsFalse();
await Assert.That(withDelete.Deletes).HasSingleItem();

pair.Host.Focus(snapshot);
var focused = pair.Send(new(ViewerVerb.ListFull, Body: withDelete.Tag));
await Assert.That(focused.Unchanged).IsFalse();
await Assert.That(focused.Window).IsEqualTo(WindowCommand.Focus);

// Taken by that listing, so the next is unchanged again
await Assert.That(pair.Send(new(ViewerVerb.ListFull, Body: focused.Tag)).Unchanged).IsTrue();
}

[Test]
public async Task ViewerDiscardOfOneSnapshotReachesTheTray()
{
Expand Down
45 changes: 45 additions & 0 deletions src/DiffEngineTray/OwnedInlineHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ sealed class OwnedInlineHost :
/// </summary>
AcceptProgress? progress;

/// <summary>
/// What <see cref="IQueueOwner.ListingTag"/> is made of, under <see cref="gate"/>. The queue
/// is immutable and replaced on every change, so a new reference is a new generation; asks
/// counts window commands stashed, so a listing carrying one is never answered unchanged.
/// </summary>
readonly string instance = Guid.NewGuid().ToString("N");
InlineQueue? taggedQueue;
long generation;
long asks;

/// <summary>
/// One accept-all at a time. The menu, a hot key and a displaying viewer can each ask for one,
/// and two sweeping the same queue at once would apply every snapshot twice and report two
Expand Down Expand Up @@ -284,6 +294,40 @@ ViewerResponse IQueueOwner.Listing(bool withPatches)
}
}

string IQueueOwner.ListingTag()
{
// The tracked files by what the listing would carry of them rather than by a count, since
// the tracker changes them on its own scan as well as through here. A handful of paths,
// where the listing it saves is every patch.
var files = TrackedFiles is { } tracked ? Fingerprint(tracked) : "";
lock (gate)
{
if (!ReferenceEquals(queue, taggedQueue))
{
taggedQueue = queue;
generation++;
}

return $"{instance}.{generation}.{asks}.{progress?.Build()}.{files}";
}
}

static string Fingerprint(ITrackedFiles tracked)
{
var builder = new StringBuilder();
foreach (var move in tracked.Moves())
{
builder.Append(move).Append('\n');
}

foreach (var delete in tracked.Deletes())
{
builder.Append(delete).Append('\n');
}

return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())));
}

bool IQueueOwner.Has(string key)
{
if (TrackedKeys.IsTracked(key))
Expand Down Expand Up @@ -650,6 +694,7 @@ void Ask(WindowCommand command, string? key)
{
window = command;
windowKey = key;
asks++;
}
}

Expand Down
64 changes: 64 additions & 0 deletions src/DiffEngineViewer.Tests/AttachedViewerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,70 @@ await Assert.That(queue.Select(_ => _.Name))
await Assert.That(queue[0].TotalRows).IsGreaterThan(0);
}

/// <summary>
/// Asked with the tag of the listing it gave last, an owner whose queue has not moved answers
/// that and nothing else, rather than every patch again. Any change makes it answer in full.
/// </summary>
[Test]
public async Task AnUnchangedQueueIsNotSentAgain()
{
using var owner = new ServerFixture();
owner.Send(Inline(Fixtures.Patch()));

var full = owner.Send(new(ViewerVerb.ListFull));
await Assert.That(full.Tag).IsNotNull();
await Assert.That(full.Items).HasSingleItem();

var same = owner.Send(new(ViewerVerb.ListFull, Body: full.Tag));
await Assert.That(same.Unchanged).IsTrue();
await Assert.That(same.Items).IsEmpty();

owner.Send(Inline(Fixtures.Patch("OtherTests.cs", 7, null, "new")));
var changed = owner.Send(new(ViewerVerb.ListFull, Body: full.Tag));
await Assert.That(changed.Unchanged).IsFalse();
await Assert.That(changed.Items.Count).IsEqualTo(2);
}

/// <summary>
/// What an unchanged answer stands for is the listing held from before it, so the window keeps
/// showing it, and still follows the owner once something does change.
/// </summary>
[Test]
public async Task AnAttachedViewerFollowsAChangeAfterAnUnchangedListing()
{
using var owner = new ServerFixture();
owner.Send(Inline(Fixtures.Patch()));
var (host, link) = Attach(owner);
link.Pump();
var shown = host.State;

link.Pump();
await Assert.That(host.State).IsSameReferenceAs(shown);

owner.Send(Inline(Fixtures.Patch("OtherTests.cs", 7, null, "new")));
link.Pump();
await Assert.That(host.State.Queue.Select(_ => _.Name))
.IsEquivalentTo(["SampleTests.cs:42", "OtherTests.cs:7"]);
}

/// <summary>
/// A send that changes nothing still says so, on a listing that was answered unchanged.
/// </summary>
[Test]
public async Task ARefusalArrivesOnAnUnchangedListing()
{
using var owner = new ServerFixture();
owner.Send(Inline(Fixtures.Patch()));
var (host, link) = Attach(owner);
link.Pump();

link.Post(ViewerVerb.Discard, "nothing|1");
link.Pump();

await Assert.That(host.State.Message).IsEqualTo("No pending snapshot for nothing|1");
await Assert.That(host.State.Queue).HasSingleItem();
}

[Test]
public async Task AFailedEntryKeepsItsStatus()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
Ok: false,
Message: No pending snapshot for missing|1
Message: No pending snapshot for missing|1,
Unchanged: false
}
3 changes: 2 additions & 1 deletion src/DiffEngineViewer.Tests/IpcTests.List.verified.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
Key: othertests.cs|7,
Name: othertests.cs:7
}
]
],
Unchanged: false
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
Ok: true
Ok: true,
Unchanged: false
}
Loading
Loading