Open the tray menu without a socket round trip - #860
Merged
Conversation
Right clicking the tray icon took half a second whenever a viewer owned the inline queue and had since exited. The menu is built on the UI thread inside ContextMenuStrip.Opening, and BuildTrackingMenuItems read Tracker.Snapshots, which read the queue live - a loopback exchange bounded by ViewerClient.ShortTimeout. Measured from the shell's own notify icon message to the menu being open: 3-26ms with the queue held here, 513ms without, and on every click rather than the first. Snapshots is the scan cache now. Nothing is lost where the queue is held here, since OwnedInlineHost.Changed already runs Refresh on every mutation and the tray's own accepts and discards refresh too, so the cache is the live queue. Where a viewer holds it the listing is at most one scan old, which is what TrackingAny and the icon have always shown - and what MenuBuilder already said it was showing. The Tracker seeds the cache in its constructor rather than leaving it empty until the first scan two seconds later, so a tray that has just started does not show an empty menu over a queue that is not. AcceptAllSnapshots keeps its live read, because that guard exists precisely so a stale empty cache cannot make it silently do nothing, but takes it inside the worker rather than in front of it: the caller is a click or a hot key and the read is a round trip. The half second itself is the other half of this. A connection to a port nothing is listening on is supposed to be refused at once, and every caller in RemoteInlineHost was written expecting it - "a refused connection means the viewer has gone". It is not refused at once everywhere: where the SYN is dropped rather than answered with a reset, the connect runs to its timeout instead. On one machine a closed loopback port costs 503ms against a 500ms cap, and 2034ms uncapped, the same for a dual mode socket and an IPv4 one. Ownership is decided at startup and this host is never replaced, so that was the price of every scan and every menu verb for the rest of the tray's life. Exchange asks the OS whether anything holds the port before connecting. The listener table is a local query costing well under a millisecond, and it turns the gone owner case from 513ms into 1.1ms. Cheaper than a backoff and with no staleness window: a viewer that starts is found on the next call, and racing the check costs no more than the connect always did. TrayViewerSyncTest's two pair helpers gained a Listing that refreshes before reading, standing in for the scan timer. That is also what ViewerAcceptAllEmptiesTheTrayListing needed to be a test at all: it asserts an empty listing, and a cache satisfies that whether or not anything worked.
This was referenced Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Right clicking the tray icon took half a second whenever a viewer owned the inline queue and had since exited.
The menu is built on the UI thread inside
ContextMenuStrip.Opening, andBuildTrackingMenuItemsreadTracker.Snapshots, which read the queue live — a loopback exchange bounded byViewerClient.ShortTimeout. Measured from the shell's own notify icon message to the menu being open:~511 ms of each slow row is the one
inline.List()call, and it is every click rather than the first.The menu reads the cache
Snapshotsis the scan cache now. Nothing is lost where the queue is held here, sinceOwnedInlineHost.Changedalready runsRefreshon every mutation and the tray's own accepts and discards refresh too, so the cache is the live queue. Where a viewer holds it the listing is at most one scan old — which is whatTrackingAnyand the icon have always shown, and whatMenuBuilderalready said it was showing.The
Trackerseeds the cache in its constructor rather than leaving it empty until the first scan two seconds later, so a tray that has just started does not show an empty menu over a queue that is not.AcceptAllSnapshotskeeps its live read, because that guard exists precisely so a stale empty cache cannot make it silently do nothing, but takes it inside the worker rather than in front of it: the caller is a click or a hot key and the read is a round trip.Connection refused is not always fast
A connection to a port nothing is listening on is supposed to be refused at once, and every caller in
RemoteInlineHostwas written expecting it — "a refused connection means the viewer has gone". It is not refused at once everywhere: where the SYN is dropped rather than answered with a reset, the connect runs to its timeout instead. Measured on one machine:ConnectionRefusedafter 2034 msOwnership is decided at startup and this host is never replaced, so that was the price of every scan and every menu verb for the rest of the tray's life.
Exchangeasks the OS whether anything holds the port before connecting. The listener table is a local query costing well under a millisecond. Cheaper than a backoff and with no staleness window: a viewer that starts is found on the next call, and racing the check costs no more than the connect always did.Verified
Same instrumented right-click, unresponsive owner holding 3493 — 531/515/513/513 ms becomes 20.5/1.3/1.5/2.0 ms.
And the scan path, within a single run where the owner held the port and then exited:
Full solution: 1905 tests, 0 failed, 20 skipped.
Tests
TrayViewerSyncTest's two pair helpers gained aListingthat refreshes before reading, standing in for the scan timer. That is also whatViewerAcceptAllEmptiesTheTrayListingneeded to be a test at all: it asserts an empty listing, and a cache satisfies that whether or not anything worked.