Skip to content

Commit 8d41eb2

Browse files
committed
Open the tray menu without a socket round trip
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.
1 parent 8292b68 commit 8d41eb2

3 files changed

Lines changed: 145 additions & 49 deletions

File tree

src/DiffEngineTray.Tests/TrayViewerSyncTest.cs

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public async Task TrayAcceptOfOneSnapshotLeavesTheRestInTheAttachedViewer()
7979
await pair.Tracker.Accept(snapshot);
8080

8181
await Assert.That(pair.Pump().Keys()).IsEquivalentTo([Key(other, 7)]);
82-
await Assert.That(pair.Tracker.Snapshots.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
82+
await Assert.That(pair.Listing.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
8383
}
8484

8585
[Test]
@@ -160,7 +160,7 @@ public async Task ASettleReachesTheAttachedViewer()
160160
pair.Send(new(ViewerVerb.Settle, Key(sample, 1)));
161161

162162
await Assert.That(pair.Pump().Keys()).IsEquivalentTo([Key(other, 7)]);
163-
await Assert.That(pair.Tracker.Snapshots.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
163+
await Assert.That(pair.Listing.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
164164
}
165165

166166
/// <summary>
@@ -181,7 +181,7 @@ public async Task ViewerAcceptAllEmptiesTheTray()
181181

182182
var viewer = pair.Pump();
183183
await Assert.That(viewer.Queue).IsEmpty();
184-
await Assert.That(pair.Tracker.Snapshots).IsEmpty();
184+
await Assert.That(pair.Listing).IsEmpty();
185185
await Assert.That(pair.Tracker.Moves).IsEmpty();
186186
await Assert.That(pair.Tracker.Deletes).IsEmpty();
187187
await Assert.That(await File.ReadAllTextAsync(move.Target)).IsEqualTo("received");
@@ -199,7 +199,7 @@ public async Task ViewerAcceptOfOneSnapshotReachesTheTray()
199199
pair.Link.Post(ViewerSideVerb.Accept, Key(sample, 1));
200200

201201
await Assert.That(pair.Pump().Keys()).IsEquivalentTo([Key(other, 7)]);
202-
await Assert.That(pair.Tracker.Snapshots.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
202+
await Assert.That(pair.Listing.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
203203
await Assert.That(pair.Applied.Select(_ => _.LineHint)).IsEquivalentTo([1]);
204204
}
205205

@@ -214,7 +214,7 @@ public async Task ViewerDiscardOfOneSnapshotReachesTheTray()
214214
pair.Link.Post(ViewerSideVerb.Discard, Key(sample, 1));
215215

216216
await Assert.That(pair.Pump().Keys()).IsEquivalentTo([Key(other, 7)]);
217-
await Assert.That(pair.Tracker.Snapshots.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
217+
await Assert.That(pair.Listing.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
218218
await Assert.That(pair.Applied).IsEmpty();
219219
}
220220

@@ -270,7 +270,7 @@ public async Task AcceptingASnapshotThatIsAlreadyGoneSaysNothing()
270270

271271
await Assert.That(pair.Failures).IsEmpty();
272272
await Assert.That(pair.Applied).IsEmpty();
273-
await Assert.That(pair.Tracker.Snapshots.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
273+
await Assert.That(pair.Listing.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
274274
}
275275

276276
/// <summary>
@@ -289,7 +289,7 @@ public async Task AFailedAcceptStaysPendingOnBothSides()
289289
var viewer = pair.Pump();
290290
await Assert.That(viewer.Queue.Single().Status).IsEqualTo("the file is locked");
291291
await Assert.That(viewer.Exit).IsFalse();
292-
await Assert.That(pair.Tracker.Snapshots.Single().Status).IsEqualTo("the file is locked");
292+
await Assert.That(pair.Listing.Single().Status).IsEqualTo("the file is locked");
293293
await Assert.That(pair.Failures.Single()).Contains("the file is locked");
294294
}
295295

@@ -314,7 +314,7 @@ public async Task TrayAcceptAllReportsWhatStayedPending()
314314
var viewer = pair.Pump();
315315
await Assert.That(viewer.Keys()).IsEquivalentTo([Key(sample, 1)]);
316316
await Assert.That(viewer.Queue.Single().Status).IsEqualTo("the file is locked");
317-
await Assert.That(pair.Tracker.Snapshots.Single().Status).IsEqualTo("the file is locked");
317+
await Assert.That(pair.Listing.Single().Status).IsEqualTo("the file is locked");
318318
await Assert.That(pair.Failures.Single()).Contains("the file is locked");
319319
}
320320

@@ -357,7 +357,7 @@ public async Task TrayAcceptAllEmptiesTheOwningViewer()
357357
await pair.Tracker.AcceptAll();
358358

359359
await Assert.That(pair.Viewer.Queue).IsEmpty();
360-
await Assert.That(pair.Tracker.Snapshots).IsEmpty();
360+
await Assert.That(pair.Listing).IsEmpty();
361361
await Assert.That(pair.Applied.Count).IsEqualTo(2);
362362
}
363363

@@ -389,7 +389,7 @@ public async Task TrayAcceptOfOneSnapshotLeavesTheRestInTheOwningViewer()
389389
await pair.Tracker.Accept(snapshot);
390390

391391
await Assert.That(pair.Viewer.Keys()).IsEquivalentTo([Key(other, 7)]);
392-
await Assert.That(pair.Tracker.Snapshots.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
392+
await Assert.That(pair.Listing.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
393393
}
394394

395395
[Test]
@@ -415,7 +415,7 @@ public async Task TrayDiscardAllEmptiesTheOwningViewer()
415415
pair.Tracker.Clear();
416416

417417
await Assert.That(pair.Viewer.Queue).IsEmpty();
418-
await Assert.That(pair.Tracker.Snapshots).IsEmpty();
418+
await Assert.That(pair.Listing).IsEmpty();
419419
await Assert.That(pair.Applied).IsEmpty();
420420
}
421421

@@ -429,7 +429,7 @@ public async Task ViewerAcceptReachesTheTrayListing()
429429
pair.Act(CommandKind.Accept, Key(sample, 1));
430430

431431
await Assert.That(pair.Viewer.Keys()).IsEquivalentTo([Key(other, 7)]);
432-
await Assert.That(pair.Tracker.Snapshots.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
432+
await Assert.That(pair.Listing.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
433433
}
434434

435435
[Test]
@@ -442,7 +442,7 @@ public async Task ViewerAcceptAllEmptiesTheTrayListing()
442442
pair.Act(CommandKind.AcceptAll, null);
443443

444444
await Assert.That(pair.Viewer.Queue).IsEmpty();
445-
await Assert.That(pair.Tracker.Snapshots).IsEmpty();
445+
await Assert.That(pair.Listing).IsEmpty();
446446
await Assert.That(pair.Tracker.TrackingAny).IsFalse();
447447
}
448448

@@ -456,7 +456,7 @@ public async Task ViewerDiscardReachesTheTrayListing()
456456
pair.Act(CommandKind.Discard, Key(sample, 1));
457457

458458
await Assert.That(pair.Viewer.Keys()).IsEquivalentTo([Key(other, 7)]);
459-
await Assert.That(pair.Tracker.Snapshots.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
459+
await Assert.That(pair.Listing.Select(_ => _.Key)).IsEquivalentTo([Key(other, 7)]);
460460
await Assert.That(pair.Applied).IsEmpty();
461461
}
462462

@@ -473,7 +473,7 @@ public async Task AFailedAcceptStaysPendingOnBothSidesOfAnOwningViewer()
473473
await pair.Tracker.Accept(snapshot);
474474

475475
await Assert.That(pair.Viewer.Queue.Single().Status).IsEqualTo("the file is locked");
476-
await Assert.That(pair.Tracker.Snapshots.Single().Status).IsEqualTo("the file is locked");
476+
await Assert.That(pair.Listing.Single().Status).IsEqualTo("the file is locked");
477477
await Assert.That(pair.Failures.Single()).Contains("the file is locked");
478478
}
479479

@@ -492,7 +492,7 @@ public async Task TrayAcceptAllReportsWhatTheOwningViewerKept()
492492

493493
await Assert.That(pair.Viewer.Keys()).IsEquivalentTo([Key(sample, 1)]);
494494
await Assert.That(pair.Viewer.Queue.Single().Status).IsEqualTo("the file is locked");
495-
await Assert.That(pair.Tracker.Snapshots.Single().Status).IsEqualTo("the file is locked");
495+
await Assert.That(pair.Listing.Single().Status).IsEqualTo("the file is locked");
496496
await Assert.That(pair.Failures.Single()).Contains("the file is locked");
497497
}
498498

@@ -518,7 +518,7 @@ public async Task ASlowAcceptIsWaitedForRatherThanCalledAMissingViewer()
518518

519519
await Assert.That(pair.Failures).IsEmpty();
520520
await Assert.That(pair.Viewer.Queue).IsEmpty();
521-
await Assert.That(pair.Tracker.Snapshots).IsEmpty();
521+
await Assert.That(pair.Listing).IsEmpty();
522522
}
523523

524524
/// <inheritdoc cref="ASlowAcceptIsWaitedForRatherThanCalledAMissingViewer"/>
@@ -821,6 +821,24 @@ public TrayOwned(Func<InlinePatch, InlineApplyResult>? applier = null)
821821
public List<string> Warnings { get; } = [];
822822
public List<string> Failures { get; } = [];
823823

824+
/// <summary>
825+
/// What the tray menu is built from. <see cref="Tracker.Snapshots"/> is the last listing
826+
/// seen rather than a live read, so this refreshes first - standing in for the two second
827+
/// scan, and for <see cref="OwnedInlineHost.Changed"/> where that is what keeps it current.
828+
/// <para>
829+
/// Reading the property alone would assert against whatever the cache happened to hold,
830+
/// which for an empty expectation is a test that cannot fail.
831+
/// </para>
832+
/// </summary>
833+
public IReadOnlyList<PendingSnapshot> Listing
834+
{
835+
get
836+
{
837+
Tracker.Refresh();
838+
return Tracker.Snapshots;
839+
}
840+
}
841+
824842
readonly string root = TempRoot();
825843

826844
/// <summary>
@@ -858,7 +876,7 @@ public PendingSnapshot Queue(string source, int line, string content = "new", st
858876
throw new($"The owner refused the patch. {response.Message}");
859877
}
860878

861-
return Tracker.Snapshots.Single(_ => _.Key == Key(source, line));
879+
return Listing.Single(_ => _.Key == Key(source, line));
862880
}
863881

864882
public TrackedMoveFiles AddMove()
@@ -982,6 +1000,20 @@ public TrackedMoveFiles StageMove()
9821000

9831001
public SessionState Viewer => Window.State;
9841002

1003+
/// <summary>
1004+
/// What the tray menu is built from, refreshed first - see
1005+
/// <see cref="TrayOwned.Listing"/>. It matters more on this side: with the queue in the
1006+
/// other process, the cache only moves when something refreshes it.
1007+
/// </summary>
1008+
public IReadOnlyList<PendingSnapshot> Listing
1009+
{
1010+
get
1011+
{
1012+
Tracker.Refresh();
1013+
return Tracker.Snapshots;
1014+
}
1015+
}
1016+
9851017
public void Queue(string source, int line, string content = "new", string? framework = null)
9861018
{
9871019
var message = new ViewerMessage(ViewerVerb.Inline, Body: Payload(source, line, content, framework));
@@ -995,7 +1027,7 @@ public void Queue(string source, int line, string content = "new", string? frame
9951027
public PendingSnapshot Snapshot(string source, int line, string content = "new", string? framework = null)
9961028
{
9971029
Queue(source, line, content, framework);
998-
return Tracker.Snapshots.Single(_ => _.Key == Key(source, line));
1030+
return Listing.Single(_ => _.Key == Key(source, line));
9991031
}
10001032

10011033
public ViewerResponse Send(ViewerMessage message)

src/DiffEngineTray/RemoteInlineHost.cs

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
1+
using System.Net.NetworkInformation;
2+
13
/// <summary>
24
/// The queue belongs to a viewer that bound the port before this tray started, so every call is a
35
/// short loopback round trip and the tray is a remote control.
46
/// <para>
5-
/// The listing and the menu verbs use ViewerClient.ShortTimeout. Those run from the 2 second scan
6-
/// timer and from the menu opening, so a slow exchange must not outlast the timer period or block
7-
/// the UI.
7+
/// The listing and the menu verbs use ViewerClient.ShortTimeout. The listing runs from the 2
8+
/// second scan timer, so a slow exchange must not outlast the timer period. It is no longer what
9+
/// the menu is built from - see <see cref="Tracker.Snapshots"/> - so nothing here is waited on
10+
/// from the UI thread except a verb the user clicked.
811
/// </para>
912
/// <para>
1013
/// Accepting does not, for the reason <see cref="acceptWait"/> gives.
1114
/// </para>
1215
/// <para>
13-
/// A refused connection means the viewer has gone, which is the same as nothing pending. The queue
14-
/// went with it, and this tray does not take ownership: it was decided at startup.
16+
/// No owner means the viewer has gone, which is the same as nothing pending. The queue went with
17+
/// it, and this tray does not take ownership: it was decided at startup.
18+
/// </para>
19+
/// <para>
20+
/// "No owner" is asked of the OS rather than found out by connecting - see
21+
/// <see cref="PortIsHeld"/>.
1522
/// </para>
1623
/// </summary>
1724
class RemoteInlineHost : IInlineHost
@@ -156,6 +163,50 @@ static bool Send(ViewerVerb verb, string? key, TimeSpan wait, out string? messag
156163
return response.Ok;
157164
}
158165

159-
static bool Exchange(ViewerMessage message, TimeSpan wait, [NotNullWhen(true)] out ViewerResponse? response) =>
160-
ViewerClient.TrySend(message, out response, wait: wait);
166+
static bool Exchange(ViewerMessage message, TimeSpan wait, [NotNullWhen(true)] out ViewerResponse? response)
167+
{
168+
if (!PortIsHeld())
169+
{
170+
response = null;
171+
return false;
172+
}
173+
174+
return ViewerClient.TrySend(message, out response, wait: wait);
175+
}
176+
177+
/// <summary>
178+
/// Whether anything holds the port, asked of the OS rather than found out by connecting to it.
179+
/// <para>
180+
/// Connecting to a port nothing is listening on is supposed to be refused at once, and every
181+
/// caller here was written expecting that. It is not refused at once on every machine: where
182+
/// the SYN is dropped rather than answered with a reset, the connect runs to its timeout
183+
/// instead. Once the owning viewer exits, that is the full
184+
/// <see cref="ViewerClient.ShortTimeout"/> per call - half a second on the two second scan,
185+
/// and half a second on every menu verb - for the rest of this tray's life, because ownership
186+
/// is decided at startup and this host is never replaced.
187+
/// </para>
188+
/// <para>
189+
/// The listener table is a local kernel query costing well under a millisecond, and it answers
190+
/// the only question worth asking first. Matched on the port alone: a listener on any address
191+
/// accepts a loopback connection, so the round trip is skipped only when nothing at all holds
192+
/// the port. Racing it is harmless either way - an owner that binds just after the check is
193+
/// found by the next call, and one that exits just after it costs the timeout exactly as
194+
/// before.
195+
/// </para>
196+
/// </summary>
197+
static bool PortIsHeld()
198+
{
199+
var port = ViewerClient.Port;
200+
try
201+
{
202+
return IPGlobalProperties.GetIPGlobalProperties()
203+
.GetActiveTcpListeners()
204+
.Any(_ => _.Port == port);
205+
}
206+
catch (NetworkInformationException)
207+
{
208+
// No table to read, so let the connect decide as it always did
209+
return true;
210+
}
211+
}
161212
}

src/DiffEngineTray/Tracker.cs

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ class Tracker :
1010
ConcurrentDictionary<string, TrackedMove> moves = new(StringComparer.OrdinalIgnoreCase);
1111
ConcurrentDictionary<string, TrackedDelete> deletes = new(StringComparer.OrdinalIgnoreCase);
1212
IInlineHost inline;
13-
// The last listing seen, used for the icon state. Free when this tray owns the queue, and a
14-
// loopback round trip when a viewer does, which is why the menu re-reads live when it opens.
13+
// The last listing seen, used for the icon state and for the menu. Free when this tray owns
14+
// the queue, and a loopback round trip when a viewer does, which is why nothing re-reads it
15+
// from a click.
1516
IReadOnlyList<PendingSnapshot> snapshots = [];
1617
AsyncTimer timer;
1718
int lastScanCount;
@@ -31,6 +32,11 @@ public Tracker(Action active, Action inactive, LockedFilesResolver? lockedFilesR
3132
{
3233
ExceptionHandler.Handle("Failed to scan files", exception);
3334
});
35+
36+
// Seeded rather than left empty until the first scan two seconds later. The menu reads
37+
// this cache now, so without it a tray that has just started shows none of what a viewer
38+
// already had queued - and the icon stays dark for the same two seconds.
39+
Refresh();
3440
}
3541

3642
Task ScanFiles(Cancel cancel)
@@ -316,19 +322,20 @@ public Task Discard(PendingSnapshot snapshot) =>
316322
}
317323
});
318324

319-
public Task AcceptAllSnapshots()
320-
{
321-
// Live read, not the scan cache: this can be called before the first scan, and acting on
322-
// a stale empty cache would silently do nothing.
323-
if (Snapshots.Count == 0)
324-
{
325-
return Task.CompletedTask;
326-
}
327-
328-
return Task.Run(() =>
325+
public Task AcceptAllSnapshots() =>
326+
Task.Run(() =>
329327
{
330328
try
331329
{
330+
// Live read, not the scan cache: this can be called before the first scan, and
331+
// acting on a stale empty cache would silently do nothing. Inside the worker
332+
// rather than in front of it, because the caller is a menu click or a hot key and
333+
// the read is a round trip whenever a viewer owns the queue.
334+
if (inline.List().Count == 0)
335+
{
336+
return;
337+
}
338+
332339
if (!inline.AcceptAll(out var message))
333340
{
334341
inlineFailed?.Invoke($"Could not accept the pending snapshots. {message}");
@@ -341,7 +348,6 @@ public Task AcceptAllSnapshots()
341348
ExceptionHandler.Handle("Failed to accept the pending snapshots", exception);
342349
}
343350
});
344-
}
345351

346352
/// <summary>
347353
/// Accepts just these snapshots, for a group header: unlike <see cref="AcceptAllSnapshots"/>,
@@ -936,17 +942,24 @@ int ITrackedFiles.DiscardAll()
936942
}
937943

938944
/// <summary>
939-
/// Read live rather than from the scan cache, so the menu shows the viewer's current queue at
940-
/// the moment it opens.
945+
/// The last listing seen, rather than a fresh one.
946+
/// <para>
947+
/// This is what the menu is built from, and building it runs on the UI thread inside
948+
/// <c>ContextMenuStrip.Opening</c>. Reading live there put a loopback round trip between the
949+
/// right click and the menu whenever a viewer owned the queue. Worse, a connection to a port
950+
/// nothing is listening on is only refused at once on some machines - where the SYN is dropped
951+
/// instead, an owner that had exited cost the whole of
952+
/// <see cref="ViewerClient.ShortTimeout"/>, so every menu open took half a second for the rest
953+
/// of the tray's life.
954+
/// </para>
955+
/// <para>
956+
/// Nothing is lost where the queue is held here: <see cref="OwnedInlineHost.Changed"/> runs
957+
/// <see cref="Refresh"/> on every mutation, and the tray's own accepts and discards refresh
958+
/// too, so the cache is the live queue. Where a viewer holds it, the listing is at most one
959+
/// scan old - which is what <see cref="TrackingAny"/> and the icon have always shown.
960+
/// </para>
941961
/// </summary>
942-
public IReadOnlyList<PendingSnapshot> Snapshots
943-
{
944-
get
945-
{
946-
snapshots = inline.List();
947-
return snapshots;
948-
}
949-
}
962+
public IReadOnlyList<PendingSnapshot> Snapshots => snapshots;
950963

951964
/// <summary>
952965
/// Deliberately not <see cref="Clear"/>: exiting is not discarding. The diff tools this tray

0 commit comments

Comments
 (0)