From faecb85db8a53dcce9b3de9c19b6dde7ab0855ee Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 23 Sep 2026 14:25:36 +1000 Subject: [PATCH] Answer an attached viewer's unchanged listing without resending it An attached viewer asks for the full listing five times a second, even hidden, and a full listing is every patch base64'd twice by the owner, inside its gate, then parsed again by the viewer - to learn, almost every time, that nothing changed. The owner now tags each full listing, and the viewer sends back the tag of the one it holds. While the tag still stands the owner answers "unchanged" and nothing else, and the viewer keeps the queue it already parsed; the tracked files are still stat'd each pass, since a re-run rewriting one changes no listing. The tag is taken before the listing is built, so a change landing in between costs one more full listing rather than a stale one kept. A tag is cheap to take: both queues are immutable, so a new reference is a new generation. The tray also folds in its tracked files, which its own scan changes, and a count of stashed window commands, so a focus is never answered unchanged. Each owner's tags carry an id of their own, so a tag held across an owner restart cannot match. A hidden window lists once a second instead of five times. Older owners ignore the tag and answer in full; older viewers send none and are answered in full. --- .../InlineQueueClientTests.cs | 2 + src/DiffEngine.Tests/ViewerProtocolTests.cs | 47 ++++++++++++++ src/DiffEngine/Protocol/IQueueOwner.cs | 8 +++ .../Protocol/ViewerMessageHandler.cs | 24 ++++++- src/DiffEngine/Protocol/ViewerResponse.cs | 44 ++++++++++++- .../TrayViewerSyncTest.cs | 27 ++++++++ src/DiffEngineTray/OwnedInlineHost.cs | 45 +++++++++++++ .../AttachedViewerTests.cs | 64 +++++++++++++++++++ .../IpcTests.AcceptAnUnknownKey.verified.txt | 3 +- .../IpcTests.List.verified.txt | 3 +- .../IpcTests.ListWhenEmpty.verified.txt | 3 +- src/DiffEngineViewer/Ipc/MessageHandler.cs | 25 ++++++++ src/DiffEngineViewer/Ipc/OwnerLink.cs | 44 ++++++++++++- src/DiffEngineViewer/ViewerProgram.cs | 8 ++- todo.md | 1 - 15 files changed, 338 insertions(+), 10 deletions(-) diff --git a/src/DiffEngine.Tests/InlineQueueClientTests.cs b/src/DiffEngine.Tests/InlineQueueClientTests.cs index 748ee8713..e3b233a27 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 168291a2e..2954e2469 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 2768a740c..7ae18e098 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 d8c8ee23d..6d8b3fc95 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 5e766a8ab..286d68f5a 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 2fc128038..6fc2a21ed 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 afdea9d1a..9e78f91e5 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 635912361..21006a6be 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 38cce331f..176a07912 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 2c61dfe8b..567c9e756 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 6c8ee9bb4..9d29d49ce 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 810264c2f..8dcdef7cb 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 24f249c3b..15d956226 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 2847f3e14..bc14a3f37 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 ffd5dfc79..8c6d602d8 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.