Skip to content

Commit 26e79f0

Browse files
authored
Charge MaxInstance for a viewer that has to be started (#855)
The viewer was exempt from the cap on the grounds that it queues rather than opening a window per pair. That is true of the second pair and every one after it, and false of the first: with nothing owning the queue, AddDiff starts a process. So MaxInstancesToLaunch(0) - "no window opens" - opened one, and a run with no tray put a viewer on screen per staged snapshot with nothing available to stop it. The check goes in ViewerLaunchGate, which is the only place that knows which of the two is happening, and after the ownership probe. A caller that asked would charge all twenty of a parallel run's callers for the one window between them, and the nineteen that find the owner would be capped out of forwarding their pairs to it. Capped is its own outcome so AddDiff can report TooManyRunningDiffTools rather than folding it in with a tool that could not be found.
1 parent 70ec3fc commit 26e79f0

8 files changed

Lines changed: 191 additions & 14 deletions

File tree

claude.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,10 @@ apart.
266266
- Unless that diff tool is the viewer, which is the `Diff` verb and `--diff <received> <target>`.
267267
Then the premise above is false — there is no window for the pair yet — so it is tracked exactly
268268
as a move and a window is raised over the entry, and `DiffRunner` skips the whole process per
269-
pair path: nothing to find already showing it, no window to replace, no `MaxInstance` slot to
270-
spend, and no process for the tray to kill on accept. `DiffRunner.Kill` sends `Settle` for the
269+
pair path: nothing to find already showing it, no window to replace, and no process for the
270+
tray to kill on accept. `MaxInstance` still applies, but charged by `ViewerLaunchGate` rather
271+
than by `DiffRunner`, and only on a viewer that has to be started: handing a pair to one already
272+
on screen opens no window and spends nothing, so the caller cannot be the one to ask. `DiffRunner.Kill` sends `Settle` for the
271273
move key rather than killing anything, since the row is drawn in a window shared with every other
272274
pending pair. That is what makes ten failing image snapshots one window instead of ten, and it is
273275
only available to the viewer because no other tool can be told to drop one pair.

docs/diff-tool.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ This allows, in most cases, for no manual closing of the tool to be required.<!-
4848

4949
By default a maximum of 5 tool instances will be launched. This prevents a change that breaks many tests from causing too much load on a machine.
5050

51-
This value can be changed using an environment variable or by explicitly specifying the value by code. When both are used, the environment variable value will be used.
51+
This value can be changed using an environment variable or by explicitly specifying the value by code. When both are used, the value set in code wins; the environment variable is the ambient default for a run that sets nothing.
52+
53+
The count includes [DiffEngineViewer](/docs/viewer.md), but only when a viewer has to be started. Handing a pair to one already on screen opens no window and so spends nothing.
5254

5355

5456
### Using an environment variable

docs/mdsource/diff-tool.source.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ include: diffToolCleanup
4141

4242
By default a maximum of 5 tool instances will be launched. This prevents a change that breaks many tests from causing too much load on a machine.
4343

44-
This value can be changed using an environment variable or by explicitly specifying the value by code. When both are used, the environment variable value will be used.
44+
This value can be changed using an environment variable or by explicitly specifying the value by code. When both are used, the value set in code wins; the environment variable is the ambient default for a run that sets nothing.
45+
46+
The count includes [DiffEngineViewer](/docs/viewer.md), but only when a viewer has to be started. Handing a pair to one already on screen opens no window and so spends nothing.
4547

4648

4749
### Using an environment variable

src/DiffEngine.Tests/PendingFilesDiffTests.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,37 @@ await Assert.That(owner.Heard).IsEquivalentTo(
7070
]);
7171
}
7272

73+
/// <summary>
74+
/// With nothing owning the queue this route starts a viewer, and MaxInstancesToLaunch(0) says
75+
/// no window opens. It used to be exempt on the grounds that the viewer queues rather than
76+
/// opening one per pair - true of every pair after the first, and not of the first, which
77+
/// starts a process.
78+
/// <para>
79+
/// This is the arrangement a test suite that has to leave diff on runs in: no tray, no owner,
80+
/// and the cap at zero. Before, every staged snapshot in such a run put a viewer on the
81+
/// screen, and nothing in DiffEngine could be set to stop it.
82+
/// </para>
83+
/// </summary>
84+
[Test]
85+
public async Task WithNoOwnerAndNoSlotNothingIsStarted()
86+
{
87+
using var absent = new NoOwner();
88+
89+
DiffRunner.MaxInstancesToLaunch(0);
90+
MaxInstance.ResetCount();
91+
try
92+
{
93+
var result = await PendingFiles.AddDiffAsync(Viewer(), Temp, Target, Cancel.None);
94+
95+
await Assert.That(result).IsEqualTo(LaunchResult.TooManyRunningDiffTools);
96+
}
97+
finally
98+
{
99+
MaxInstance.ResetAppDomainValue();
100+
MaxInstance.ResetCount();
101+
}
102+
}
103+
73104
/// <summary>
74105
/// The other end: the pair's test started passing, so the row it took goes. A settle rather
75106
/// than a kill, because there is no process of its own to kill, and rather than a discard,

src/DiffEngine.Tests/ViewerLaunchGateTests.cs

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/// <summary>
1+
/// <summary>
22
/// The gate that keeps a parallel run from starting a viewer per failing snapshot.
33
/// <para>
44
/// The ownership probe is supplied rather than the real one, so what is asserted is the gate's own
@@ -24,7 +24,8 @@ public async Task ManyCallersAtOnceLaunchOnce()
2424
.Select(_ => Task.Run(() => ViewerLaunchGate.Launch(
2525
retry: () => true,
2626
launch: viewer.Start,
27-
isOwned: viewer.IsUp))));
27+
isOwned: viewer.IsUp,
28+
canLaunch: () => true))));
2829

2930
await Assert.That(viewer.Starts).IsEqualTo(1);
3031
await Assert.That(outcomes.Count(_ => _ == ViewerLaunchOutcome.Launched)).IsEqualTo(1);
@@ -46,7 +47,8 @@ public async Task TheGateIsHeldUntilTheLaunchedViewerAnswers()
4647
.Select(_ => Task.Run(() => ViewerLaunchGate.Launch(
4748
retry: () => true,
4849
launch: viewer.Start,
49-
isOwned: viewer.IsUp))));
50+
isOwned: viewer.IsUp,
51+
canLaunch: () => true))));
5052

5153
await Assert.That(viewer.Starts).IsEqualTo(1);
5254
await Assert.That(outcomes.Count(_ => _ == ViewerLaunchOutcome.Taken)).IsEqualTo(9);
@@ -74,7 +76,8 @@ public async Task AViewerThatNeverAnswersDoesNotHoldTheGateForever()
7476
Interlocked.Increment(ref starts);
7577
return true;
7678
},
77-
isOwned: () => false))));
79+
isOwned: () => false,
80+
canLaunch: () => true))));
7881

7982
await Assert.That(starts).IsEqualTo(3);
8083
await Assert.That(outcomes.All(_ => _ == ViewerLaunchOutcome.Launched)).IsTrue();
@@ -96,7 +99,8 @@ public async Task ALaunchThatCouldNotStartIsReportedRatherThanWaitedOn()
9699
var outcome = ViewerLaunchGate.Launch(
97100
retry: () => true,
98101
launch: () => false,
99-
isOwned: () => false);
102+
isOwned: () => false,
103+
canLaunch: () => true);
100104

101105
await Assert.That(outcome).IsEqualTo(ViewerLaunchOutcome.Failed);
102106
}
@@ -129,6 +133,98 @@ public async Task ARefusingOwnerIsNotLaunchedOver()
129133
await Assert.That(launches).IsEqualTo(0);
130134
}
131135

136+
/// <summary>
137+
/// MaxInstancesToLaunch(0) means no window opens, and the viewer is a window. It used to be
138+
/// exempt on the grounds that it queues rather than opening one per pair, which is true of the
139+
/// second pair and every one after, and not of the first: that one starts a process.
140+
/// </summary>
141+
[Test]
142+
public async Task NoSlotMeansNoViewerIsStarted()
143+
{
144+
var viewer = new FakeViewer();
145+
146+
var outcome = ViewerLaunchGate.Launch(
147+
retry: () => true,
148+
launch: viewer.Start,
149+
isOwned: () => false,
150+
canLaunch: () => false);
151+
152+
await Assert.That(outcome).IsEqualTo(ViewerLaunchOutcome.Capped);
153+
await Assert.That(viewer.Starts).IsEqualTo(0);
154+
}
155+
156+
/// <inheritdoc cref="NoSlotMeansNoViewerIsStarted" />
157+
[Test]
158+
public async Task NoSlotMeansNoViewerIsStartedAsync()
159+
{
160+
var viewer = new FakeViewer();
161+
162+
var outcome = await ViewerLaunchGate.LaunchAsync(
163+
retry: () => Task.FromResult(true),
164+
launch: () => Task.FromResult(viewer.Start()),
165+
Cancel.None,
166+
isOwned: () => false,
167+
canLaunch: () => false);
168+
169+
await Assert.That(outcome).IsEqualTo(ViewerLaunchOutcome.Capped);
170+
await Assert.That(viewer.Starts).IsEqualTo(0);
171+
}
172+
173+
/// <summary>
174+
/// A slot is spent on a window, not on a pair. So the cap is asked only once the ownership
175+
/// probe has said there is no window - otherwise the nineteen callers that find the one their
176+
/// sibling started would each be charged for it, and a run of twenty failing snapshots would
177+
/// exhaust any cap and strand its pairs.
178+
/// </summary>
179+
[Test]
180+
public async Task ForwardingToARunningViewerSpendsNoSlot()
181+
{
182+
var viewer = new FakeViewer();
183+
var asked = 0;
184+
185+
var outcome = ViewerLaunchGate.Launch(
186+
retry: () => true,
187+
launch: viewer.Start,
188+
isOwned: () => true,
189+
canLaunch: () =>
190+
{
191+
asked++;
192+
return true;
193+
});
194+
195+
await Assert.That(outcome).IsEqualTo(ViewerLaunchOutcome.Taken);
196+
await Assert.That(viewer.Starts).IsEqualTo(0);
197+
await Assert.That(asked).IsEqualTo(0);
198+
}
199+
200+
/// <summary>
201+
/// The real cap, so the default the call sites rely on is not only ever exercised through a
202+
/// stand-in.
203+
/// </summary>
204+
[Test]
205+
public async Task TheDefaultSlotCheckReadsMaxInstance()
206+
{
207+
var viewer = new FakeViewer();
208+
try
209+
{
210+
DiffRunner.MaxInstancesToLaunch(0);
211+
MaxInstance.ResetCount();
212+
213+
var outcome = ViewerLaunchGate.Launch(
214+
retry: () => true,
215+
launch: viewer.Start,
216+
isOwned: () => false);
217+
218+
await Assert.That(outcome).IsEqualTo(ViewerLaunchOutcome.Capped);
219+
await Assert.That(viewer.Starts).IsEqualTo(0);
220+
}
221+
finally
222+
{
223+
MaxInstance.ResetAppDomainValue();
224+
MaxInstance.ResetCount();
225+
}
226+
}
227+
132228
/// <summary>
133229
/// The real probe, against a real bound port, so the default the call sites rely on is not
134230
/// only ever exercised through a stand-in.

src/DiffEngine/DiffRunner.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,11 @@ static LaunchResult InnerLaunch(TryResolveTool tryResolveTool, string tempFile,
216216
}
217217

218218
// The viewer queues rather than opening a window per pair, so none of the process
219-
// bookkeeping below applies to it: there is no instance showing this pair to find, no
220-
// window to replace, and no slot to spend on a window that already exists.
219+
// bookkeeping below applies to it: there is no instance showing this pair to find, and no
220+
// window to replace. The cap still does, but only on a viewer that has to be started -
221+
// handing a pair to one already on screen opens nothing. ViewerLaunchGate is the only
222+
// place that knows which of the two is happening, so it charges MaxInstance rather than
223+
// this method.
221224
if (PendingFiles.IsViewer(tool))
222225
{
223226
return PendingFiles.AddDiff(tool, tempFile, targetFile);

src/DiffEngine/Tray/PendingFiles.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,17 @@ public static LaunchResult AddDiff(ResolvedTool tool, string tempFile, string ta
130130
/// A launch that turned out not to be one is not reported as one. Twenty pairs failing at once
131131
/// put twenty callers on the gate and one viewer on the screen, and calling that twenty new
132132
/// instances is how the count stopped meaning anything.
133+
/// <para>
134+
/// A capped one reports what every other tool's does, rather than being folded in with a tool
135+
/// that could not be found: the pair has a tool and the cap is why no window opened.
136+
/// </para>
133137
/// </summary>
134138
static LaunchResult Launched(ViewerLaunchOutcome outcome) =>
135139
outcome switch
136140
{
137141
ViewerLaunchOutcome.Launched => LaunchResult.StartedNewInstance,
138142
ViewerLaunchOutcome.Taken => LaunchResult.AlreadyRunningAndSupportsRefresh,
143+
ViewerLaunchOutcome.Capped => LaunchResult.TooManyRunningDiffTools,
139144
_ => LaunchResult.NoDiffToolFound
140145
};
141146

src/DiffEngine/Viewer/ViewerLaunchGate.cs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ enum ViewerLaunchOutcome
1919
/// <summary>
2020
/// Nothing could be started, and nobody was there to take it.
2121
/// </summary>
22-
Failed
22+
Failed,
23+
24+
/// <summary>
25+
/// Nobody was there to take it and <see cref="MaxInstance" /> had no slot left, so nothing was
26+
/// started.
27+
/// </summary>
28+
Capped
2329
}
2430

2531
/// <summary>
@@ -51,6 +57,14 @@ enum ViewerLaunchOutcome
5157
/// cross process wait on the failing path of every run to save a handful of starts in the rarer
5258
/// arrangement.
5359
/// </para>
60+
/// <para>
61+
/// The gate is also where <see cref="MaxInstance" /> is charged for a viewer, because it is the one
62+
/// place that knows whether a window is about to be opened. Handing a pair to a viewer that is
63+
/// already up is not a new instance and spends nothing, which is why the caller cannot ask: it
64+
/// would charge all twenty of the callers above for the one window between them. Asked after the
65+
/// ownership probe, so the nineteen that find an owner still forward their work when the cap is
66+
/// long since reached.
67+
/// </para>
5468
/// </summary>
5569
static class ViewerLaunchGate
5670
{
@@ -75,9 +89,19 @@ static class ViewerLaunchGate
7589
/// twenty concurrent connects to a port nothing is listening on and read the answer back out
7690
/// of the operating system.
7791
/// </param>
78-
public static ViewerLaunchOutcome Launch(Func<bool> retry, Func<bool> launch, Func<bool>? isOwned = null)
92+
/// <param name="canLaunch">
93+
/// Whether a slot is available, and spends one when it is. Defaults to <see cref="MaxInstance" />.
94+
/// Supplied by the tests that are about the gate rather than about the cap, since the count it
95+
/// reads is shared with every other launch the process has made.
96+
/// </param>
97+
public static ViewerLaunchOutcome Launch(
98+
Func<bool> retry,
99+
Func<bool> launch,
100+
Func<bool>? isOwned = null,
101+
Func<bool>? canLaunch = null)
79102
{
80103
isOwned ??= () => ViewerClient.IsOwned();
104+
canLaunch ??= () => !MaxInstance.Reached();
81105
bool owned;
82106
gate.Wait();
83107
try
@@ -87,6 +111,11 @@ public static ViewerLaunchOutcome Launch(Func<bool> retry, Func<bool> launch, Fu
87111
owned = isOwned();
88112
if (!owned)
89113
{
114+
if (!canLaunch())
115+
{
116+
return ViewerLaunchOutcome.Capped;
117+
}
118+
90119
if (!launch())
91120
{
92121
return ViewerLaunchOutcome.Failed;
@@ -113,16 +142,23 @@ public static async Task<ViewerLaunchOutcome> LaunchAsync(
113142
Func<Task<bool>> retry,
114143
Func<Task<bool>> launch,
115144
Cancel cancel,
116-
Func<bool>? isOwned = null)
145+
Func<bool>? isOwned = null,
146+
Func<bool>? canLaunch = null)
117147
{
118148
isOwned ??= () => ViewerClient.IsOwned();
149+
canLaunch ??= () => !MaxInstance.Reached();
119150
bool owned;
120151
await gate.WaitAsync(cancel);
121152
try
122153
{
123154
owned = isOwned();
124155
if (!owned)
125156
{
157+
if (!canLaunch())
158+
{
159+
return ViewerLaunchOutcome.Capped;
160+
}
161+
126162
if (!await launch())
127163
{
128164
return ViewerLaunchOutcome.Failed;

0 commit comments

Comments
 (0)