Skip to content

Fix the bugs and the performance items from the review: the viewer's model, the library, the inline patcher, the tray and the three heads - #922

Merged
SimonCropp merged 73 commits into
mainfrom
viewer-review-fixes
Oct 3, 2026
Merged

SimonCropp merged 73 commits into
mainfrom
viewer-review-fixes

Conversation

@SimonCropp

@SimonCropp SimonCropp commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Every bug in todo.md from the review of main at 991bc48, and then every performance item in it: the five bugs in the viewer's model, the twenty seven that six per-area reviews found (library, inline patcher, tray, and the Windows, Linux and macOS heads), and the fourteen performance items across the same areas. One commit per fix from the second round on, so any of them can be reverted alone. In the performance round a benchmark is committed ahead of each fix that replaces a path, so the earlier number can be had again from history.

Needs attention before merging

  • macOS is unverified. Nothing here can compile Swift, so the macos-14 job is the only build of the four macOS fixes and the two macOS performance changes. It compiles them and its suite passes, but two of the fixes are event handling, which no capture exercises, and neither performance change has been measured: a capture draws no spinner and never takes the scaled copy. todo.md lists the check that would confirm each on a Mac.
  • Native binaries are in. native/ changed in both rounds, and both of build-native's binaries PRs are merged into this branch: VerifyTests/DiffEngine#923 for the bug fixes and VerifyTests/DiffEngine#924 for the performance changes, built from 28f1528, after which native/ has not changed. So the committed .so and .dylib are built from the sources beside them. The C ABI is unchanged throughout.
  • Five things behave differently after the performance round, each on purpose:
    • A text diff past its budget is correct but may not be the smallest: two texts of more than 10,000 lines between them with 8,000 or more shared lines out of place. A block moved whole is still found.
    • "Accept all in" a group goes the way accept-all does: a step at a time off the render thread, with progress in the status line, and a snapshot that is refused stays in the queue with its status.
    • A bulk accept writes a source file's snapshots together, so progress moves a file at a time, and a snapshot discarded or settled while its own file is being written is written with the rest. It is not counted as accepted.
    • A queue of more than a hundred pending files is looked at a hundred a pass, so a row that is not on screen can follow its file a second or two late.
    • On Windows, a character of two UTF-16 units whose first pixel column is its pane's last used to be cut to nothing and is now drawn.
  • Verify has one call to change for staged inline trios to be cleared per framework: InlineStaging.Settle(...) in place of ClearStaged(...) in InlineEngine.Settle(). DiffEngine's half is in here.
  • Four third party tools start differently on Windows (the Word and Excel comparers, Cursor, VS Code): through CreateProcess with handle inheritance off, so a test run no longer waits on them. That was proven with a console exe, a windowed exe and a .cmd standing in; the real tools were not run.

Round one: the viewer's model (5)

  • The viewer no longer asks for a verified file (RequiresTarget: false), so a new snapshot gets no placeholder, and the eight map formats EmptyFiles has no template for reach the viewer at all.
  • A pair sent again unchanged leaves the reader's scroll, page, zoom and menu where they were.
  • Document reading survives a copy that could not be written, instead of stopping until restart.
  • PDFs are read again once a slow one returns, and the timeout counts from the last page to land.
  • A setting one viewer put back to its default is not restored by another viewer's next write.

Round two (27)

Library (3)

  • A viewer that exits with a failure before it holds the queue is reported as not launched, so an inline snapshot is staged rather than said to be queued. Resolution passes over a copy older than 20.5.0 when a newer one is there, and the NuGet cache fallback takes the highest version rather than the most recently written.
  • Tools declared UseShellExecute: false no longer inherit the test host's handles on Windows.
  • A launched viewer starts in its own folder, so it does not pin the test host's working directory.

Inline patcher (6)

  • F#: an accept into a call that does not start its line (do!, let! x =) is indented from the column the expression starts at, so it compiles. FsCompilerRoundTripTests now covers those shapes under dotnet fsi.
  • An appended Snapshot call goes in front of ConfigureAwait, ToTask and GetAwaiter, in both languages.
  • Append passes over a call in the member that already has a Snapshot call.
  • Remove of settings.Snapshot("old"); takes the statement, where it used to leave settings;. Awaited, assigned, returned or passed, only the call goes.
  • A settle no longer takes another member's entry that an accept moved onto its line, and a failing re-run of a call site that moved updates its entry rather than queueing a duplicate.
  • Staged trios are labelled with their framework, and InlineStaging.Settle clears only the running one's.

Tray (6)

  • "Accept all" no longer deletes a verified file that a move in the same sweep wrote; a move withdraws a pending delete of its target.
  • The deletes an accept-all carries out are the ones pending when it began.
  • A viewer that owns the queue and does not answer holds the deletes, rather than reading as nothing pending.
  • Main is [STAThread], so Debug view's Copy copies.
  • An owning tray stages its queue when the Windows session ends.
  • A Move or Diff for a tracked pair keeps the tool it was tracked with.

Windows head (4)

  • A decode dropped because its picture left the screen is retried when the picture comes back, instead of a spinner for good.
  • The first window is centred for the size it opens at.
  • Raise restores a window minimised from maximised as maximised.
  • Both panes give a picture the same width, so one picture on both sides is composed once. Five Windows pixel baselines move with it: the right hand picture, one pixel left.

Linux head (4), built and run in an ubuntu:24.04 container as the unix job is

  • Accept-all is a with Shift held, so Caps Lock no longer turns accept into accept-all.
  • The footer wraps its buttons and gives the status a line when they do not fit.
  • Characters JetBrains Mono lacks are drawn from the machine's fonts, found through fontconfig at run time. A capture never uses them, so no baseline depends on what is installed.
  • Text can be selected in the right pane when the left shows only filler, which is every pending delete.

macOS head (4, one of them half), compiled and run only by CI

  • Keys and clicks are queued and handed over one a poll.
  • The footer wraps, and the status gets a line of its own when there is no room beside the buttons.
  • Dragging the scroller's knob scrolls the panes as it moves. A live resize still draws the old rows, which needs a frame callback in the ABI.
  • calt and liga are off, so <> and != are drawn as the characters they are. The five OSX baselines that moved with it were taken again from the job's artifact.

Round three: performance (14)

Every performance item in todo.md, each measured before and after by a benchmark that is now in the repository. The figures are one pass of every benchmark on the tree before this round and on the branch, a project at a time on a machine doing nothing else: BenchmarkDotNet in process, .NET 10, a Ryzen 9 5900X. The Linux head's are from an ubuntu:24.04 container under Xvfb with Mesa's software rasteriser on four threads, as the unix job runs it.

Viewer model (5)

  • A screen is built when the state changes rather than once a frame (ScreenCache), and the WinForms head and the native payload both stop at a screen they were handed last frame.
  • The text diff takes out the lines only one side has before Myers runs, and has a budget of work past which it settles for a correct diff that may not be the smallest.
  • "Accept all in" a group is a batch off the render thread, as accept-all is, and a batch's bookkeeping no longer grows with the square of the queue.
  • Both sides of a document are drawn at once.
  • The watch over an owned queue's files looks at a hundred a pass, and at a pass a second while the window is hidden.
Before After
A frame in which nothing happened, 2,000 entries queued 992 µs, 3.2 MB 1 ns, 0 B
The same with 100,000 lines selected 13.8 ms, 16.6 MB 1 ns, 0 B
Encoding that frame for the macOS and Linux heads, rows of box drawing 3.2 ms 2 ns
Encoding a changed frame of those rows 3.4 ms, 2.4 MB 0.25 ms, 15 KB
Diffing 40,000 lines against 40,000 with none in common 4,011 ms 3.6 ms
Diffing 40,000 lines against the same lines in another order 4,027 ms 175 ms
The viewer reading and diffing the first of those pairs, before it answers the test 3,922 ms 19 ms
"Accept all in" a group of 200 snapshots: how long the window is held 1,287 ms 15 µs
The same, until every snapshot is written 1,257 ms 27 ms
A bulk accept's bookkeeping for 2,000 snapshots, apart from applying them 3,245 ms, 8.8 GB 0.9 ms, 1.6 MB
A pair of 50 page PDFs: the right side's first page 3,579 ms 265 ms
The same pair, both sides drawn 7,019 ms 4,324 ms
One pass of the watch over 1,000 pending files 26.9 ms 2.7 ms

Library (1)

  • The operating system's listener table is asked before a connect to a viewer port nobody may hold, since a refused loopback connect takes two seconds on Windows.
Before After
The first telling send of a test process, with nothing owning the port 2,043 ms 0.34 ms
A probe of that port, as the launch gate makes 507 ms 0.35 ms

Inline snapshots (2)

  • A bulk accept applies a source file's snapshots with one read and one write (InlineApplier.ApplyAll), each still with an outcome of its own, and the scan rents its map.
  • InlineStaging.Clear keeps the list of staging directories for a second rather than walking obj for every passing verification.
Before After
Accepting 500 snapshots in one 600 KB source file 25.9 s 0.82 s
InlineStaging.Clear with nothing staged 9.0 ms 0.35 ms

Windows head (2)

  • A row is cut to the cells its pane has before GDI+ is handed it.
  • A picture zoomed to half its own size or less is copied out of one scaled copy, made off the UI thread.
Before After
A paint of 72 rows of 2,000 character lines 15.3 ms 3.2 ms
The same in a window 3,212 px wide 48.8 ms 10.0 ms
A paint of a row holding a megabyte line 24.4 ms 0.30 ms
A paint of a 4000 by 3000 pair zoomed to 150% 49.5 ms 1.8 ms
The same at 400%, which is past half its own size and still scaled every paint 20.0 ms 14.9 ms

Linux head (2), built, run and measured in the container

  • The checkerboard behind a picture is one quad of a repeating texture, drawn only where the picture has a pixel to see through.
  • A frame is drawn only when it differs from the one on the screen, and built only when something a frame is built from has arrived. The loop still turns sixty times a second.
Before After
A second of an idle window at the size it opens at 527 ms of processor, 60 frames drawn 9 ms, none drawn
A second of an idle 4K window showing two pictures 10.4 s of processor, and 3.6 s to draw the sixty 3 ms, none drawn
One frame of two opaque pictures at 4K 60.3 ms, 113,834 triangles 27.0 ms, 542
One frame of text at 4K, which neither change touches 21.4 ms 21.5 ms

macOS head (2), compiled and run only by CI, and not measured

  • A repaint leaves out what the context's clip cannot reach and keeps the CTLines the last two draws made. Which characters can go in one run is asked of the embedded font rather than of four ranges written down, and that half is the model's, so it is measured: segmenting a hundred rows of box drawing went from 116 µs and 319 KB to 15 µs and 3 KB.
  • An enlarged picture below its own size is drawn from a copy at that size, made on the work queue.

What each fix left is in todo.md under Performance, with the checks that would confirm the two macOS changes on a Mac.

Tests

Each fix has tests that fail without it, except where no test can reach it: keyboard and mouse handling inside the Linux shim was verified by hand in the container with xdotool, and the macOS event handling not at all. The performance changes have tests for what they must not change (a diff past its budget is still a correct diff, a batch's outcomes are each patch's own, a frame the cache hands back is the frame a build would give) and benchmarks for what they do.

Release build is clean, and dotnet test --solution src/DiffEngine.slnx --configuration Release passes on Windows: 3,038 tests, 0 failed, 27 skipped. In the Linux container the branch as it stands builds with no warnings, the viewer's tests with the pixel snapshots on pass 771 with 1 skipped, and all fifteen Linux baselines reproduce byte for byte.

Also in here

  • todo.md loses every bug and every performance item, and gains what each fix says it did not reach. Of the smaller items, one Linux one turned out not to be a problem and is gone (GetWindowPosition is answered from a cached value in raylib 6.0), and one was run rather than left as plausible.
  • Three benchmark projects, run in process: src/DiffEngine.Benchmarks, src/DiffEngineViewer.Benchmarks and src/DiffEngineViewer.Windows.Benchmarks. claude.md says why three and why in process.
  • docs/ and claude.md describe the changed behaviour.

…nts that stopped being read

The five items under Bugs in todo.md, each with tests that fail without the fix.

- The viewer no longer asks for a verified file. It was declared RequiresTarget,
  so for a snapshot with no verified file EmptyFiles wrote a placeholder before
  the viewer heard of the pair, and the viewer compared against it as though it
  were the expected file: a blank page, or a 212 byte PDF it could not open,
  which also hid the received text. EmptyFiles has no file for .geojson, .gpx,
  .kml, .topojson, .wkt, .wkb, .fgb or .geoparquet, so for those the launch
  ended at NoEmptyFileForExtension and nothing was raised at all. The viewer
  reads a missing target as the empty side of a new snapshot, which is what it
  now gets.

- A pair that arrives again saying what the queued one says leaves the reader
  where they are, as an inline patch already did. A test that keeps failing the
  same way sends its pair on every run, and each one opened the entry again:
  back at its first change and first page, fitted, with the menu closed. The
  entry is replaced by one that reads the same and carries the new stamps,
  never kept as the same object, because TrackedWatch applies what a pass found
  by reference and a pass that looked while the run had cleared its received
  file found it gone. A document is compared by its bytes, since its text is
  read after it arrives and the arrival may not have it yet.

- DocumentWatch's loop no longer ends on a fault. A copy that could not be
  written into the cache, because something held the file or the disk was full,
  threw out of the pass, the loop said so once and returned, and no document
  was read or drawn again until the viewer was restarted. A turn that fails now
  says why, once for as long as that stays the reason, and is tried again, and
  the status line is given back when it gets through. Nothing is marked as
  started before everything that can fail has been done, so a drawing whose
  copy could not be written is not left as a spinner. A file that could not be
  read for a moment is tried again too, rather than recorded as changed.

- A PDF left behind after the timeout no longer disables PDFs for the life of
  the window. The flag is cleared when the call that was left behind returns,
  and a PDF opened meanwhile waits for it and is then read, with nothing
  recorded against it. The timeout is counted from the last page to land rather
  than from the start, so a long document that keeps landing pages is not given
  up on part way through.

- A setting one viewer put back to its default is no longer restored by another
  viewer's next write. A default is stored as no line at all, and each write
  laid everything this viewer held back over the file wherever the file had no
  value, so the projection one window reset came back with the other's window
  position. A write is now the file as it stands with the one key changed, plus
  any of this viewer's own whose earlier write failed.
The review this list came from was run with six others beside it, one each for
the library, the inline patcher, the tray, and the Windows, Linux and macOS
heads. Their findings arrived after the first list was written and are added
here, unfixed, by area.

Each says how far it was taken. Four inline patcher findings were reproduced by
running the patcher, and the F# one by compiling what it writes with dotnet
fsi. Five more were confirmed by reading the code they rest on. The rest are
marked as reported, with the reviewer's own evidence, after checking that the
code each one quotes is in the tree as quoted. Nothing under a Linux or macOS
heading has been run.
…ow's place, and a picture composed for ever

- A decode that lands for a picture no longer on screen is thrown away, and it
  left the path marked as on its way. A reader who came back to that picture
  had nothing started for it, and a spinner in each pane until the file
  changed: stepping past a picture before it had decoded, which holding Tab
  through a queue of them does to nearly every one. The mark now goes when the
  decode lands, whatever is done with the result.

- The first window was centred for its unscaled size and then scaled in place.
  WinForms centres as it creates the window, before OnHandleCreated sizes it
  for the display, so it grew down and to the right from there: at 150% on a
  1080p display the footer was under the taskbar, and that was the placement
  remembered for every run after. It is centred again for the size it has.

- Raise put a minimised window back to normal whatever it had been minimised
  from, so a maximised window came back at its restored size and was then
  remembered as not maximised. It goes back to what it was.

- The right pane's picture space was the pixel wider an odd width leaves that
  pane. One picture on both sides, which a page two identical documents share
  is, was then asked for at two sizes, and the cache keeps one composite per
  picture: each paint composed both, each landing throwing the other away, for
  as long as the entry was on screen. Both pictures are now given the same
  width, which also fits two pictures of one size to one size. Five pixel
  baselines move with it, the right hand picture one pixel to the left.
Each handler wrote its event into one field of the input the next poll reads,
and the pump dispatches everything AppKit has queued before it returns. So
whenever a frame was slow, the loop waiting behind an accept on InlineApplier's
mutex, two presses of Down scrolled once, Tab then a accepted the entry the
reader meant to skip, and d then a click on another row discarded the clicked
row, which the reader had never looked at: the managed side applies a frame's
click before its key.

Keys, clicks and menu events now go into a queue in Runtime, and
deview_poll_input hands over the one that is next, as the WinForms head does.
What adds up or says where it is now, the wheel, a drag, the scroller and a
close, is still whole per poll. The ABI is as it was.

Two things follow from handing over one a poll. The pump does not wait out its
frame while more are queued, or a fast key repeat would be handed over more
slowly than it arrives. And a context menu is not popped until the queue is
empty: popping holds the managed loop for as long as the menu is up, so a key
pressed straight after the right-click would be applied when the menu closed,
however much later that was.

Not compiled or run here. There is no Mac on this machine, so the macos-14 job
is the first to build it, and nothing in a capture exercises input.
…ff them

The footer was one row whatever was in it. The buttons were laid out left to
right with nothing to stop them at the window's edge, and the status was drawn
from the right edge whatever was already there. At the 1100 points the window
opens at, an image pair in a queue had "images differ" drawn over its last
button, and a paged document's eleven buttons needed 1405, so Zoom out and Zoom
in were off the window and could not be clicked.

A button that would pass the edge now starts another row, and the body gives up
the height. The status is drawn beside the last row when there is room right of
the last button, as before, and otherwise on a line of its own above the
buttons, losing its end if it is wider than the window. Above rather than
below, and with the buttons placed from their labels and the width alone, so
they stay where they are as the status comes and goes: a click is resolved by
position, and the status changes while the pointer is on its way.

A footer whose buttons fit one row with room for the status beside them is laid
out exactly as it was. That is every scene PixelTests captures, so no macOS
baseline is expected to move.

The managed side still slices the body as if the footer were one row. This
head's own chrome leaves it 64 points to spare, which is three rows of buttons,
or two and a status line. A paged document's buttons pass that in a window
under about 600 points wide, or about 740 with a status too long for the last
row, and the last one or two rows of the body are then not drawn.

Not compiled or run here. There is no Mac on this machine, so the macos-14 job
is the first to build it.
NSScroller tracks a press on its knob in a loop of its own, inside mouseDown,
until the button comes up. That is inside the pump, so deview_present did not
return for the length of the drag: the knob moved, every move was reported, and
the managed loop that scrolls the panes in answer ran once, at the release.

The scroller is now a subclass that takes a press on the knob as three ordinary
events, the way the view takes a selection, so there is a frame between one
move and the next and the panes scroll as the knob is dragged. It reports where
the drag has got to and leaves the knob to the frame that answers, which puts
it on the row the panes show. A press in the slot is still AppKit's: what it
does is the reader's setting, and it is over in a click. When the knob's own
rectangle cannot be read, the press goes to AppKit as before.

A window being resized is the same kind of loop, and that one cannot be stepped
around. The scroller is kept against the right edge and stretched by its
autoresizing mask while it runs, where it used to stay wherever the last frame
had put it. The panes still keep the rows they were sliced for until the mouse
comes up, drawn into the new size. Doing better takes a way to ask the managed
side for a frame from inside the loop, which is a change to the ABI, and the
readme now says so.

Not compiled or run here. There is no Mac on this machine, so the macos-14 job
is the first to build it, and a capture makes no window, so nothing there
exercises a scroller.
Core Text applies a font's calt feature unless told not to, and JetBrains Mono
does its code ligatures through calt. So macOS drew <>, !=, <=, =>, -> and ==
as one glyph each, where the Windows and Linux heads draw a glyph a character:
the title of a file comparison read <> there and as one diamond here. In a tool
whose job is to show which characters a snapshot holds, that is the wrong
answer, and most of all for the snapshot's own text.

The font is now a copy with calt and liga off, through
kCTFontFeatureSettingsAttribute.

This moves the macOS pixel baselines whose text holds a sequence calt
substitutes, and those have to be accepted again from the macos-14 job, since
they cannot be made here. Worked out from the embedded font's own GSUB table,
they are the five file mode scenes, for the <> in their title: FileDiff,
Images, ImagesEnlarged, Selection and Minimal, which also has ... at each end
of its folded rows. The five inline scenes hold no such sequence and should
come out as they were.

Not compiled or run here. There is no Mac on this machine, so the macos-14 job
is the first to build it.
Following a drag of the knob only does anything if the scroller has a knob. A
scroll view enables and disables its own scrollers, and this one is placed by
hand with no scroll view around it, so nothing here ever said it was enabled:
it was whatever an NSScroller starts as, and one that is not enabled draws its
slot with nothing in it.

Which that is could not be checked here. If a scroller starts enabled, this
changes nothing. If it does not, the pane scroller has been an empty strip
since it was added, and this is what gives it a knob. A commit of its own, so
it can be dropped alone.

Not compiled or run: there is no Mac here, and a capture makes no window.
…ame sweep just wrote

A delete for a verified file stayed tracked when a later run failed against
that file and a move onto it arrived. Accept all carries out the moves and then
the deletes, so the received file was moved into place and then deleted: both
gone, with nothing said. DiffRunner.SettleDelete withdraws such a delete, but
not from a library that predates it, not while a viewer holds the queue the
settle is sent to, and not while that port is remembered as unowned.

A move now withdraws the delete it finds waiting for its target, whichever port
it arrives on, since a run that verified against a file is what makes its
delete stale.

That leaves the delete that arrived after the move, where which of the two is
stale cannot be known. Both sweeps, the menu's and the one a displaying viewer
asks for, now leave a delete pending when a move in the same sweep wrote its
file or a move still pending is going to. It is decided by what was written
rather than by what was swept, so a move whose received file has gone, which is
dropped without writing anything, holds nothing back.
…hose

An accept-all takes its snapshots when it starts and read the deletes when
their turn came, after the snapshots had been applied. A snapshot moving inline
arrives as a patch plus a delete of the verified file the patch replaces, so a
pair landing while a batch was applying had its delete swept by a batch its
patch was never in: the verified file went while the patch was only pending.

Both sweeps now list the deletes before anything is accepted, as the viewer's
own batch does. The menu's lists them at the click, and the one a displaying
viewer asks for lists them ahead of its snapshots and hands the keys to
ITrackedFiles.AcceptAll. A delete that arrived during the batch stays pending
and is counted as neither accepted nor kept. One that was listed and has gone
since is passed over.

Ahead of the snapshots rather than beside them, because Verify raises the
delete and then queues the patch. What is left is a batch that begins between
those two sends.
…answer

Accept all asked the queue what was pending and read no answer as nothing
pending: RemoteInlineHost.List flattens an owner that could not be asked to an
empty list, which is right for a menu. So with a viewer holding the port and a
patch, cold starting or wedged, and the tray holding the paired delete, the
snapshot sweep was skipped and the delete was carried out. The verified file
went with the patch that replaces it never tried.

IInlineHost.TryList tells the three answers apart. Nothing holding the port is
still nothing pending. An owner that is there and did not say what it holds is
treated as a snapshot that was not written: the deletes are held and the user
is told the viewer did not answer.

A program that is not a viewer holding the port is no owner either, as it is
for every send DiffEngine makes, so a tray that could not bind 3493 over one
still carries out its deletes. ViewerClient already records that when a reply
is not this protocol, and FoundUnowned lets the host read it.
Program.Main was async Task. The entry point the runtime starts for one of
those is a method the compiler writes, which carries no attribute, so the
thread every tray window lives on was MTA. Clipboard.SetText throws
ThreadStateException there, which the debug view's catch for a busy clipboard
does not match, so every click on Copy threw and nothing was copied.

Main is now a synchronous [STAThread] method that blocks on Inner, which is
what the compiler written entry point did. Nothing in Inner awaits until
Application.Run() has returned, and WinForms has taken its synchronization
context back off the thread by then, so the awaits that follow continue on the
pool instead of waiting on a loop that is no longer pumping.
A tray that owns the queue stages what is pending as it exits, but only from
the unwind after Application.Run() returns. A logoff or a shutdown never gets
there. Windows sends each top level window WM_QUERYENDSESSION and then
WM_ENDSESSION and may end the process once they are answered. The tray has no
Form to turn those into a close, only the notify icon's window, so the loop
kept running until the process was ended and the queue went with it. For a
tray started at login that is how it usually stops.

SessionEndWindow is a hidden top level window that hears WM_ENDSESSION and
calls OwnedInlineHost.SessionEnding inside the message, which stages the queue
before the message is answered. Patches arriving after that are refused, as an
owning viewer refuses them once it is closing, so the sender stages what a
process on its way out would otherwise have acknowledged and lost. Nothing is
done for the query, or for an end that was called off, since a session that
carries on would then have its queue both staged and held.

Confirmed outside the repo with a tray shaped process: a notify icon window
and no Form answers the query and Application.Run() has not returned two
seconds after WM_ENDSESSION, while a hidden window made this way is sent both
messages on the UI thread. A real logoff was not run.
A move for a pair the tray already tracks was always rebuilt from what the
message carried. A Move or a Diff over the viewer port carries the two paths
and nothing about the tool, so the tray filled the gap as it does for a pair
seen for the first time, with its own choice for the extension, over a move
that had already said which tool was showing the pair.

"Open diff tool" on a viewer pair does this by hand: it starts
DiffEngineViewer --diff, which cannot bind the port and forwards the pair to
the tray as a Diff. The pair went from the viewer, not killable and open, to
whatever the tray would pick, killable and with no window, so the "Accept
open" hot key passed over a pair that was on screen in the viewer.

A move that names no tool now changes nothing about the one recorded: the exe,
its arguments, whether it may be killed, whether it kills what locks it and
whether it is the viewer all stay, and only the target is taken.

The focus race test told a focus from a Diff by the exe surviving, which a Diff
now leaves alone as well, so it asserts on the entry not having been replaced.
… GetAwaiter

An append always went on the end of the chain in C#, so await Verify(value).ConfigureAwait(false) was given a Snapshot call on a ConfiguredTaskAwaitable, which has none (CS1061), at a call site CanAnchor had already said could host a snapshot. Only F# stopped short of the end, and only at ToTask. The three members that hand back something other than a SettingsTask now end a chain in both languages, and the call goes in front of the first of them.
An append took the first entry point the search yielded. Once an accept higher in the file leaves the hint stale, the walk starts over from the member's declaration, and the first call it meets is the one most likely to have been accepted already: the patch was refused with 'already has a Snapshot call' while the call it was for sat two lines below, and a single accept dropped the entry. A call that has one is now passed over, along with any entry point inside its arguments. The call on the recorded line still decides, since a hint that lands on a call names it, and so does the nearest call when the patch names no member to bound the walk.
The appended call and a literal on its own line were both indented one level in from the line's leading whitespace. F#'s offside rule needs them to clear the column the expression starts at, and after do!, let! x =, let x = or a binding written on one line that column is further along the line: the appended call was read as the start of the next statement and the literal as offside, FS0010 either way, in source that compiled until it was accepted into. A chain ending on a closing paren or an argument at that column did the same to a call that does start its line. Both are now measured from where the expression starts, found by walking back over the call's receivers, and FsCompilerRoundTripTests compiles the shapes with dotnet fsi.
…variable

A Remove took the call off the end of whatever it hung off and kept the receiver. That is right for a verify call and wrong for a variable: settings.Snapshot("old"); became settings; (CS0201), and VerifySettings.Snapshot is public API that Verify sends a Remove for under NotInline. Where the call hangs off a name and nothing is chained after it, the statement's lines go instead, but only where that cannot change what is around them: alone on its lines, in a block and ending in its own semicolon in C#, and followed by another statement of the same block in F#. Anything else is reported rather than left as a bare receiver.
…ne names

A queue entry is keyed by its line, and accepting a snapshot moves every later call site in the file. The re-run's patches then matched no key and were queued beside the entries they should have updated, so a bulk accept applied the stale content and refused the fresh, and a passing call that had moved onto a later test's old line settled that test's entry by key. An enqueue whose key names nothing now folds into the one entry for the same member, test, mode and anchor that its framework already has content in, and takes it to the new line. A key that names another member's entry is no longer believed: a settle leaves it unless the value settles it, staged trios are cleared by the same rule, and an enqueue updates its own entry instead. The viewer keeps the reader on an entry that moved. Two existing tests gave both call sites of one member the same literal, which now reads as one call site, so they have a literal each.
…ed to it

Clearing staged trios could be scoped to a framework, but never was in practice. A test run stages through InlinePatchFile.Write with a patch that names no framework, and an unlabeled trio is cleared whichever framework asks. And the caller, Verify, could not pass an origin to InlineStaging.Clear, because the moniker the labels are written with is internal. So in a multi-targeted project with no viewer, the framework that passed deleted the staged snapshot of the one still failing. Write now stamps the framework of the process staging the file where the patch carries none, as the send to a queue owner does, and InlineStaging.Settle clears for the running framework alone. Clear is unchanged, which is what a retire wants.
…urned or passed

Removing a Snapshot call from a variable now takes the whole statement, because
settings.Snapshot("old"); otherwise became settings; which is no statement. But
every call on a variable with nothing chained after it was treated that way,
and one that could not be taken with its statement was refused. So
await task.Snapshot("old"); and var kept = task.Snapshot("old"); were refused
too, where taking the call alone leaves await task; and var kept = task; which
is what each would have run without the snapshot, and what it did before.

The statement goes only where the variable would be left as the statement.
Awaited, returned, assigned, compared or passed, the call alone is taken. A
lambda's body is still refused, since _ => _ is no body for a lambda that
returns nothing. In F# what takes the value has to sit on the same line: the =
a line above is the one a whole body hangs off.
…etter typed

The Linux head read letters as the characters typed, 'a' for accept and 'A'
for accept all. Caps Lock types 'A' with no Shift held, so with it on the key
that accepts one snapshot accepted every pending one, with no confirmation, and
d, v, q, n, p, m, r and j did nothing.

The letter is now folded to lower case and accept-all is chosen by whether
Shift is held down, which is how the Windows and macOS heads decide it. Shift
with any other letter is that letter's command, as it is on those two.
…ws only filler

A drag starts only where there is text to select, and that was asked of the
left pane whichever pane was pressed: where its text starts is read from the
first row that draws any, and a pane of nothing but filler has none. So nothing
in the right pane could be selected or copied for a pending delete, whose left
side is empty, or anywhere inside a long removed block.

It is now asked of the pane the press landed in. A press in a pane of filler
still starts nothing.
…do not fit

Every footer button was drawn on one line and the status after the last of
them, wherever that was. A paged document pending in a queue has eleven
buttons, 1199 pixels of them in a window with 1084, so Zoom in could not be
clicked and the status line was not drawn at all; in a window of its own the
same document left the status 54 pixels. The status line is where the page on
screen, a page that could not be drawn, a selection and a failed accept are
said.

Buttons now wrap onto another row at the window's edge, and a status with no
room beside the last row takes a line of its own, cut with an ellipsis if even
that is too narrow. The title stops short of the subtitle in the same way
rather than running on under it. A footer that fits is laid out exactly as
before, so the existing baselines are unchanged.

Two scenes pin it, a document in a window of its own and the same one in a
queue. Both are skipped on macOS, where they have no baseline.
…Linux

The embedded font was the only font the Linux head had, so CJK, Hangul, Arabic,
Hebrew, Thai and emoji all drew as the replacement glyph and a snapshot holding
them could not be reviewed: a line with one such character changed looked the
same on both sides. The other two heads fall back to system fonts.

The window's font now takes the machine's fonts as further sources, found
through fontconfig in the order it falls back through them. Only the fonts a
character on screen has needed are read, on a thread of their own, and
fontconfig is loaded at run time rather than linked, so a machine without it
loads the library as before. Each is scaled to the embedded font's em, and a
glyph wider than the cells the grid gave it is cut off where the next character
starts. ImGui is built with 32 bit characters so that anything past the basic
plane can be drawn at all.

A capture never draws with them. It uses the embedded font alone, so a baseline
is the same picture whatever a runner has installed, and a new scene pins that
after showing the same text in the window. It is skipped on macOS, where it has
no baseline.
A launched viewer took its working directory from the test host, which is usually the test project's output folder. On Windows a directory a process is in cannot be deleted, and a viewer hidden behind a tray lives for the session, so git clean -xdf or removing a worktree failed with nothing on screen to say what was holding it.

The viewer now starts in the folder it runs from, which it holds anyway. A relative path on a --delete or --diff launch meant relative to the host, so it is rooted first; a rooted one goes over as the caller spelt it, since the row is settled by a key built from that spelling.

DiffRunner.LaunchProcess is left alone: a third party tool resolves relative arguments against the directory it inherits, and its window is on screen for as long as it holds one.
ViewerLaunchGate waited for the port and then reported the launch whatever had happened to the process, so a viewer that could not take the launch still read as Launched. AddInlineAsync turned that into Queued, and a caller told Queued stages nothing: the snapshot was in no queue and in no file. A copy from before 20.5.0 exits with 2 on --payload, and an apphost with no runtime exits before any viewer code runs. Each held the gate for the whole five second wait and left its payload file in the temp directory.

The launcher now hands back the process, and the wait ends as Failed as soon as that process has exited with a failure. A clean exit is still waited on, since a viewer that hands its work to an owner exits with zero, and one that is running and slow is still reported as launched. A failed inline launch takes its payload file back.

Two things about which copy is started, from the same item. A wildcard over folders named for versions is taken highest first rather than most recently written, so the NuGet cache no longer yields whichever DiffEngine was restored last. And a copy stamped older than 20.5.0 is passed over while a newer one is further down the search order; it is still taken when it is the only one, and one an environment variable names is never second guessed.
…m the test host

DiffRunner.LaunchProcess started a tool declared UseShellExecute: false through Process.Start, which on Windows always hands the child every inheritable handle whatever is redirected. One of those is the pipe dotnet test reads the host's output from, and it reads until every writer has closed it. So a run that opened the Word or Excel comparer, Cursor or VS Code's launcher did not return until the process it had started was gone, and the Word comparer's stays until Word is closed. This is Verify issue 1229 again, for the tools ShellExecute was not the answer for.

On Windows those tools are now started by CreateProcess directly, with handle inheritance off and no console. ShellExecute with a hidden window was the other way to do it and is not used: the hidden request reaches the program as how to show its first window, so a console program that opens a window of its own never appears, and nothing in the file says which kind of console program it is. No console whatever the tool declared, because a console program sharing the host's console is given the host's standard handles with it, inherited or not.

Tools declared with ShellExecute are untouched, and so is everything off Windows, where nothing in a definition says which tools need the terminal's streams.
The twenty seven bugs the six per-area reviews found are fixed in the commits
before this one. todo.md loses them, and gains what each fix says it did not
reach: the macOS changes have not been run, Verify has a call to change before
staged trios are cleared per framework, and so on by area.

- docs: which copy of the viewer runs now passes over one from before 20.5.0
  when a newer one is there; the tray's Accept all says which deletes it
  carries out and which it holds; the inline page covers the F# indentation,
  what ends a chain in either language, a Remove that takes its statement,
  InlineStaging.Settle, and a settle or a re-run asking whose entry a line
  names.
- claude.md says the same for whoever works on this next, and counts the
  shim's exports correctly: eleven, not eight.
- OsSettingsResolver.Resolve documented one parameter and not the others, which
  a Release build makes an error. The Debug builds it was written under do not.
- Three comments that the fixes made untrue: ILoopHooks on the native heads
  having no modal loops, the tray's DiffToolLauncher on what DiffRunner starts
  a tool with, and the viewer definition calling itself a console program.
@SimonCropp SimonCropp changed the title Fix the five bugs from the review: new snapshots, re-runs, and documents that stopped being read Fix the bugs from the review: the viewer's model, the library, the inline patcher, the tray and the three heads Oct 3, 2026
This was referenced Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant