diff --git a/src/DiffEngine.Tests/InlineQueueClientTests.cs b/src/DiffEngine.Tests/InlineQueueClientTests.cs
index 748ee871..e3b233a2 100644
--- a/src/DiffEngine.Tests/InlineQueueClientTests.cs
+++ b/src/DiffEngine.Tests/InlineQueueClientTests.cs
@@ -404,6 +404,8 @@ ViewerResponse IQueueOwner.Listing(bool withPatches)
}
}
+ string? IQueueOwner.ListingTag() => null;
+
bool IQueueOwner.Has(string key)
{
lock (gate)
diff --git a/src/DiffEngine.Tests/ViewerProtocolTests.cs b/src/DiffEngine.Tests/ViewerProtocolTests.cs
index 168291a2..2954e246 100644
--- a/src/DiffEngine.Tests/ViewerProtocolTests.cs
+++ b/src/DiffEngine.Tests/ViewerProtocolTests.cs
@@ -479,6 +479,49 @@ static ViewerResponse RoundTrip(ViewerResponse response)
await Assert.That(RoundTrip(ViewerResponse.Success("Applied Tests.cs:42")).Written).IsNull();
}
+ ///
+ /// 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.
+ ///
+ [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();
+ }
+
///
/// 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.
@@ -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)
diff --git a/src/DiffEngine/Protocol/IQueueOwner.cs b/src/DiffEngine/Protocol/IQueueOwner.cs
index 2768a740..7ae18e09 100644
--- a/src/DiffEngine/Protocol/IQueueOwner.cs
+++ b/src/DiffEngine/Protocol/IQueueOwner.cs
@@ -42,6 +42,14 @@ interface IQueueOwner
///
ViewerResponse Listing(bool withPatches);
+ ///
+ /// 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.
+ ///
+ string? ListingTag();
+
bool Has(string key);
///
diff --git a/src/DiffEngine/Protocol/ViewerMessageHandler.cs b/src/DiffEngine/Protocol/ViewerMessageHandler.cs
index d8c8ee23..6d8b3fc9 100644
--- a/src/DiffEngine/Protocol/ViewerMessageHandler.cs
+++ b/src/DiffEngine/Protocol/ViewerMessageHandler.cs
@@ -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);
@@ -134,6 +134,28 @@ static ViewerResponse Delete(IQueueOwner owner, string? file)
return ViewerResponse.Success();
}
+ ///
+ /// 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.
+ ///
+ 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)
diff --git a/src/DiffEngine/Protocol/ViewerResponse.cs b/src/DiffEngine/Protocol/ViewerResponse.cs
index 5e766a8a..286d68f5 100644
--- a/src/DiffEngine/Protocol/ViewerResponse.cs
+++ b/src/DiffEngine/Protocol/ViewerResponse.cs
@@ -79,6 +79,28 @@ record ViewerResponse(
///
public bool? Written { get; init; }
+ ///
+ /// On a full listing: what the owner's queue looked like when it was taken, for the reader to
+ /// send back on its next . Opaque, and only ever compared for
+ /// equality by the owner that made it. Null from an owner that predates it.
+ ///
+ public string? Tag { get; init; }
+
+ ///
+ /// 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.
+ ///
+ 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, []);
@@ -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);
@@ -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();
var moves = new List();
var deletes = new List();
@@ -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))
{
@@ -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;
}
diff --git a/src/DiffEngineTray.Tests/TrayViewerSyncTest.cs b/src/DiffEngineTray.Tests/TrayViewerSyncTest.cs
index 2fc12803..6fc2a21e 100644
--- a/src/DiffEngineTray.Tests/TrayViewerSyncTest.cs
+++ b/src/DiffEngineTray.Tests/TrayViewerSyncTest.cs
@@ -281,6 +281,33 @@ public async Task AViewerGroupAcceptCarriesOutItsDeletesOnceTheTrayWroteTheSnaps
await Assert.That(await File.ReadAllTextAsync(move.Target)).IsEqualTo("received");
}
+ ///
+ /// 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.
+ ///
+ [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()
{
diff --git a/src/DiffEngineTray/OwnedInlineHost.cs b/src/DiffEngineTray/OwnedInlineHost.cs
index afdea9d1..9e78f91e 100644
--- a/src/DiffEngineTray/OwnedInlineHost.cs
+++ b/src/DiffEngineTray/OwnedInlineHost.cs
@@ -41,6 +41,16 @@ sealed class OwnedInlineHost :
///
AcceptProgress? progress;
+ ///
+ /// What is made of, under . 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.
+ ///
+ readonly string instance = Guid.NewGuid().ToString("N");
+ InlineQueue? taggedQueue;
+ long generation;
+ long asks;
+
///
/// 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
@@ -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))
@@ -650,6 +694,7 @@ void Ask(WindowCommand command, string? key)
{
window = command;
windowKey = key;
+ asks++;
}
}
diff --git a/src/DiffEngineViewer.Tests/AttachedViewerTests.cs b/src/DiffEngineViewer.Tests/AttachedViewerTests.cs
index 63591236..21006a6b 100644
--- a/src/DiffEngineViewer.Tests/AttachedViewerTests.cs
+++ b/src/DiffEngineViewer.Tests/AttachedViewerTests.cs
@@ -36,6 +36,70 @@ await Assert.That(queue.Select(_ => _.Name))
await Assert.That(queue[0].TotalRows).IsGreaterThan(0);
}
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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"]);
+ }
+
+ ///
+ /// A send that changes nothing still says so, on a listing that was answered unchanged.
+ ///
+ [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()
{
diff --git a/src/DiffEngineViewer.Tests/IpcTests.AcceptAnUnknownKey.verified.txt b/src/DiffEngineViewer.Tests/IpcTests.AcceptAnUnknownKey.verified.txt
index 38cce331..176a0791 100644
--- a/src/DiffEngineViewer.Tests/IpcTests.AcceptAnUnknownKey.verified.txt
+++ b/src/DiffEngineViewer.Tests/IpcTests.AcceptAnUnknownKey.verified.txt
@@ -1,4 +1,5 @@
{
Ok: false,
- Message: No pending snapshot for missing|1
+ Message: No pending snapshot for missing|1,
+ Unchanged: false
}
\ No newline at end of file
diff --git a/src/DiffEngineViewer.Tests/IpcTests.List.verified.txt b/src/DiffEngineViewer.Tests/IpcTests.List.verified.txt
index 2c61dfe8..567c9e75 100644
--- a/src/DiffEngineViewer.Tests/IpcTests.List.verified.txt
+++ b/src/DiffEngineViewer.Tests/IpcTests.List.verified.txt
@@ -9,5 +9,6 @@
Key: othertests.cs|7,
Name: othertests.cs:7
}
- ]
+ ],
+ Unchanged: false
}
\ No newline at end of file
diff --git a/src/DiffEngineViewer.Tests/IpcTests.ListWhenEmpty.verified.txt b/src/DiffEngineViewer.Tests/IpcTests.ListWhenEmpty.verified.txt
index 6c8ee9bb..9d29d49c 100644
--- a/src/DiffEngineViewer.Tests/IpcTests.ListWhenEmpty.verified.txt
+++ b/src/DiffEngineViewer.Tests/IpcTests.ListWhenEmpty.verified.txt
@@ -1,3 +1,4 @@
{
- Ok: true
+ Ok: true,
+ Unchanged: false
}
\ No newline at end of file
diff --git a/src/DiffEngineViewer/Ipc/MessageHandler.cs b/src/DiffEngineViewer/Ipc/MessageHandler.cs
index 810264c2..8dcdef7c 100644
--- a/src/DiffEngineViewer/Ipc/MessageHandler.cs
+++ b/src/DiffEngineViewer/Ipc/MessageHandler.cs
@@ -111,6 +111,31 @@ ViewerResponse IQueueOwner.Listing(bool withPatches)
progress: state.Progress);
}
+ ///
+ /// The session's queue is immutable and replaced on every change - its moves and deletes are
+ /// entries in it - so a new reference is a new generation. The progress rides the listing
+ /// beside it. No window command is ever stashed: this owner has its own window.
+ ///
+ string IQueueOwner.ListingTag()
+ {
+ var state = host.State;
+ lock (tagGate)
+ {
+ if (!ReferenceEquals(state.Queue, taggedQueue))
+ {
+ taggedQueue = state.Queue;
+ generation++;
+ }
+
+ return $"{instance}.{generation}.{state.Progress?.Build()}";
+ }
+ }
+
+ readonly string instance = Guid.NewGuid().ToString("N");
+ readonly Lock tagGate = new();
+ IReadOnlyList? taggedQueue;
+ long generation;
+
bool IQueueOwner.Has(string key) =>
IndexOf(host.State, key) >= 0;
diff --git a/src/DiffEngineViewer/Ipc/OwnerLink.cs b/src/DiffEngineViewer/Ipc/OwnerLink.cs
index 24f249c3..15d95622 100644
--- a/src/DiffEngineViewer/Ipc/OwnerLink.cs
+++ b/src/DiffEngineViewer/Ipc/OwnerLink.cs
@@ -59,6 +59,25 @@ sealed class OwnerLink(SessionHost host, int port)
record Outbound(ViewerVerb Verb, string? Key, string? Body);
+ ///
+ /// The last full listing, under the tag the owner gave it, and the queue parsed out of it. An
+ /// owner answers "unchanged" to that tag rather than serializing every patch again, and this
+ /// is what stands in for the listing it did not send. Null from an owner that gives no tags,
+ /// which is then asked in full every time, as before them.
+ ///
+ record Held(string Tag, InlineQueue Pending, ViewerResponse Response);
+
+ Held? held;
+
+ ///
+ /// Whether the window is hidden, set by the render loop as it hides and shows it. A hidden
+ /// window is listed at : nobody is watching it follow the owner,
+ /// and what brings it back - a focus riding the listing - can wait a second.
+ ///
+ public bool Hidden { get; set; }
+
+ public static TimeSpan HiddenInterval { get; set; } = TimeSpan.FromSeconds(1);
+
public void Post(ViewerVerb verb, string? key, string? body = null) =>
Enqueue(() => Send(new(verb, key, body)));
@@ -161,7 +180,7 @@ public bool Pump(out bool sent) =>
///
bool List(string? message)
{
- if (!ViewerClient.TrySend(new(ViewerVerb.ListFull), out var response, port, Wait))
+ if (!ViewerClient.TrySend(new(ViewerVerb.ListFull, Body: held?.Tag), out var response, port, Wait))
{
return false;
}
@@ -179,7 +198,26 @@ bool List(string? message)
return true;
}
- var pending = InlineQueue.From(ViewerListing.Pending(response.Items));
+ InlineQueue pending;
+ if (response.Unchanged &&
+ held is not null &&
+ response.Tag == held.Tag)
+ {
+ // Nothing sent and nothing to parse: the queue is the one already held. Its files are
+ // still read below, since a re-run rewriting one changes no listing.
+ pending = held.Pending;
+ response = held.Response;
+ }
+ else
+ {
+ pending = InlineQueue.From(ViewerListing.Pending(response.Items));
+ // Without its window command, which was for this listing and is acted on below, not
+ // for every unchanged answer after it
+ held = response.Tag is { } tag
+ ? new(tag, pending, response with { Window = null, WindowKey = null })
+ : null;
+ }
+
var changes = ReadChanges(response);
host.Mutate(_ => ViewerSession.Sync(_, pending, changes, message, response.Progress));
@@ -271,7 +309,7 @@ void Pump(Cancel cancel)
return;
}
- WaitHandle.WaitAny([cancel.WaitHandle, wake], Interval);
+ WaitHandle.WaitAny([cancel.WaitHandle, wake], Hidden ? HiddenInterval : Interval);
}
}
diff --git a/src/DiffEngineViewer/ViewerProgram.cs b/src/DiffEngineViewer/ViewerProgram.cs
index 2847f3e1..bc14a3f3 100644
--- a/src/DiffEngineViewer/ViewerProgram.cs
+++ b/src/DiffEngineViewer/ViewerProgram.cs
@@ -320,13 +320,17 @@ static void Loop(
return;
}
+ var hide = command == WindowCommand.Hide;
+ // A focus shows the window as well as raising it
+ link?.Hidden = hide;
+
if (command == WindowCommand.Focus)
{
window.Focus();
continue;
}
- window.SetHidden(command == WindowCommand.Hide);
+ window.SetHidden(hide);
}
// Committed under the lock rather than read and acted on. Between reading Exit and the
@@ -394,6 +398,8 @@ static void Loop(
host.State.Queue.Count > 0)
{
window.SetHidden(true);
+ link?.Hidden = true;
+
continue;
}
diff --git a/todo.md b/todo.md
index ffd5dfc7..8c6d602d 100644
--- a/todo.md
+++ b/todo.md
@@ -234,6 +234,5 @@ Library and inline
## Perf
-- [ ] **An attached viewer polls `ListFull` five times a second, even while hidden.** The owner re-serialises every patch (base64 twice) inside its gate and the viewer re-parses all of it (`src/DiffEngineViewer/Ipc/OwnerLink.cs:100`, `src/DiffEngine/Protocol/ViewerListing.cs`, `src/DiffEngineTray/OwnedInlineHost.cs:259-284`). Add a generation or etag and answer "unchanged"; poll slower while hidden.
- [ ] macOS repaints the whole window every frame (`native/swift/Sources/Deview/Runtime.swift:139-140`). Redraw only when the frame, bounds or a picture stamp change, and cache scaled pictures.
- [ ] Windows image panes rescale from full resolution and redraw the checkerboard on every paint (11 to 40 ms per image), and decode on the UI thread (`ViewerCanvas.cs:385-420`, `ImageCache.cs:56-71`). Cache the composited scaled bitmap per path, stamp and size.