From 7b3da81ad53fb07e29e069716cd6987228afee70 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 23 Sep 2026 12:06:28 +1000 Subject: [PATCH 1/2] Cut per frame, per verification and per scan work found in the review Eleven of the perf items in todo.md, each described in the pull request. The attached viewer's polling, macOS repainting and Windows image caching are left. --- native/CMakeLists.txt | 3 + src/DiffEngine/Inline/InlineStaging.cs | 34 ++++++++++ src/DiffEngine/Process/ProcessCleanup.cs | 8 +++ src/DiffEngine/Tray/PiperClient.cs | 52 +++++++++++++++ .../PiperTest.SendOnly.verified.txt | 12 +--- src/DiffEngineTray/Tracker.cs | 64 +++++++++++++++++++ .../FormsViewerWindow.cs | 22 ++++++- src/DiffEngineViewer.Windows/ViewerCanvas.cs | 3 +- src/DiffEngineViewer/DiffRows.cs | 14 +++- src/DiffEngineViewer/Model/RowText.cs | 24 +++++++ src/DiffEngineViewer/Native/ScreenPayload.cs | 10 +-- src/DiffEngineViewer/QueueProjection.cs | 63 ++++++++++++------ src/DiffEngineViewer/SelectionText.cs | 43 +++++++++++-- todo.md | 22 +++---- 14 files changed, 321 insertions(+), 53 deletions(-) diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index c9198df8..e596f134 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -30,6 +30,9 @@ set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(BUILD_GAMES OFF CACHE BOOL "" FORCE) set(CUSTOMIZE_BUILD ON CACHE BOOL "" FORCE) +# raylib spins for the last part of every frame by default, for timing precision nothing here +# needs: most of a millisecond of a core per frame, for as long as the viewer is open. +set(SUPPORT_PARTIALBUSY_WAIT_LOOP OFF CACHE BOOL "" FORCE) set(SUPPORT_MODULE_RAUDIO OFF CACHE BOOL "" FORCE) set(SUPPORT_MODULE_RMODELS OFF CACHE BOOL "" FORCE) # The formats the viewer compares as pictures and raylib can decode. WebP and ICO are also diff --git a/src/DiffEngine/Inline/InlineStaging.cs b/src/DiffEngine/Inline/InlineStaging.cs index c78c4dec..a4acdb6e 100644 --- a/src/DiffEngine/Inline/InlineStaging.cs +++ b/src/DiffEngine/Inline/InlineStaging.cs @@ -166,7 +166,41 @@ static bool IsOneCallSite(List<(string PatchPath, InlinePatch Patch)> staged) return staged.All(_ => _.Patch.LineHint == line); } + /// + /// Read again only when the directory has changed since. runs once per + /// verification and used to read and parse every staged patch each time, so a run with a few + /// hundred staged was reading them a few hundred times over. Creating or deleting a trio is + /// what changes a directory's write time, and overwriting one keeps the name - which is derived + /// from the call site and framework that matching reads - so what is cached still matches. + /// static List<(string PatchPath, InlinePatch Patch)> ReadStaged(string directory) + { + DateTime written; + try + { + written = Directory.GetLastWriteTimeUtc(directory); + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) + { + return []; + } + + if (stagedCache.TryGetValue(directory, out var cached) && + cached.Written == written) + { + return cached.Patches; + } + + var patches = ReadStagedFiles(directory); + stagedCache[directory] = (written, patches); + return patches; + } + + static readonly ConcurrentDictionary Patches)> stagedCache = + new(StringComparer.OrdinalIgnoreCase); + + static List<(string PatchPath, InlinePatch Patch)> ReadStagedFiles(string directory) { var result = new List<(string, InlinePatch)>(); string[] files; diff --git a/src/DiffEngine/Process/ProcessCleanup.cs b/src/DiffEngine/Process/ProcessCleanup.cs index 3b925380..3d104c46 100644 --- a/src/DiffEngine/Process/ProcessCleanup.cs +++ b/src/DiffEngine/Process/ProcessCleanup.cs @@ -68,6 +68,14 @@ public static void Kill(string command) Logging.Write($"Kill: {command}. Matching count: {matchingCommands.Count}"); if (matchingCommands.Count == 0) { + // Only when someone will read it. This is the usual outcome of a passing verification, + // and on Linux and macOS the list is every process the user owns, so the joined string + // was hundreds of KB of garbage per test with logging off. + if (!Logging.enabled) + { + return; + } + var separator = Environment.NewLine + "\t"; var joined = string.Join(separator, Commands.Select(_ => _.Command)); Logging.Write($"No matching commands. All commands: {separator}{joined}."); diff --git a/src/DiffEngine/Tray/PiperClient.cs b/src/DiffEngine/Tray/PiperClient.cs index 1caca83f..7dfc04c1 100644 --- a/src/DiffEngine/Tray/PiperClient.cs +++ b/src/DiffEngine/Tray/PiperClient.cs @@ -1,3 +1,4 @@ +using System.Net.NetworkInformation; static class PiperClient { public static int Port = 3492; @@ -86,6 +87,12 @@ public static string BuildMovePayload(string tempFile, string targetFile, string /// static bool Send(string payload) { + if (!PortIsHeld()) + { + HandleNoListener(payload); + return false; + } + try { InnerSend(payload); @@ -100,6 +107,14 @@ static bool Send(string payload) static async Task SendAsync(string payload, Cancel cancel) { + // Before the listener check, so a cancelled send says so whether or not a tray is there + cancel.ThrowIfCancellationRequested(); + if (!PortIsHeld()) + { + HandleNoListener(payload); + return false; + } + try { await InnerSendAsync(payload, cancel); @@ -123,6 +138,17 @@ static async Task SendAsync(string payload, Cancel cancel) } } + static void HandleNoListener(string payload) => + Trace.WriteLine( + $""" + Failed to send payload to DiffEngineTray. + + Payload: + {payload} + + Nothing is listening on the tray's port. + """); + static void HandleSendException(string payload, Exception exception) => Trace.WriteLine( $""" @@ -188,6 +214,32 @@ static async Task InnerSendAsync(string payload, Cancel cancel) } } + /// + /// Whether anything is listening, asked of the OS rather than found out by connecting. Whether + /// a tray runs is read once per process, so after it exits every move and delete still came + /// here, and a connect to a port nobody holds is not refused at once everywhere: where the SYN + /// is dropped it runs to its timeout, two seconds a send. The listener table answers in well + /// under a millisecond. A table that cannot be read leaves the connect to decide. + /// + static bool PortIsHeld() + { + try + { + var port = Port; + return IPGlobalProperties.GetIPGlobalProperties() + .GetActiveTcpListeners() + .Any(_ => _.Port == port); + } + catch (NetworkInformationException) + { + return true; + } + catch (PlatformNotSupportedException) + { + return true; + } + } + static IPEndPoint GetEndpoint() => new(IPAddress.Loopback, Port); } \ No newline at end of file diff --git a/src/DiffEngineTray.Tests/PiperTest.SendOnly.verified.txt b/src/DiffEngineTray.Tests/PiperTest.SendOnly.verified.txt index 06f3420c..ed81591f 100644 --- a/src/DiffEngineTray.Tests/PiperTest.SendOnly.verified.txt +++ b/src/DiffEngineTray.Tests/PiperTest.SendOnly.verified.txt @@ -10,11 +10,7 @@ Payload: "ProcessId":10 } -Exception: -System.Net.Sockets.SocketException (10061): No connection could be made because the target machine actively refused it. - at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ThrowException(SocketError error, CancellationToken cancellationToken) - at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.System.Threading.Tasks.Sources.IValueTaskSource.GetResult(Int16 token) - at System.Net.Sockets.TcpClient.CompleteConnectAsync(ValueTask task), +Nothing is listening on the tray's port., Failed to send payload to DiffEngineTray. Payload: @@ -23,9 +19,5 @@ Payload: } -Exception: -System.Net.Sockets.SocketException (10061): No connection could be made because the target machine actively refused it. - at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ThrowException(SocketError error, CancellationToken cancellationToken) - at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.System.Threading.Tasks.Sources.IValueTaskSource.GetResult(Int16 token) - at System.Net.Sockets.TcpClient.CompleteConnectAsync(ValueTask task) +Nothing is listening on the tray's port. ] \ No newline at end of file diff --git a/src/DiffEngineTray/Tracker.cs b/src/DiffEngineTray/Tracker.cs index d4a853f8..b75475cc 100644 --- a/src/DiffEngineTray/Tracker.cs +++ b/src/DiffEngineTray/Tracker.cs @@ -82,10 +82,27 @@ void RemoveAndKill(TrackedMove tacked) { return; } + // A pair found different, and untouched since, is still different. The scan runs every two + // seconds and read both files through again each time, which for a few large same-size + // pairs - bitmaps, fixed-size data - was hundreds of megabytes a scan for as long as they + // stayed pending. + var stamp = Stamp(move); + if (stamp is not null && + differing.TryGetValue(move.Temp, out var known) && + known == stamp) + { + return; + } + try { if (!await FileComparer.FilesAreEqual(move.Temp, move.Target)) { + if (stamp is not null) + { + differing[move.Temp] = stamp.Value; + } + return; } } @@ -99,6 +116,23 @@ void RemoveAndKill(TrackedMove tacked) RemoveAndKill(pair.Value); } + readonly ConcurrentDictionary differing = new(StringComparer.OrdinalIgnoreCase); + + static (long, DateTime, long, DateTime)? Stamp(TrackedMove move) + { + try + { + var temp = new FileInfo(move.Temp); + var target = new FileInfo(move.Target); + return (temp.Length, temp.LastWriteTimeUtc, target.Length, target.LastWriteTimeUtc); + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) + { + return null; + } + } + void ToggleActive() { if (TrackingAny) @@ -613,6 +647,15 @@ bool InnerMove(TrackedMove move, AcceptBatch batch) return true; } + // Nothing waiting will change these, and every retry is another 400ms of a frozen + // menu - an accept-all in a read-only workspace sat through all eight for every move + if (CannotEverMove(move)) + { + Log.Warning("Could not accept `{Name}`: the target is read-only or its directory is missing. Kept pending", move.Name); + acceptFailed?.Invoke(move); + return false; + } + var locked = FindLockedFiles(move); if (locked == null) { @@ -645,6 +688,27 @@ bool InnerMove(TrackedMove move, AcceptBatch batch) return false; } + static bool CannotEverMove(TrackedMove move) + { + try + { + var directory = Path.GetDirectoryName(move.Target); + if (directory is not null && + !Directory.Exists(directory)) + { + return true; + } + + return File.Exists(move.Target) && + new FileInfo(move.Target).IsReadOnly; + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) + { + return false; + } + } + bool ShouldKill(TrackedMove move, LockedFiles locked, AcceptBatch batch) { if (move.KillLockingProcess || diff --git a/src/DiffEngineViewer.Windows/FormsViewerWindow.cs b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs index f0315edc..cb32a5c0 100644 --- a/src/DiffEngineViewer.Windows/FormsViewerWindow.cs +++ b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs @@ -62,10 +62,30 @@ public bool Present(Screen screen) return false; } - Thread.Sleep(frameMilliseconds); + Wait(); return true; } + /// + /// Until the next frame is due or input arrives, whichever is first. Thread.Sleep(16) woke on + /// the default 15.6ms timer tick after the one it asked for, so about 31ms: half the frame + /// rate, with every key and click waiting out the rest of it. 15 lands on the next tick, and + /// input ends the wait at once. Hidden, which a tray keeps it for days, there is nothing to + /// draw, so it wakes a few times a second rather than sixty. + /// + void Wait() + { + var timeout = form.Visible ? frameMilliseconds - 1 : hiddenMilliseconds; + MsgWaitForMultipleObjectsEx(0, IntPtr.Zero, (uint) timeout, allInput, inputAvailable); + } + + const int hiddenMilliseconds = 100; + const uint allInput = 0x04FF; + const uint inputAvailable = 0x0004; + + [DllImport("user32.dll")] + static extern uint MsgWaitForMultipleObjectsEx(uint count, IntPtr handles, uint milliseconds, uint wakeMask, uint flags); + public ViewerInput Poll() => form.IsDisposed ? default : form.Drain(); diff --git a/src/DiffEngineViewer.Windows/ViewerCanvas.cs b/src/DiffEngineViewer.Windows/ViewerCanvas.cs index 254099b2..25582c5a 100644 --- a/src/DiffEngineViewer.Windows/ViewerCanvas.cs +++ b/src/DiffEngineViewer.Windows/ViewerCanvas.cs @@ -502,7 +502,8 @@ void DrawRow(Graphics graphics, Pane pane, int index, Rectangle bounds) Cellular(bounds.X, bounds.Y, gutter, bounds.Height)); Painter.Draw( graphics, - RowText.Flatten(row.Text), + // No wider than the pane can show in pixels, which no line of characters can exceed + RowText.Clip(RowText.Flatten(row.Text), bounds.Width), font, Palette.Foreground(row.Kind), Cellular(bounds.X + gutter, bounds.Y, bounds.Width - gutter, bounds.Height)); diff --git a/src/DiffEngineViewer/DiffRows.cs b/src/DiffEngineViewer/DiffRows.cs index 708ae671..bb3af4ac 100644 --- a/src/DiffEngineViewer/DiffRows.cs +++ b/src/DiffEngineViewer/DiffRows.cs @@ -1,3 +1,5 @@ +using DiffPlex; +using DiffPlex.Chunkers; using DiffPlex.DiffBuilder; using DiffPlex.DiffBuilder.Model; @@ -14,14 +16,22 @@ public static (IReadOnlyList Left, IReadOnlyList Right) Build(string l // on indentation or a trailing space came back Unchanged on every row, so the panes drew no // markers, NextChange found nothing, and the reviewer was shown a failure with no visible // difference. Whitespace is exactly what the F# layout convention is about. - var model = SideBySideDiffBuilder.Diff( + var model = builder.BuildDiffModel( rightText, leftText, - ignoreWhiteSpace: false, + ignoreWhitespace: false, ignoreCase: false); return (Convert(model.NewText.Lines), Convert(model.OldText.Lines)); } + /// + /// Lines chunked into lines, and each line's words into the whole line. The builder diffs the + /// words of every modified pair to fill SubPieces, which nothing here reads, and that pass + /// grows with the square of the line length - minified JSON, a base64 blob. A line that is its + /// own single word leaves the rows identical and the pass trivial. + /// + static readonly SideBySideDiffBuilder builder = new(Differ.Instance, LineChunker.Instance, LineChunker.Instance); + static List Convert(List lines) { var rows = new List(lines.Count); diff --git a/src/DiffEngineViewer/Model/RowText.cs b/src/DiffEngineViewer/Model/RowText.cs index 91a677cf..6a148aba 100644 --- a/src/DiffEngineViewer/Model/RowText.cs +++ b/src/DiffEngineViewer/Model/RowText.cs @@ -5,6 +5,30 @@ /// static class RowText { + /// + /// No more of a row than can be on screen. Nothing scrolls horizontally, so a character past + /// the window's width is never drawn - but a renderer still laid out and measured the whole + /// line, and a one megabyte minified line cost most of a second a paint. Every character is + /// at least one cell wide, so the window's width in characters is always enough. Never ends + /// between the halves of a surrogate pair. + /// + public static string Clip(string text, int cells) + { + if (text.Length <= cells) + { + return text; + } + + var length = Math.Max(0, cells); + if (length > 0 && + char.IsHighSurrogate(text[length - 1])) + { + length--; + } + + return text[..length]; + } + public static string Flatten(string text) { if (text.AsSpan().IndexOfAny('\t', '\r', '\n') < 0) diff --git a/src/DiffEngineViewer/Native/ScreenPayload.cs b/src/DiffEngineViewer/Native/ScreenPayload.cs index 6efd34d2..00a4934b 100644 --- a/src/DiffEngineViewer/Native/ScreenPayload.cs +++ b/src/DiffEngineViewer/Native/ScreenPayload.cs @@ -34,8 +34,8 @@ public void Build(Screen screen) (subtitleOffset, subtitleLength) = Add(screen.Subtitle); (statusOffset, statusLength) = Add(screen.Status); - panes[0] = AddPane(screen.Left); - panes[1] = AddPane(screen.Right); + panes[0] = AddPane(screen.Left, screen.Columns); + panes[1] = AddPane(screen.Right, screen.Columns); foreach (var button in screen.Buttons) { @@ -157,13 +157,15 @@ unsafe DeviewScreen Native( MenuRow = menuRow }; - DeviewPane AddPane(Pane pane) + DeviewPane AddPane(Pane pane, int columns) { var (headerOffset, headerLength) = Add(pane.Header); var rowOffset = rows.Count; foreach (var row in pane.Rows) { - var (textOffset, textLength) = Add(row.Text); + // Clipped to the window, for the reason RowText.Clip gives: marshalled and laid out + // whole every frame otherwise + var (textOffset, textLength) = Add(RowText.Clip(row.Text, columns)); rows.Add( new() { diff --git a/src/DiffEngineViewer/QueueProjection.cs b/src/DiffEngineViewer/QueueProjection.cs index aa103523..45e1300b 100644 --- a/src/DiffEngineViewer/QueueProjection.cs +++ b/src/DiffEngineViewer/QueueProjection.cs @@ -38,12 +38,38 @@ public static IReadOnlyList Order(IReadOnlyList entries) buckets.Add(null); } + // Each entry's group worked out once, and its mates collected in one pass, rather than every + // entry asking every other one for a key built from two new strings. This runs twice per + // change to the queue, under the lock the render loop takes, and at a few hundred entries + // the pairwise version was tens of milliseconds and megabytes of garbage each time. + var groups = new string?[entries.Count]; + var mates = new Dictionary<(string?, string), List>(); + for (var index = 0; index < entries.Count; index++) + { + var entry = entries[index]; + if (TestGroup(entry) is not { } group) + { + continue; + } + + groups[index] = group; + var key = (entry.Solution, group); + if (!mates.TryGetValue(key, out var list)) + { + list = []; + mates[key] = list; + } + + list.Add(entry); + } + var result = new List(entries.Count); var emitted = new HashSet(ReferenceEqualityComparer.Instance); foreach (var bucket in buckets) { - foreach (var entry in entries) + for (var index = 0; index < entries.Count; index++) { + var entry = entries[index]; if (entry.Solution != bucket || !emitted.Add(entry)) { @@ -51,16 +77,14 @@ public static IReadOnlyList Order(IReadOnlyList entries) } result.Add(entry); - if (TestGroup(entry) is not { } group) + if (groups[index] is not { } group) { continue; } - foreach (var mate in entries) + foreach (var mate in mates[(bucket, group)]) { - if (mate.Solution == bucket && - TestGroup(mate) == group && - emitted.Add(mate)) + if (emitted.Add(mate)) { result.Add(mate); } @@ -387,25 +411,26 @@ static string[] Labels(IReadOnlyList entries) return labels; } + /// + /// Counted once per label rather than each entry compared with every other, since this runs + /// every frame over the whole queue. + /// static List Collisions(IReadOnlyList entries, string[] labels) { - var collisions = new List(); + var counts = new Dictionary<(string?, string), int>(); for (var index = 0; index < entries.Count; index++) { - if (LabelPath(entries[index]) is null) - { - continue; - } + var key = (entries[index].Solution, labels[index]); + counts[key] = counts.TryGetValue(key, out var count) ? count + 1 : 1; + } - for (var other = 0; other < entries.Count; other++) + var collisions = new List(); + for (var index = 0; index < entries.Count; index++) + { + if (LabelPath(entries[index]) is not null && + counts[(entries[index].Solution, labels[index])] > 1) { - if (other != index && - entries[other].Solution == entries[index].Solution && - labels[other] == labels[index]) - { - collisions.Add(index); - break; - } + collisions.Add(index); } } diff --git a/src/DiffEngineViewer/SelectionText.cs b/src/DiffEngineViewer/SelectionText.cs index 2aaa992f..7a52dcb1 100644 --- a/src/DiffEngineViewer/SelectionText.cs +++ b/src/DiffEngineViewer/SelectionText.cs @@ -132,18 +132,51 @@ public static string All(QueueEntry entry, PaneSide side) => /// What the status line says while something is selected. The universal statement about a /// selection: the heads that can draw a highlight also draw this, and the one that cannot /// still says a selection exists and how much of one. + /// + /// Counted from the spans rather than by building the text, because this runs every frame for + /// as long as a selection exists: ctrl+a over a large file built megabytes of string sixty + /// times a second only to measure it. The counts are what would produce - one + /// line per non-filler row, joined by one newline each. + /// /// public static string Summary(TextSelection selection, QueueEntry entry) { - var text = Of(selection, entry); - if (text.Length == 0) + var rows = Rows(entry, selection.Side); + var (startRow, _) = selection.Start; + var (endRow, _) = selection.End; + var lines = 0; + var length = 0; + for (var index = Math.Max(0, startRow); index <= endRow && index < rows.Count; index++) + { + var row = rows[index]; + if (row.Kind == RowKind.Filler) + { + continue; + } + + lines++; + length += Span(selection, selection.Side, index, row.Text).Length; + } + + if (lines == 0) { return "nothing selected"; } - var lines = text.Count(_ => _ == '\n') + 1; - var characters = $"{text.Length} character{(text.Length == 1 ? "" : "s")}"; - return lines == 1 ? $"selected {characters}" : $"selected {lines} lines, {characters}"; + // The newlines joining the lines + length += lines - 1; + if (length == 0) + { + return "nothing selected"; + } + + var characters = $"{length} character{(length == 1 ? "" : "s")}"; + if (lines == 1) + { + return $"selected {characters}"; + } + + return $"selected {lines} lines, {characters}"; } static int ClampRow(int row, IReadOnlyList rows) => diff --git a/todo.md b/todo.md index 21a20f45..18ee9bc2 100644 --- a/todo.md +++ b/todo.md @@ -180,20 +180,20 @@ Library and inline ## Perf -- [ ] **Queue projection is O(n²) per frame and per mutation under the lock** (`src/DiffEngineViewer/QueueProjection.cs`). `Order` calls `TestGroup` (two string allocations) for every pair and runs twice per inline change and per `Sync`; `Rows`/`Labels`/`Collisions` run every frame with n² comparisons, and grouped entries always collide so all four passes run. Compute the group key once per entry, group with a dictionary, detect collisions with a `(solution, label)` count, and cache `Rows` per queue instance. +- [x] **Queue projection is O(n²) per frame and per mutation under the lock** (`src/DiffEngineViewer/QueueProjection.cs`). `Order` calls `TestGroup` (two string allocations) for every pair and runs twice per inline change and per `Sync`; `Rows`/`Labels`/`Collisions` run every frame with n² comparisons, and grouped entries always collide so all four passes run. Compute the group key once per entry, group with a dictionary, detect collisions with a `(solution, label)` count, and cache `Rows` per queue instance. - [ ] **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. -- [ ] **On macOS and Linux every passing verification builds a string of every process's command line**, even with logging off (`src/DiffEngine/Process/ProcessCleanup.cs:66-71`; the Unix `FindAll` ignores the name filter). Guard with `Logging.enabled`, and keep only commands that start with a resolved tool's exe path. -- [ ] `SelectionText.Summary` rebuilds the whole selected text every frame (`src/DiffEngineViewer/ScreenBuilder.cs:248`, `SelectionText.cs:131-142`), and a right-click builds both whole sides just to test for emptiness (`MenuState.cs:76`). Count from span lengths, once per selection. -- [ ] DiffPlex computes word-level sub-diffs that are never read, has no cost cap for large wholly-different files, and runs under the lock for inline changes and tracked arrivals (`src/DiffEngineViewer/DiffRows.cs:17-22`, `QueueEntry.cs:59-63`). Use a whole-line chunker for the word pass; guard with an edit-distance lower bound. +- [x] **On macOS and Linux every passing verification builds a string of every process's command line**, even with logging off (`src/DiffEngine/Process/ProcessCleanup.cs:66-71`; the Unix `FindAll` ignores the name filter). Guard with `Logging.enabled`, and keep only commands that start with a resolved tool's exe path. +- [x] `SelectionText.Summary` rebuilds the whole selected text every frame (`src/DiffEngineViewer/ScreenBuilder.cs:248`, `SelectionText.cs:131-142`), and a right-click builds both whole sides just to test for emptiness (`MenuState.cs:76`). Count from span lengths, once per selection. +- [x] DiffPlex computes word-level sub-diffs that are never read, has no cost cap for large wholly-different files, and runs under the lock for inline changes and tracked arrivals (`src/DiffEngineViewer/DiffRows.cs:17-22`, `QueueEntry.cs:59-63`). Use a whole-line chunker for the word pass; guard with an edit-distance lower bound. - [ ] 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 `Thread.Sleep(16)` sleeps about 30 ms at the default timer resolution, and a hidden process wakes about 34 times a second forever (`src/DiffEngineViewer.Windows/FormsViewerWindow.cs:65`). Use `MsgWaitForMultipleObjectsEx`, with a longer timeout while hidden. +- [x] Windows `Thread.Sleep(16)` sleeps about 30 ms at the default timer resolution, and a hidden process wakes about 34 times a second forever (`src/DiffEngineViewer.Windows/FormsViewerWindow.cs:65`). Use `MsgWaitForMultipleObjectsEx`, with a longer timeout while hidden. - [ ] 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. -- [ ] All three heads lay out each row's full text though only about 35 cells fit (`ViewerCanvas.cs:503-508`, `src/DiffEngineViewer/Native/ScreenPayload.cs:164-177`); a 1 MB minified line costs about 0.8 s per paint on Windows. Truncate to the visible columns before drawing or marshalling. -- [ ] raylib busy-waits the last 5% of every frame: `set(SUPPORT_PARTIALBUSY_WAIT_LOOP OFF CACHE BOOL "" FORCE)` in `native/CMakeLists.txt`. -- [ ] `InlineStaging.Clear` walks the `obj` tree and re-reads and parses every staged `.inlinepatch` on each verification (`src/DiffEngine/Inline/InlineStaging.cs:94-192`). Cache per directory keyed on `LastWriteTimeUtc`. -- [ ] Tray: `SafeMove`'s 8 × 400 ms retry runs on the UI thread even for failures that cannot clear, such as a read-only target or a missing directory (`src/DiffEngineTray/Tracker.cs:535-585`, `FileEx.cs:73-90`). Retry only sharing violations. -- [ ] Tray: the 2 s scan re-reads every equal-size, different pair from scratch (`Tracker.cs:81-97`, `FileComparer.cs:18-57`). Cache length and write time with the last result. -- [ ] `PiperClient` has no unowned-port memory for 3492, and `TrayAvailable` is cached at type init, so after the tray exits every send pays a refused connect (`src/DiffEngine/Tray/PiperClient.cs:138-153`, `PendingFiles.cs:47-49`). +- [x] All three heads lay out each row's full text though only about 35 cells fit (`ViewerCanvas.cs:503-508`, `src/DiffEngineViewer/Native/ScreenPayload.cs:164-177`); a 1 MB minified line costs about 0.8 s per paint on Windows. Truncate to the visible columns before drawing or marshalling. +- [x] raylib busy-waits the last 5% of every frame: `set(SUPPORT_PARTIALBUSY_WAIT_LOOP OFF CACHE BOOL "" FORCE)` in `native/CMakeLists.txt`. +- [x] `InlineStaging.Clear` walks the `obj` tree and re-reads and parses every staged `.inlinepatch` on each verification (`src/DiffEngine/Inline/InlineStaging.cs:94-192`). Cache per directory keyed on `LastWriteTimeUtc`. +- [x] Tray: `SafeMove`'s 8 × 400 ms retry runs on the UI thread even for failures that cannot clear, such as a read-only target or a missing directory (`src/DiffEngineTray/Tracker.cs:535-585`, `FileEx.cs:73-90`). Retry only sharing violations. +- [x] Tray: the 2 s scan re-reads every equal-size, different pair from scratch (`Tracker.cs:81-97`, `FileComparer.cs:18-57`). Cache length and write time with the last result. +- [x] `PiperClient` has no unowned-port memory for 3492, and `TrayAvailable` is cached at type init, so after the tray exits every send pays a refused connect (`src/DiffEngine/Tray/PiperClient.cs:138-153`, `PendingFiles.cs:47-49`). ## Appendix: repro tests From c906e8a3aacb687338bb09a57487d014b2c7fc02 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 23 Sep 2026 12:44:52 +1000 Subject: [PATCH 2/2] Use an invalid path character that is invalid everywhere PathEntriesAreUnquotedAndEmptiesDropped used | for an entry that cannot be a path, which is only invalid on Windows, so the Linux and macOS runs kept it and failed. --- src/DiffEngine.Tests/OsSettingsResolverTest.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/DiffEngine.Tests/OsSettingsResolverTest.cs b/src/DiffEngine.Tests/OsSettingsResolverTest.cs index 1701ec50..9cf8aaec 100644 --- a/src/DiffEngine.Tests/OsSettingsResolverTest.cs +++ b/src/DiffEngine.Tests/OsSettingsResolverTest.cs @@ -11,7 +11,8 @@ public class OsSettingsResolverTest [Test] public async Task PathEntriesAreUnquotedAndEmptiesDropped() { - var paths = OsSettingsResolver.ParsePath(@"C:\one;""C:\Program Files\two"" ; ;C:\thr|ee;", ';'); + // NUL rather than a character like |, which is only invalid on Windows + var paths = OsSettingsResolver.ParsePath(@"C:\one;""C:\Program Files\two"" ; ;C:\thr" + "\0" + "ee;", ';'); await Assert.That(paths).IsEquivalentTo([@"C:\one", @"C:\Program Files\two"]); }