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.