Skip to content

Fix the next batch of open items: the library, the inline patcher, the tray, the viewer's model and the three heads - #927

Merged
SimonCropp merged 50 commits into
mainfrom
todo-leftovers
Oct 4, 2026
Merged

SimonCropp merged 50 commits into
mainfrom
todo-leftovers

Conversation

@SimonCropp

@SimonCropp SimonCropp commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

The next batch of what todo.md had open: 61 items taken up across the seven areas, one commit each. 44 are fixed, one of them in part, 5 turned out not to be problems or needed no change, and 12 were looked at and left, each with what stood in the way now written into todo.md.

Needs attention before merging

  • macOS is unverified. Four Swift changes, not compiled before this PR's macos-14 job and not run by a person.
  • An Append with a stale hint is now refused when more than one call in the member could take it, with "Re-run the test", where it took the first and was wrong about half the time, silently. Accept-all over a file with several such members takes a run each. One commit ("Refuse an Append whose line names no call when two calls could take it"), so it can be dropped alone.
  • A held delete stays held. A delete whose file an accepted move wrote is no longer carried out by a later "Accept all": it is marked ! with the reason, and goes when accepted on its own or when a run raises it again.
  • The tray believes fewer processes. A process is a move's tool only when its command line names the received file, which closes the reused process id. A custom tool that rewrites the path it is given is no longer closed on accept.
  • A decoded picture is kept premultiplied on Windows, which was left out before because it moves translucent pixels by one level in 255. The picture baselines still pass at the suite's tolerance and were not taken again, so they are no longer exact.
  • The native payload cuts rows at half the window and a cell, on both native heads splitting the panes equally. Read, not run on macOS; the Linux baselines reproduce.
  • Listings share a kept connection of their own, used by the tray and an attached viewer. Their tests pass; their callers were not all read.
  • Native binaries. native/ changed, both the C++ and the Swift, so build-native will open its binaries PR against this branch. No ABI change.

Library (7 of 9)

  • A failed viewer launch gives its MaxInstance slot back.
  • Windows is asked for its listeners alone: 0.32 ms beside 3,000 connections, where reading every row was 13.1 ms.
  • A port the table found empty is asked about again after a second, not ten minutes, so a tray started after a test process is found.
  • Only a refusal or an unanswered connect is remembered as nobody being there.
  • On .NET Framework the kept connection is not inherited by child processes. Confirmed first: a killed net48 client's connection stayed open eight seconds, until its child exited.
  • Listings share a kept connection. Sends that change the queue stay a connection each.
  • Two tests that timed a send no longer time anything. PortIsHeld taking any failure as "may be held" is right as it is.
  • Left: the viewer staging and the caller staging too, which needs an exit code from the viewer.

Inline patcher (6 of 9)

  • A batch lexes a file once and carries the scan across edits: 500 call sites patched in memory in 286 ms, from 767.
  • A dry run is answered from the last scan of an unchanged file: anchoring 500 call sites takes 51 ms and 1.6 MB, from 834 ms and 1.3 GB.
  • The holes of an F# interpolated string are lexed as code; four shapes lost the call under them. Held to dotnet fsi over 59 lines.
  • A statement a Remove takes whole keeps its line over a Snapshot call, so applied twice it leaves the sibling.
  • The Append refusal, above.
  • A batch write that fails fails only the patches that needed it.
  • Left: a Remove with a stale hint answering AlreadyApplied (the source cannot tell it from one applied twice), and entries found by line (needs the applier, the queue, both batches and the remote host to change together).

Tray (8 of 9)

  • Held deletes, above. The version marker is removed as the session ends. "Discard (n)" runs wholly on a worker. A scan that keeps failing is said once. A file put back after a failed accept counts as a change to the listing. Tracker.differing is pruned. KeyRegisterTests no longer registers a real global hot key. The command line check, above.
  • The ninth is a survey: VS Code and Rider from the PATH are the only tools started through a script, and neither they nor the tools that hand over to another process can be tracked safely.

Viewer model (6 of 9)

Before After
A scroll or drag frame, 2,000 entries queued 745 µs, 1,077 KB 33 µs, 49 KB
Encoding a changed 4K frame of CJK rows for the native heads 3.6 ms 1.7 ms
One arrival into a 2,000 entry queue 2.2 ms 0.24 ms
A settle or a re-run in that queue 2.1 ms 0.09 ms
  • Past its budget a diff keeps the longest run of unique lines still in order: 28,800 lines kept of 40,000 with runs moved, where it kept 16,112.
  • Bulk discards are a batch, with the files thrown away outside the lock.
  • An open menu stays open when a file is written again with what it held.
  • Left: answering a sender before reading its pair, the hundred files a pass, and a snapshot withdrawn while its file is being written.

Windows head (6 of 8)

  • The footer wraps its buttons and gives a status with no room lines of its own. Two baselines moved and one scene is new.
  • The tests that post keys and wheel turns no longer take the keyboard of whoever is at the machine: their forms were the foreground window, even parked off screen.
  • A failed compose is held against its size. An ampersand in a button label is drawn. The premultiplied decode, above: 9 ms a paint at 400%, from 15.
  • A test proves by throwing that an exception comes out of a pump, in a way that cannot show the dialog.
  • The half pixel placement item is not a problem.

Linux head (7 of 10), built, run and photographed in an ubuntu:24.04 container

  • A held letter gives one command; only navigation repeats.
  • A hidden window is neither built nor drawn.
  • A picture past the texture limit is brought down to one that fits, rather than drawn as nothing. One baseline taken again.
  • Sampling near a picture's own size no longer works out a mipmap level per pixel, which was the tenth a frame of pictures had gained: 30 ms back to 27 at 4K.
  • [ ] 0 - = fall back to position on a layout with no Latin letters. A capture declares the vertex offset. Two tests fail if an idle or hidden window is drawn.
  • A latched Shift and a release that never arrives were both tried and are not problems.
  • Left: the taller footer, which changes what an ABI field means.

All nineteen Linux baselines reproduce byte for byte, eighteen unchanged.

macOS head (4 of 7), not compiled or run

  • A repeat of a held key is dropped for accept, accept all and discard.
  • The zoom keys match on what was typed. A long title stops short of the subtitle.
  • A picture both panes name keeps a scaled copy for each, which ends a loop of making one copy again for each pane in turn.

Tests

dotnet build src --configuration Release is clean and dotnet test --solution src/DiffEngine.slnx --configuration Release passes on Windows: 3,297 tests, 0 failed, 34 skipped. The library's and the viewer's suites also pass with the process held to two cores, which is what found last round's three CI failures.

Also in here

  • todo.md is regrouped by area and loses what is done; it gains what each fix left and why each item left alone was.
  • claude.md and docs/tray.md describe the changed behaviour.

The file was three lists by where an item came from, each opening with what had been fixed. It is now one list an area of what is open, with the history, the legend of evidence tags and three notes that described a change rather than work to do taken out.
…r long the key is held

A held key repeats, and ViewerView.keyDown queued every repeat. For Down that is
the point. For a, A and d it accepted or discarded the entry on screen and then
each one that took its place, none of them read, and repeats queued while a
frame was slow were still handed over after the key came up.

keyDown now drops an event that is a repeat (NSEvent.isARepeat) when the key it
maps to is accept, accept all or discard: the three of
ViewerSession.ChangesQueue that a key can be in this head. The WinForms head
does the same in ProcessCmdKey. Every other key repeats as before, and the menu
bar's items do not come through keyDown.

Not compiled and not run: nothing on the machine this was written on can build
Swift against AppKit. CI's macos-14 job compiles it, and no capture exercises a
key.

To check on a Mac: with five or more entries queued, hold a for two seconds.
One entry is accepted, where every entry was. The same for d and for shift+a.
Hold Down in a long file: the panes keep scrolling.
… are

Plus, minus and equals were matched only on charactersIgnoringModifiers, which
is the key with Option left out. On a layout that has one of them behind Option
that is the digit or letter printed on the key, so the reader could not zoom
from the keyboard. The brackets were moved to `characters` last round for the
same reason, and these three now sit beside them in that switch.

The matches on charactersIgnoringModifiers below stay, so nothing that zoomed
stops: both readings keep Shift, so on a US layout equals unshifted, plus with
Shift held and minus arrive as themselves either way. The command and control
chords are left on charactersIgnoringModifiers, since `characters` with control
held is a control character.

Not compiled and not run: nothing on the machine this was written on can build
Swift against AppKit. CI's macos-14 job compiles it, and no capture exercises a
key.

To check on a Mac: on the US layout, over a picture pair, = zooms in, shift+=
zooms in, - zooms out and 0 fits, as before. Then add a layout that has plus,
minus or equals behind Option, type it there, and it zooms where it did
nothing.
The title was drawn across the whole row and the subtitle over its right hand
end, so a title long enough to reach it was two texts on top of one another,
and neither could be read there. A deep path in a narrow window does it.

Renderer.draw now gives a title that would run on under the subtitle the row
up to one cell before it, and the text is clipped there. The title gives way,
as it does in the Linux head: what it says is also in the pane headers and the
queue, and which entry this is is said only by the subtitle.

A title that fits beside its subtitle is handed the same rect as before, so it
is drawn as it was. That is every capture: the widest title in the ten macOS
pixel scenes is 42 cells and its subtitle 4, in a row of 120.

Widths are counted in cells, as the footer's are, so a title of characters a
fallback font draws wider than a cell can still reach the subtitle when the
count says it fits.

Not compiled and not run: nothing on the machine this was written on can build
Swift against AppKit. CI's macos-14 job compiles it and its captures show the
title row unchanged.

To check on a Mac: `DiffEngineViewer --diff` two files whose names are sixty
characters each, and narrow the window. The title stops one character before
"diff", where it ran on under it.
…name one picture

A byte equal pair of documents names one page's png in both panes, since pages
are kept under the document's hash. The left pane is half the panes' width
rounded down and the right one the rest, so they can be a point apart, and a
page fitted to its pane's width is then asked for at two sizes. Renderer kept
one scaled copy a picture. Read through, that does not settle: the left pane's
copy lands and is a redraw, in which the right pane finds the copy is not its
size and asks for its own; that lands and is a redraw, in which the left pane
asks again. A scale of the page on the work queue and a redraw of the window,
for as long as the pair is on screen.

A picture both panes name now keeps the last two copies made: when a copy
lands, the one it takes the place of goes into Picture.spare rather than away,
and `fitted` looks there after `scaled`. Two sizes are asked for, so that is
one each, and nothing is asked for again until the window is resized. A picture
only one pane names keeps one copy, as before.

A capture is unchanged. It scales there and then for each pane and writes only
`scaled`, as it did, and none of the macOS pixel scenes has one picture in
both panes. The enlarged path still draws such a pair from the picture.

Not compiled and not run: nothing on the machine this was written on can build
Swift against AppKit. The loop was found by reading, and so was its end. CI's
macos-14 job compiles this and its captures do not reach it.

To check on a Mac: `DiffEngineViewer --diff a.pdf b.pdf` with b a copy of a,
and drag the window's edge a point at a time until the page fills its pane's
width. In Instruments, CGContextDrawImage under Renderer.scale on the pictures
queue: called without end before, twice a resize after. CPU for the process
falls to nothing once the window is left alone.
A delete and a move pending on the same verified file are only tracked
together when the delete arrived second, and an accept-all then carries
out the move and leaves the delete. But the sweep forgot why as it
ended: the delete stayed in the menu looking like any other, with a log
line as the only account of it, and pressing "Accept all" again deleted
the snapshot the first press had just accepted. Accepting the move from
its own item and then pressing "Accept all" lost the file the same way,
with no sweep having held anything.

The reason is now on the delete. A move that writes a file marks the
delete pending on it, whichever way the move was accepted, and no
accept-all, from the menu, a hot key or the wire, carries a marked
delete out. Accepting the delete on its own still does, from its item,
the header over the deletes or its key over the wire. A test run that
raises the delete again clears the mark, since that run looked at the
file the move wrote. A delete waiting on a move that is still pending
is held as before, for as long as the move is there.

The user is told in a balloon when a sweep keeps one, and the menu marks
the delete with "!" and carries the reason on the item and in its tip,
as a snapshot that was not written does. The debug view has a Held
field for it.

Checked by TrackerMoveOntoDeleteTest (ASecondAcceptAllStillHoldsTheDelete,
AMoveAcceptedOnItsOwnHoldsTheDeleteFromALaterAcceptAll,
ASecondWireSweepStillHoldsTheDelete and TheDebugViewSaysWhyADeleteIsHeld
fail without the fix, the first three by the file being deleted) and by
MenuBuilderTest.ADeleteAcceptAllWouldKeepCarriesItsReason, which builds
the items without opening the menu.
A logoff or a shutdown never returns from Application.Run(), so the
TrayVersionFile.Delete() after it never ran and the marker went on
saying a tray of this version was running. Only a tray that owned the
inline queue listened for the session ending at all, and what it did
then was stage its queue.

Every tray now keeps the window that hears WM_ENDSESSION, and what it
runs is Program.SessionEnding: the queue staged where it is held here,
then the marker removed, the second whether or not the first worked.

Checked by SessionEndWindowTest, with the removal handed in so the
marker of a tray running on the machine is not touched:
ATrayThatDoesNotOwnTheQueueRemovesItsMarkerAsTheSessionEnds and
AnOwningTrayStagesItsQueueAndThenRemovesItsMarker. The line in
Program.Inner that wires it is read, not run: nothing starts a tray
in a test, and no session was ended to see it.
…ue would not

"Discard (n)" is a menu click or a hot key, so it runs on the thread
drawing everything. Only the queue's half of it had been moved to a
worker: the tracked moves were still discarded before the call
returned, and discarding a move ends its diff tool and waits up to
half a second for it to go. The whole of it now runs on the worker,
files first, so a queue that is slow to answer holds up nothing else.

A queue that would not discard, or could not be reached, keeps its
snapshots in the menu under the button just pressed to be rid of them,
and only the log said why. It now goes to the balloon as well.

Checked by TrackerClearTest. TheFilesAreNotDiscardedOnTheThreadThatAsked
asks from a thread of its own and fails without the fix, as does
ABulkDiscardTheQueueDidNotCarryOutIsSaid. The first needed somewhere to
see the thread from, so the files half is a virtual method a test
overrides. TrayViewerSyncTest still passes.
A scan that fails is logged and the next one runs, which is right for
one that fails alone. But a tray whose every scan failed looked like
one with nothing wrong, while its icon and menu stopped following the
files on disk.

Three failures running are now said in a balloon, once for the run and
not for each scan in it, which would be a balloon every two seconds for
as long as the cause stood. A scan that works ends the run, so a later
run of failures is said afresh. Nothing modal, and every failure is
still logged.

Checked by TrackerScanTest: AScanThatKeepsFailingIsSaidOnce and
AScanThatFailsAgainAfterWorkingIsSaidAgain fail without the fix, and
ASingleFailedScanIsNotSaid pins the threshold. The tests run scans
themselves beside the tracker's own timer over the same failing queue,
and what they assert holds however many of the timer's land in between.
…e listing

An accept takes its move out of the tracker for as long as the move
takes, seconds when a file is locked, and puts the same object back
when it could not be carried out. The queue owner takes its listing tag
and then builds the listing, so a listing built in that gap went out
without the move under a tag taken while it was there. Once the move
was back the tracker held exactly the objects it had held at the tag,
so every later poll was answered "unchanged" and the displaying viewer
went on missing a pending file until something else changed. A delete
that could not be deleted has the same gap, a shorter one.

Version() tells a change by which objects are tracked, and that stays.
The two places that put the same object back are counted as well, and
a count that has moved is a change.

Checked by TrackerVersionTest: AMoveTakenOutAndPutBackIsAChange lists
from inside the failed accept, sees no move, and then asks for the
version, and ADeleteTakenOutAndPutBackIsAChange does the same for a
delete. Both fail without the fix, with the version unmoved.
The scan remembers a pair it found different, by the two files' sizes
and write times, so it does not read both through again every two
seconds. Nothing took an entry out when its move was accepted,
discarded or settled, so a tray that stayed up kept one for every
received file it had ever compared.

Pruned in the scan, once a pass, rather than wherever a move leaves:
there are a score of such places, and a scan still comparing a move as
it leaves writes its entry after any of them had run.

Checked by TrackerScanTest.WhatAMoveWasFoundToBeIsForgottenOnceItHasLeft,
which fails without the fix.
The tests took Ctrl+Alt+Shift+F24 from the whole desktop for the length
of each, so they failed wherever something else held it and took it
from whatever wanted it meanwhile. What they are about is what happens
to a press once a key is bound, which the desktop has no part in.

KeyRegister has an internal constructor taking the two calls it makes
to register and give back a key, and the public one passes user32's.
The tests pass their own, so nothing is registered, and two more pin
what the real calls were only assumed to do: a key the desktop refuses
is not bound, and every key bound is given back on dispose.

Checked by running KeyRegisterTests. Not shown failing first: that
would mean holding the key from another process on the owner's desktop.
…ceived file

A move's process id is a claim, and it was believed when the process
holding the id ran the executable the move names. That tells which
program a process is and not which window: a stale id Windows had
handed to another copy of the same tool, open on another snapshot or
started by hand for a merge, was tracked as this pair's and ended when
the pair was accepted.

Every tool DiffEngine starts is given the two paths as arguments, the
received file exactly as the move carries it, so the process is now
also asked for its command line and tracked only when that names the
received file as the whole of a path. It is the question
ProcessCleanup.StillRunning asks before a newer library sends an id,
asked again here for a library that does not. Read through
NtQueryInformationProcess(ProcessCommandLineInformation), which needs
only the handle already held and reads nothing out of the other
process's memory.

A process's start time was the other candidate and settles nothing:
the id is checked as the move arrives, when everything there is to find
was started before it, the process that took over a closed tool's id
included.

Checked by TrackerProcessImageTest with processes the test starts:
AnotherCopyOfTheToolIsNotTrackedAndNotEnded,
ACopyOfTheToolShowingALongerPathIsNotTracked and
AReRunNamingAnotherCopyOfTheToolLeavesTheMoveWithNone fail without the
fix, the first with the copy tracked. The tests elsewhere that stand a
process in for a diff tool now start it with the received file on its
command line, as a tool is.
ImageCache remembered that a compose on the pool had failed as a flag on the
picture, so that a pane asking on every step of its spinner did not start it
again for good. What fails is nearly always the memory for one size, the whole
of a large picture at half its own, and the flag also stopped the fitted copy
being made again: after the next resize the pane had the copy from before it
stretched into place for as long as the entry was on screen.

What failed is now kept as the size and the way of building it. That one is
still not started again, and any other is.

AComposeThatFailsLeavesTheOtherSizesToBeMade was run against the flag and
failed there, waiting for a fitted compose that was never started.
The footer's buttons left UseMnemonic on, as the status label once did. A
label is the model's words, and an ampersand in one was not drawn: the letter
after it was underlined and became an Alt chord that pressed the button.

A_footer_button_does_not_read_one_as_a_mnemonic was run without the change and
failed. The button row is named so the test can find it.
…o is

Between half its own size and its own size an enlarged picture is scaled on
every paint, straight from the decoded picture, since a copy of the whole of
it at that size would be up to the decoded picture again. The decoded picture
was kept as the decoder hands it over, so GDI+ multiplied every pixel it read
by its alpha on every one of those paints.

ImageCache.Load now copies the decode into Format32bppPArgb, once.
PicturePaintBenchmarks, a pair of 4000 by 3000 pictures at 400% in the window
a viewer opens at, fifteen iterations, on a machine busy with other builds:

            before      after
  Still     20.2 ms     8.6 ms
  Dragged   15.4 ms     9.3 ms

150% and 200%, which copy from a scaled copy, are 2 ms either way. No more
memory is held.

What it costs is that a translucent pixel is rounded when it is multiplied
rather than after it is filtered. Against the committed baselines the five
scenes with a picture in them differ by one level in 255, in translucent
pixels only (1,006 pixels in Images, 54,608 in ImagesEnlarged), and by nothing
in an opaque one. All of them still pass at the 0.9999 the suite compares
with, so no baseline is replaced.

DecodesPremultiplied failed against the decode as it was.
The mode the module initializer sets was only read back, through an internal
of WinForms, because finding out by throwing is what put WinForms' dialog on
a desktop.

It can be thrown where no dialog is reachable. NativeWindow.Callback catches
what a WndProc throws and either rethrows it, in the mode asked for, or hands
it to the window's OnThreadException. It is Control's override of that which
goes on to the dialog; a bare NativeWindow's does nothing. So the new test
throws from a bare, message only window: in the right mode the exception
comes out of the DoEvents that dispatched the message, and in the wrong one it
would be swallowed and the test would fail having shown nothing. Under that,
the throwing thread has a handler on Application.ThreadException, which is
what WinForms looks for before it makes a dialog. The thread is the test's
own and catches everything.

Not run against a host without the mode: that is the rule, and the test does
not need it to be safe, but it has only been seen to pass.
…post input

TwoKeysInOnePumpAreTwoCommands, ATextEntryHasNoPictureForTheWheelToFind and
TheWheelOverAPictureZoomsAndOverTheRowsScrolls failed once while somebody was
using the machine. Two things about the keyboard reached them.

A key message carries no modifiers and WinForms reads a wheel turn the same
way: both are put beside Control.ModifierKeys, which is the thread's own table
of what is held, and that follows the keyboard whenever
input reaches the thread. With Alt in that table a posted Down is no command,
and with Control a wheel turn over the rows zooms, which are the failures
seen. The hosts now say that no modifier is held before each message is read
(ThreadKeys), for the calling thread only.

And the forms were the foreground window. Shown the ordinary way a form is
activated, and parked off every display it still had the keyboard: what was
typed at the machine while a test ran went to the test, as its commands, and
not to what it was typed into. ViewerForm.Parked shows it without activating
it, the tests' own forms are a ParkedForm, and a capture parks the window it
shows for itself, which had the same effect for as long as it took.

Checked without sending anything to the desktop. With Alt or Control set in
the thread's table and the hosts not clearing it, the first two tests failed
as reported; with the hosts clearing it they pass, which
APostedKeyIsNoChordWhateverTheThreadTakesToBeHeld and
WithControlHeldTheWheelZoomsOverTheRows now hold. A ViewerForm shown as the
tests showed it was GetForegroundWindow; AFormATestPostsToDoesNotTakeTheKeyboard
holds that it is not.
…ts own

The WinForms footer was one row of buttons and a label in whatever they left,
two lines high. A window narrower than its buttons had the rest of them past
its edge, where they could not be reached, and a status beside a document's
ten buttons had the width of a few words: what did not fit in two lines of
that was lost behind an ellipsis.

It is now laid out as the other two heads lay theirs out. The buttons wrap
onto another row where one does not hold them. The status goes beside the
last row when it fits there on one line, and under the buttons when it does
not, on as many lines as it needs up to three. The label is as tall as those
lines at the font it has and they start at its top, so on a scaled display it
cannot show the middle of a status as a label of a fixed two lines, centred,
could.

A taller footer hides no rows: it is docked under the canvas, which reports
what it has left, and the model slices for that. So nothing was needed of the
core. A status under the buttons goes back beside them only with room to
spare, since what it says can turn on how many rows the body has and the body
has a row more with it beside them. The buttons are the footer's own children
now: a panel around them is as wide as the footer once they wrap, and lay
over the status in a capture.

Three baselines. StatusThatDoesNotFit and DocumentPageEnlarged each had a
status that did not fit beside a document's buttons, wrapped in two lines
there and the first cut short; each now has it whole on a line under them,
with the footer a line taller and the pages that much smaller.
FooterThatWraps is new: the same in a window 560 wide, two rows of buttons and
a status of two lines. Every other scene is the pixels it was.

ButtonsThatDoNotFitOneRowWrap, ATallerFooterIsRowsTheBodyIsNotAskedFor,
AStatusWithNoRoomBesideTheButtonsHasLinesOfItsOwn,
TheStatusIsAsTallAsItsLinesAtALargerFont and
AStatusUnderTheButtonsIsSlowToGoBackBesideThem all failed against the footer
as it was. No display was scaled: a test cannot, so the font is made larger
instead.
The launch gate asks MaxInstance for a slot before it starts a viewer, since
that is the only moment the cap can stop one. A launch that then failed - no
viewer resolved, or the one started exited with a failure before anything held
the queue - kept the slot, though no window came of it. Five of those in one
process, which is five failing snapshots against a copy too old for its
arguments, and every pair after them was answered TooManyRunningDiffTools
with nothing running.

The gate now returns the slot on the two paths that report Failed from a
launch. A launch cancelled while its viewer is binding keeps it, since that
viewer is running and will open its window. A test that supplies its own
canLaunch gets a giveBack that does nothing unless it supplies that too.

AFailedLaunchGivesItsSlotBack and its async twin fail without the two calls
(Capped where Failed and Launched were expected) and pass with them.
ALaunchThatOpenedAViewerKeepsItsSlot holds the other side.
ListenerTable.IsHeld is asked in front of every connect that might wait to be
refused, and what it reads is every TCP row the machine has, filtered down to
the listeners. This measures it for a port nobody listens on, with nothing
added and with 1,500 loopback connections of this process's own, which are
3,000 rows.

Committed ahead of the change to how the table is read, so the earlier number
can be had again from history. On the machine it was written on: 1.3 ms and
99 KB with nothing added, 9.3 ms and 694 KB with the 1,500.
Whether anything listens on a port was read out of what .NET lists, which on
Windows is every TCP row the machine has, marshalled into objects and then
filtered to the listening ones. So it grew with the machine's connections,
and PiperClient reads it on every move and delete.

ListenerTable now calls GetExtendedTcpTable for the listener class, once per
address family, into a byte buffer it reads the ports out of. No rows for
connections cross into the process. Wherever that has no answer - off
Windows, the call missing or failing, a table not laid out as expected - it
reads what it read before, and a table that cannot be read at all still
leaves the connect to decide.

ListenerTableBenchmarks, a port nobody listens on, same machine and run:

  connections added    listeners alone    out of every row
  0                    0.13 ms,  8 KB     0.34 ms,  18 KB
  1,500 (3,000 rows)   0.32 ms,  8 KB     13.1 ms, 952 KB

It still grows a little, because Windows passes over the connections to
find the listeners; what went is the cost of handing them over.

ListenerTableTests holds the hand read rows to the truth on Windows, on
net10.0 and net48: an IPv4 and an IPv6 listener are found on their ports, a
port bound and not listening is answered no by both tables, and two hundred
listeners, more than the first buffer holds, are all found.
A port found with nobody on it is not connected to again by the telling
sends for ten minutes, which was set when finding out meant two seconds
waiting to be refused. On Windows it is now a read of the listener table, a
tenth of a millisecond, and the ten minutes were only how long a tray or a
viewer started after the test process heard nothing of its settles and moves.

The memory now records how a port was found empty. What the table said
stands for a second (RecheckUnlistedAfter). What a connect found stands for
the ten minutes it did, since asking again is the connect again: off Windows,
where the table could not be read, and for a port held by something that is
not a viewer.

A second rather than nothing, because settles come as fast as tests pass and
each would otherwise read the table: ten thousand of them are over a second
of reads, and at one read a second it is a ten thousandth of the run. It is
also the second an owner that answered is trusted for.

AnOwnerStartedLaterIsFoundByTheTellingSends runs with the shipped values and
waits for a telling send to reach an owner bound after the first one found
the port empty. With the ten minutes back it failed at its sixty second
limit. APortAConnectFoundEmptyIsNotAskedAboutAgainSoSoon holds the other
half. The rest of ViewerClientUnownedTests are about there being a memory, so
they give the table's answer the connect's ten minutes rather than race a
second.
APortNobodyHoldsIsNotWaitedOn and its async twin sent to a free port and
asserted the send returned inside a second, where the refusal it avoids takes
two. That is a fixed short wait around a real clock, which a two core runner
under load does not promise, and it could only tell the two apart by time.

They now put an owner on the port and make the table say nobody listens
there. A send that connected is answered and heard; one that took the
table's word returns no owner, the owner hears nothing and accepts no
connection. Nothing is timed. APortNobodyHoldsIsNotProbed does the same for
IsOwned, which the launch gate polls. That the real table says no of a real
port with no listener is ListenerTableTests.ABoundPortThatIsNotListeningIsNotHeld.

Run with the table check in NothingListening turned off, all three fail:
the send is accepted and the probe finds the owner.
Any connect that failed was recorded as a port with nobody on it. A machine
with no ports left to connect from fails every connect as well, with the
owner listening throughout, and from then on every settle and move was
skipped for ten minutes: on exactly the machine the kept connection was
added for.

Which error Windows gives when it runs out was never established, and this
does not need it. ViewerClient.NobodyThere names the two failures that do
say nobody is there, a refusal and a connect that was never answered, and
anything else leaves the memory as it was. The send still reports no owner;
it is only not remembered, so the next one asks again. A connect given up on
at this library's own deadline is still recorded, as before.

OnlyARefusalSaysNobodyIsThere holds the rule. For a real connect that fails
without being refused, AConnectThatCouldNotBeMadeSaysNothingAboutThePort
connects to port zero, which Windows fails at once as an address that is not
valid: with the rule taken out it fails, FoundUnowned being true, on net10.0
and net48. No machine was run out of ports to check the case itself.
Read and never run, this turned out to be true. .NET Framework makes its
sockets inheritable and a process started without ShellExecute is handed
every inheritable handle, so a child a test started held the test process's
kept connection to the queue's owner. Run outside the tests, on private
ports, with a .NET Framework client that connected, started a child that
lived eight seconds and went away without closing anything:

  client exited              owner saw the connection close 5.8 s later
  client killed              8.1 s later, when the child exited
  .NET 10 client killed      at once, child still running
  Framework, flag cleared    at once, child still running

What that costs the owner is a connection and a pending read it keeps for a
process that has gone, for as long as the child lives.

The kept connection's socket now has its inherit flag cleared, under
NETFRAMEWORK only, on the socket the client's constructor has already made
and before it connects. Only the kept one: an exchange on a connection of its
own ends by shutting down its sending half, which reaches the owner whoever
else holds the handle.

TheKeptConnectionIsNotOneAChildProcessIsGiven reads the flag off the kept
socket. Without the call it fails on net48, the flag being set, and passes on
net10.0 either way. It asks about the handle rather than starting a child,
because closing the connection in the test shuts it down, which the owner
sees whoever holds it; the case needs the host to go without closing, and a
test cannot do that to its own process.
Every asking send was a connection each. The ones there are many of are the
listings: a window showing an owner's queue lists five times a second for as
long as it is open, and a tray driving a viewer lists on a timer, each leaving
a port in TIME_WAIT.

List and ListFull now go down a kept connection, as the telling sends do,
where the owner's reply says it keeps one. Nothing changes on the wire: an
owner already answers any verb on a kept connection, an older owner never
says it keeps one, and an older client never asks.

It is a second connection, not the settles'. An owner answers one
connection's requests in turn, so a listing it is slow over would otherwise
stand in front of every settle behind it. And a listing does not wait for it:
the callers list on their own clocks with waits of their own, from half a
second to fifteen, so one that finds another in flight is a connection each,
as before. The wait a caller gives is set on the connection for its send.

Only the listings. A request written to a kept connection whose owner has
just gone is sent again as an ordinary exchange, since nothing says whether
the first was acted on. That is harmless for a listing and not for an accept
or a discard, so what changes the queue is still a connection each. So is
the async send, which would have to block a pool thread on a connection it
shares, or be given an async twin of the whole kept exchange.

KeptConnectionTests: ListingsShareAConnectionOfTheirOwn (four connections for
twenty listings and twenty settles; twenty two without the change),
AListingArrivesWhole, ASlowListingHoldsUpNeitherASettleNorAnotherListing
(nothing timed: the owner holds the listing until the others are back),
ACommandIsStillAConnectionEach, AnOwnerThatPredatesItIsListedAConnectionEach
and AListingFindsItsOwnerGoneAndTheNextOne. The viewer's and the tray's
tests, which list through this file, pass as they were, on two cores too.
A screen is built whenever the state is another one, which a scroll or a
drag makes it on every frame, and the queue column described every row of
the queue - label, tooltip, a header's members - to draw the forty that
fit: 0.75 ms and 1.1 MB a screen at 2,000 entries.

QueueProjection now walks the queue into slots that say where each row is
and nothing about it, slices those, and describes the slice. The same walk
answers VisibleEntries, which had its own undescribed rows. A label is
grown by collisions anywhere in the queue, so labels are still decided over
all of it, but once a queue: they are kept, weakly, against the queue's
list, which is replaced and never changed.

FrameBenchmarks, 2,000 entries: 745 us and 1,077 KB before, 33 us and 49 KB
after for a state whose queue is the list it was, and 222 us and 358 KB for
the first screen of a changed queue, which the benchmark now measures too.

QueueSliceTests holds the slice to the rows of the whole list for every
selection and fold of forty random queues, a label to a collision that is
off screen, and the allocation of a screen after a scroll, which failed at
967,760 bytes against the old projection.
ScreenPayload cut a row at the window's width counted in characters, so a
row of two cell characters was encoded four times as far as either pane
could show it, a segment a character: 3.6 ms and 3 MB a changed frame for a
4K window of 300 character CJK lines.

A row is now cut in cells, at Screen.PaneCells: half the window rounded up
and one more. That is a bound rather than a pane's width, which is each
head's to decide, but both native heads split the panes equally, so neither
pane is wider, and the gutter in front of the text is room to spare. What
is left out was never drawn, so the ABI and the captures are as they were.

The cut is found by the walk that segments the row
(CellGrid.Segments(text, cells, out end)) and that much of the row's own
string is encoded, so a row costs no string of its own. A row much longer
than a pane goes through RowText.Shown first, which reads it from the
front: flattening all of a megabyte line was a copy of it a frame.

ScreenPayloadBenchmarks, EncodeAChangedScreen: cjk 3,646 us and 3,069 KB
before, 1,661 us and 808 KB after; box 266 us to 179 us; ascii unchanged at
about 45 us and 15 KB. What is left for CJK is CellGrid classifying each
cluster, about 60 ns each.

ScreenPayloadTests.ARowIsEncodedAsFarAsAPaneCanShowIt failed against the
old cut (200 characters encoded where 51 fit), and CellGridTests holds the
new overload to Segments of the row cut at Index, for every cut of 300
random rows.
TextDiffBenchmarks had the same lines reversed, where nothing is still in
order and there is nothing for a diff to keep however it looks. Runs of
fifty lines, one in four moved somewhere else, is the shape that sorting a
list by another key leaves, and most of its order survives. Committed ahead
of the change to what a search that settles does with it, so the earlier
number can be had again: 48 ms at 10,000 lines and 311 ms at 40,000.
SimonCropp and others added 20 commits October 4, 2026 10:29
…tles

Past its budget a diff split wherever its search had got to, or where the
start of one side was in the other. That finds one block moved whole. The
same lines in another order are edits from end to end, so the search gets
nowhere in them, and they came out as nearly all changed.

A search that settles now asks first for the lines each side has once
(LineAnchors), pairs them, and takes the longest run of the pairs still in
order, by patience sorting: two sorts of the part and one of the pairs. The
sequences are split at every line of the run at once, and each part between
two of them is diffed with whatever budget is left. A run is used when it
is at least sixteen long and longer than what the settled split had going
for it, the lines its search passed or the run a displaced start led to.

Only a search that has already given up on the smallest diff asks, so
everything that was minimal is still minimal and costs what it did. Where
every line is on both sides once, the run is the most that can be
unchanged, so those diffs are minimal again at any length.

A part is looked at for anchors once, then nothing inside one that had no
run, and inside one that was split only parts of half its size or less, so
the looking is a few sorts of the whole however often searches settle.

Timed in one process, old against new, lowest of seven (the machine was
busy, and the two were within noise of each other in time): 40,000 lines
with one run of fifty in four moved kept 16,112 lines before and 28,800
after, 400,000 kept 164,732 and 299,400, in 364 ms and 195 ms; 400,000
lines shuffled kept 127 and 1,240, which is all that are still in order.

TextDiffTests: LinesInAnotherOrderKeepTheLongestRunStillInOrder failed
before at 29,872 of 31,200 and 133 of 328; the anchors are checked against
the quadratic way of finding them, a diff that settles onto them is checked
for correctness on 20,000 small random pairs, and the same elements in
another order are minimal with nothing to spend.
TrackedWatch hands ViewerSession.Refresh the entries whose files changed.
A file written again with the same content comes back as the queued entry
with new stamps, and Refresh put it in through Remove, which closes the
context menu as it does for an entry that changed. A test that keeps
failing the same way writes its received file on every run, so a menu the
reader had open closed once a run.

Refresh now tells a pass that only restamped entries from one that changed
or dropped any: the restamped copy has the very rows of the entry it
replaces. Such a pass replaces the queue with the stamps taken and nothing
else, as the same pair arriving again over the socket already does
(Restaged). A pass with any entry gone or rebuilt goes the way it did.

TrackedWatchTests.AFileWrittenAgainWithWhatItHeldLeavesAnOpenMenuOpen
failed before, for the entry on screen and for another, and
AFileWrittenAgainWithSomethingElseStillClosesAnOpenMenu holds the other
half.
An arrival, a re-run with other content, a settle and a single accept each
rebuild the whole display list from the whole queue under the session's
lock. QueueChangeBenchmarks measures the four, ahead of the change to how
the list is rebuilt: about 2.2 ms and 1.8 MB each at 2,000 entries, 0.13 ms
and 180 KB at 200.
An arrival, a settle, a discard and a single accept each rebuilt the
display list from the whole queue under the session's lock: a dictionary of
the entries, every entry's patches compared with the queue's, and an
ordering of the result.

InlineQueue hands back the items it did not touch as the items it was
given, so what a change did can be read off the queue before and after it.
ViewerSession.TryRebuildChanged does that and edits the list where it
stands: an entry replaced in place when its solution and test are what they
were, taken out, or put after the last entry of its test or the last
snapshot of its solution. Anything else falls back to the whole rebuild,
which is still there as RebuildWhole: a first snapshot in a solution, a
list not in the order a rebuild leaves (snapshots ahead of files), and a
removal that leaves a solution with only files, which a rebuild moves.

QueueChangeBenchmarks at 2,000 entries, before and after: an arrival
2,187 us and 1,793 KB to 241 us and 942 KB, a re-run 2,190 us to 94 us and
325 KB, a settle 2,101 us to 95 us, an accept 2,235 us to 160 us. What is
left is InlineQueue's own: the session still makes a PendingInline an
entry to ask it, and it copies its list and makes a key an item to search.

QueueRebuildTests drives 300 random queues through 60 random changes each
and holds every result to RebuildWhole of the same queue; it failed with
the check after a removal taken out. ARerunInALongQueueIsAboutOneEntry
failed at 1.3 MB with the short way switched off.
Discard-all and a header's discard were one transition that deleted the
received file of every pending pair: under the lock every arrival waits on,
and for a window's own discard on the thread that draws.

They are now the batch an accept-all is (AcceptBatch.Discarding). The
snapshots and the pending deletes go in the transition that begins it,
since neither touches a file, and the moves are left in the queue to be
claimed one at a time, each file thrown away outside the lock and recorded
under it. A window's discard is only begun by its frame and carried out by
the runner's worker; the socket's is driven on the listener thread, as its
accept-all is, waiting out a batch already running rather than doing
nothing behind it. ViewerSession.Apply still carries one out whole, which
is what the tests and file mode drive.

While one runs the status line says "Discarding n of m" and the window
refuses what changes the queue. It is not put on a listing: the wire's
progress is read by whoever displays the queue as an accept under way.
What a discard says when done is word for word what it said.

DiscardBatchTests: AFileIsThrownAwayOutsideTheLock has another thread take
the lock from inside each delete, and failed after its full wait with the
socket's discard put back as one mutation. The rest hold the steps, the
kept files, a group's batch and what a window's frame leaves.
A hole is code, and the scanner counted every brace in one and took every
quote for a string. So $"{'{'}" never closed, $"{'"'}" closed on a string
that ran to the end of the file, and a triple quoted literal ended at the
first three quotes whatever they were in, which a verbatim string in a hole
that ends in a doubled quote has. Each left the calls under it inside a
literal, where no patch finds them.

A hole is now lexed as code: char literals, comments, strings and backticked
names are stepped over whole. A triple quoted literal's holes are found by
the braces its dollars ask for, and a backslash in front of a brace is a
backslash and then a hole.

The other half of the item, a tick after an identifier character, is how
F# reads it too: nothing fsi compiles was read differently.

Checked by FsCompilerRoundTripTests.TicksAndHolesAreReadAsTheCompilerReadsThem,
which patches a call under each of 59 lines fsi compiles and has fsi run the
result. Five of the first set lost the call before the change.
…nder it

A Remove of settings.Snapshot("dup"); takes the statement's lines, which
brought the line under it up onto the line the patch names. Where that was
other.Snapshot("dup");, or a verify call with the same literal chained onto
it, the next apply of the same Remove - a second framework's, or the next
case of a test that ignores its parameters - had the same line and the same
anchor and took the sibling.

The statement now leaves one empty line where a Snapshot call would
otherwise come up onto its first line, as a chained call taken with its line
already did. RemovedAtHint reads an empty line over a line holding a Snapshot
call as the call removed, since a statement on a variable has no verify call
above it to be recognised by.

Only where the Snapshot call is on the statement's first line. No existing
expectation moved: a statement with anything else under it still goes whole.

Checked by RemoveOfAStatementAppliedTwiceLeavesTheSiblingUnderIt (both
siblings), RemoveOfAStatementOverSeveralLinesAppliedTwiceLeavesTheSiblingUnderIt
and RemoveOfAStatementWithNoSnapshotCallUnderItKeepsNoLine in
InlinePatcherTests, the first two failing before the change, and by a
removedStatement shape in FsCompilerRoundTripTests that fsi compiles and runs.
An Append carries no anchor. With its hint gone stale it took the first call
in the member with no Snapshot call chained onto it, and where an earlier
call there is verified through files, or through settings.Snapshot, that
call was given the snapshot of the one after it.

Two things now. A call passed a name that Snapshot is called on in the same
member (settings.Snapshot("A"); then Verify(a, settings)) has its snapshot
and is passed over. And where more than one call is still left, nothing in
the source says which the patch was for, so it is refused with a reason that
says to re-run, which brings the line the call is on now. A hint that lands
on a call still names it, a member with one such call still takes it, and a
patch with no member still goes to the nearest call, all as before.

No existing expectation moved. Checked by
AppendWithAStaleHintPassesOverACallVerifiedThroughItsSettings,
AppendWithAStaleHintIsRefusedWhenTwoCallsCouldTakeIt,
AppendTakesTheCallOnTheRecordedLineAmongSeveral and
AppendWithAStaleHintTakesTheOnlyCallWithNoSnapshot in InlinePatcherTests,
the first two failing before the change.
A batch applies each patch to what the one before it left, so the file was
lexed whole for every patch, and its line starts, line ending and indent
step were worked out whole again too, the last with a string a line. Five
hundred patches to a 600 KB file were 0.77 s of patching around one write,
and a gigabyte allocated.

The scan is now carried from each patch that edits to the next.
SourceScan.Edited keeps the spans before the edit, lexes again from the start
of the line the edit begins on, and stops at the first line start past the
edit that both scans have as code, taking the rest of the old scan's spans
moved by the change in length. A line that follows a backslash is not started
from, since a backslash is the one thing either lexer reads across a line
break from in front of it. The line starts are carried the same way, and the
line ending and the indent step are counted from them without allocating.

What each patch is told is unchanged, because the scan it is given answers as
a scan of the whole text does. That is what the tests hold it to:

- SourceScanTests.AScanMadeFromAnotherIsTheScanOfTheWholeText, both
  languages: 400 runs of 25 random edits made of everything that opens,
  closes or escapes a comment or a literal, every answer at every offset
  compared with a scan of the whole text after each. Taking the backslash
  rule out fails it.
- AScanMadeFromAnotherOfTheSuitesOwnSourceIsTheScanOfTheWholeText, the same
  over two files of thousands of lines.
- InlineApplierBatchTests.ACarriedScanTellsEachPatchWhatLexingAgainWould,
  both languages: 60 shuffled batches of Set, Append and Remove patches,
  outcomes, reasons and final source compared with lexing again for each.
- The existing batch tests, which compare ApplyAll with Apply in turn.

InlineAcceptBenchmarks, 500 call sites, before and after, on a machine busy
with other builds:

  PatchInMemory    766.5 ms, 1,048 MB  ->  285.8 ms, 711 MB
  AcceptTogether   727.6 ms            ->  354.3 ms

PatchInMemory now goes through InlineApplier.PatchInTurn, which is the loop
the batch runs. What is left is a copy of the source for each patch that
edits, 1.2 MB each in that file.
A test run asks CanAnchor once for each call site it has not seen before,
and each asking took the file's lock and mutex and read, decoded and lexed
the whole file: 0.7 s for the five hundred call sites of a 600 KB file, to be
told five hundred things about the same text.

A dry run writes nothing, so what one read stands for as long as the file
does. The applier now keeps the scan a dry run made, for up to four files,
under the file's length and write time, and CanAnchor and CanApply are
answered from it while those are unchanged, with no lock taken. A write time
cannot tell a second write inside the same tick of the file system's clock,
so a file is kept only when it had been left alone for three seconds when it
was read. One being edited, or one an accept has just written, is read every
time, as before.

Checked by ASecondProbeOfAFileDoesNotReadItAgain, which holds the file open
with nothing shared and fails without the change,
AProbeReadsAFileThatHasChangedSinceTheLast and
AProbeOfAFileJustWrittenIsNotKept in InlineApplierTests.

InlineAcceptBenchmarks.AnchorEach, on a machine busy with other builds:

   25 call sites    5.3 ms,     3.5 MB  ->   1.3 ms, 0.08 MB
  500 call sites  833.5 ms, 1,303 MB    ->  50.8 ms, 1.6 MB

The benchmark's file is now dated a minute back, as a source file is by the
time a run asks about it.
…ch's write fails

A batch's one write that failed failed every patch from the first edit on,
whatever each had been judged to be, because what they were judged against
was never written. That is right for a patch that edited, and for one that
was already applied only because an earlier patch in the batch had written
its literal. It is not right for one whose snapshot was in the source all
along, which stayed queued as a failure, or for one whose call site was
never there, which was not told to re-run.

A patch from the first edit on that made no edit is now asked again of the
file as it was read, which is the file as it still is. Already applied or
not found there is what it is told. Where it would have had to edit that
file, it reports the write as the others do. A patch judged before the first
edit keeps its answer, as it did.

The file's mutex is still held from the read to the write: that is what one
read and one write are, and the patching between them is shorter than it was.

Checked by AWriteThatFailsLeavesAPatchThatDidNotNeedItItsOwnAnswer in
InlineApplierBatchTests, which was five failures before the change, and by
AWriteThatFailsFailsEveryPatchItCarried, unchanged.
…tion

A key held past the repeat delay is reported again at the keyboard's repeat
rate, and the Linux head acted on every one of them for a letter, as it does
for an arrow. A held a accepted the entry on screen and then every one that
took its place, into source, none of them read. The WinForms head gives what
changes the queue a press each for that reason.

Which commands go on while their key is held is now asked in one place,
Repeats: scrolling and paging, next and previous change, the next variant,
the pages of a document and zooming in and out. Everything else acts once:
accept, accept all, discard, quit, the two toggles, the next projection and
zoom reset, with Home, End, Tab and Escape, which already did. A chord with
Control is still never repeated.

Checked in the ubuntu:24.04 container under Xvfb, with xdotool holding a key
for a second and a half over a queue of three pairs, against a build of the
shim that says which keys it hands over. Before: Down, n, m and ] 23 each,
and a three times, which was the whole queue accepted. After: Down, n and ]
23 each, m once, and a once with two pairs still pending. No test in the
repository can press a key in the shim: input is behind the C ABI.
…'s does

deview_capture makes a context of its own for its one frame, and that
context did not say the renderer applies a draw command's vertex offset,
which the window's context says and RenderTriangles does. Without it ImGui
lets one draw list grow past what a sixteen bit index can address, the
indices wrap, and the capture comes out scrambled.

Checked in the ubuntu:24.04 container with a capture made for it: two panes
of 220 character lines at 3840 by 2160, 53,124 characters in the rows on
screen. Before, the rows stop at line 46 and the ones above have their
colours on the wrong lines; after, all 114 rows are there. No scene in
PixelTests is that large, and all nineteen Linux baselines are the same
files byte for byte with the flag declared.
…o it

Nothing in DiffEngineViewer.Tests failed if deview_present went back to
drawing every frame: a capture draws one frame into a texture and cannot
tell, and NativeIdleBenchmarks, which shows it in its Drawn column, is run
by hand.

PixelTests.AWindowLeftAloneIsNotDrawn shows the shared window, presents one
screen a second at a time, and asks OpenGL after each present whether
anything was drawn, by the primitives generated, as NativeHead in the
benchmarks does. It passes once a second goes by with nothing drawn, and
fails after thirty seconds without one. It also asserts that the first
second after the window is shown did draw, or the count would be of nothing.

It waits for a quiet second rather than asserting which second is quiet, so
a slow runner or the window system asking for the window again does not
fail it. In the ubuntu:24.04 container, set up as the unix job is, it passed
25 times of 25 pinned to two processors with DOTNET_PROCESSOR_COUNT=2, in
about two and a half seconds each. Against a build of the shim altered to
build and draw every frame it fails, after its thirty seconds.
deview_present left an unchanged window alone, but a hidden one whose screen
changed was still built and drawn, and a viewer hidden behind a tray is
handed another screen by every arrival in its queue: the whole queue laid
out and the whole window filled, for a window nobody can see.

A hidden window now ends its turn after taking in what arrived: decoded
pictures and found fonts are still taken, the screen is still kept as the
last one handed over, and the fonts for the characters it holds are still
asked for, so they are there by the time it is shown. Showing it marks it
stale and is itself an arrival, so the first present after it builds and
draws the screen it is handed. A window that has never had a frame built is
built once wherever it is, since the grid the managed side slices by is
measured from a frame and ImGui has no font to measure with before its
first.

PixelTests.AHiddenWindowIsNotDrawn presents two screens in turn to the
hidden window sixty times and asks OpenGL how many presents drew: sixty
before, none now, and one for the first present after it is shown. Checked
further in the ubuntu:24.04 container by photographing the window off the
X server: handed a queue and then pictures while hidden, it shows each when
shown or focused, the same pixels as when they were presented to it on
screen, and its other sixteen photographs are those of the shim before.
OutsideTheFont still has the machine's fonts merged while it presents to
the hidden window, two of them in the container, which is what it is
about. All nineteen Linux baselines are the same files byte for byte.
The Linux head reads a key by the character it types, and falls back to
where the key is for the letters on a layout that types none of them.
The five keys that are not letters had no such fallback. Russian and Arabic
type letters of their own from the keys a US keyboard has [ and ] on, so
the pages of a document had no key there, and Persian types its own digits,
so zoom reset had none.

Those five are now read as what a US keyboard has in their place when the
key typed something outside ASCII and the layout has no key that types any
of a to z unshifted. Only then: a German keyboard types u with a diaeresis
where [ is and a French one a with a grave where 0 is, and both have the
character itself elsewhere, so neither is given a command for a letter of
its own language.

Checked in the ubuntu:24.04 container under Xvfb with setxkbmap and xdotool,
against a build of the shim that says which keys it hands over. Russian:
the keys for ha and the hard sign gave nothing and now give previous and
next page, and - = 0 still zoom as they did. Persian: its zero gave nothing
and now resets the zoom, and jeem and tcheh turn the page. German u with a
diaeresis and French a with a grave still give nothing, and German + still
zooms in. No test in the repository can press a key in the shim.
… copies, and make those only for a picture drawn small

A frame of two pictures cost about a tenth more under llvmpipe since
pictures were given reduced copies, and the guess was the trilinear sampling
of a picture drawn under half its size. It was not: the 4K benchmark fits
its pictures at 0.99 of their size, where nothing is sampled trilinearly.
The cause is raylib's SetTextureFilter, which for any texture that has
reduced copies makes "bilinear" GL_LINEAR_MIPMAP_NEAREST: which copy to read
is worked out for every pixel drawn, and at that size the answer is always
the picture itself. A build that makes no reduced copies at all showed it,
27.1 ms against 30.4.

The filters are now set directly. From three quarters of its size up a
picture is sampled with GL_LINEAR, which never asks. Under half it is
trilinear, as before. Between the two it is still GL_LINEAR_MIPMAP_NEAREST,
kept as its own case, because under about seven tenths that draws from the
half size copy, in which a grid of one pixel lines is even: photographed
both ways at two thirds of its size, GL_LINEAR alone leaves some of the
lines dark and some faint. Three quarters rather
than seven tenths so the change of filter is clear of the size where GL's
own answer changes, and every picture is the pixels it was.

A texture is now made with reduced copies only for a picture drawn at under
three quarters of its size, which is known when it is asked for. One that
comes to be drawn that small later, in a window made narrower, is decoded
again with them on the decoder's thread while the texture it has goes on
being drawn, and swapped when it lands, so no frame waits for them. A
capture makes them there and then.

In the ubuntu:24.04 container, llvmpipe on four threads, NativeFrameBenchmarks
with 30 iterations, before and after run in turn twice: OpaquePictures at
3840x2160 29.7 and 30.4 ms before, 27.1 and 27.4 after (CPU 101 ms to 91);
TranslucentPictures 37.0 to 33.6 and 34.2; both unchanged at 1100x700,
where the pictures are under a third of their size. Two opaque pictures
2400 square in a 4K window add 49 MB to the process where they added 94;
two 3600 square, drawn under three quarters, add 154 as before.

Pixels: all nineteen Linux baselines are the same files byte for byte, as
are the 179 captures of the earlier round's picture scenes, at every zoom
step. A window photographed off the X server at 1900, 1500 and 1100 pixels
wide, made narrower in two steps and opened afresh at each, shows the same
pixels as the shim before at all five, which a build with no reduced copies
does not.
… fits

A picture longer on a side than GL_MAX_TEXTURE_SIZE was drawn as nothing in
the Linux head, under rows that said what it was. The limit is 16384 under
llvmpipe, and a screenshot of the whole of a long page is past it.

Such a picture is now resampled as it is read to the largest size of its own
shape a texture takes (FitToATexture), each pixel of what is left an average
of the ones it stands for, and drawn from that, placed by the size the model
carries as any picture is. It happens in ReadPicture, so on the decoder's
thread for the window and there and then for a capture, and nowhere else.

What it costs, measured in the ubuntu:24.04 container on one thread: the
copy is made beside the picture as read, so both are held until it is done.
A picture 20,000 square with no alpha channel is 1.2 GB read and 0.8 GB
more for the copy, 2.1 s to read and 1.2 s to bring down. One 17,000 by
9,000 with an alpha channel took 0.4 s and 0.9 s, and a screenshot 1,920 by
30,000 took 0.3 s and 0.2 s. Reading is as it was: a picture 16,000 square,
which fits, already took the process to 3.7 GB.

PixelTests.ImageTooLargeForATexture keeps its picture, 16385 by 512, and its
baseline is now that picture drawn, a grey bar across the left pane, where
it was an empty pane: looked at before it was approved, and the test fails
against the shim before this. The other eighteen Linux baselines are the
same files byte for byte. The window was photographed off the X server
showing a picture 17,000 by 300 beside one that fits: drawn, where it was
not, and its other sixteen still scenes are the pixels they were.
todo.md loses the items fixed and gains what each fix left, and for each item that was looked at and left, what stood in the way. claude.md describes the changed behaviour: the scan a batch carries, the Append that is refused, the batch a bulk discard now is, the list edited where it stands, the diff's anchors, the listener table and the second kept connection, the tray's held deletes and command line check, the Windows footer, and the Linux head's hidden window and sampling. docs/tray.md says what a held delete looks like.
Co-authored-by: SimonCropp <122666+SimonCropp@users.noreply.github.com>
@SimonCropp
SimonCropp merged commit a1e7906 into main Oct 4, 2026
13 checks passed
@SimonCropp
SimonCropp deleted the todo-leftovers branch October 4, 2026 02:19
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