Skip to content

Commit 518d092

Browse files
authored
Let an explicit MaxInstancesToLaunch beat DiffEngine_MaxInstances (#852)
DiffEngine_MaxInstances was read ahead of the app domain value, and it persists per user - the tray writes it to the user environment on every settings save. So on a machine that had ever saved tray settings, DiffRunner.MaxInstancesToLaunch silently did nothing, and a test calling it to keep diff windows shut still had them open. Read the app domain value first, as DiffRunner.Disabled already does for an explicit set, leaving the environment as the ambient default for a run that sets nothing. Two things behind that, in SetForUser: - A save that changes nothing writes nothing. Write is handed whatever the options form was populated with, which is the value already in effect, and unrelated saves come through the same path - the "always kill locking processes" prompt persists itself that way and has no options form at all. Opening the tray once was enough to leave a user scope variable behind on a machine that had never chosen a limit. - Choosing the default clears the variable rather than persisting it, as SetTargetOnLeft does with false, so there is a way back to an unset machine through the options form. Tests wrote the user environment of the machine running them, and could not put it back reliably: the test projects run as parallel processes over the one registry key, so a capture in one and a restore in the other race. EnvironmentHelper.Set is now swappable, and both test module initializers keep those writes inside the process.
1 parent 14447c9 commit 518d092

9 files changed

Lines changed: 320 additions & 24 deletions
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/// <summary>
2+
/// DiffEngine_MaxInstances was read ahead of the app domain value, and it persists per user -
3+
/// DiffEngineTray writes it to the user environment on every options save. So on a machine that
4+
/// had ever saved Options, <see cref="DiffRunner.MaxInstancesToLaunch" /> silently did nothing,
5+
/// and a test calling it to keep diff windows shut still had them open.
6+
/// </summary>
7+
[NotInParallel]
8+
public class MaxInstancePrecedenceTests
9+
{
10+
const string variable = "DiffEngine_MaxInstances";
11+
12+
[Test]
13+
public async Task Setting_it_beats_the_environment()
14+
{
15+
Environment.SetEnvironmentVariable(variable, "10");
16+
MaxInstance.ResetAppDomainValue();
17+
18+
DiffRunner.MaxInstancesToLaunch(0);
19+
20+
await Assert.That(MaxInstance.MaxInstancesToLaunch).IsEqualTo(0);
21+
}
22+
23+
/// <summary>
24+
/// The environment is still what an app domain that sets nothing gets, which is how the tray
25+
/// and DiffEngine_MaxInstances go on working.
26+
/// </summary>
27+
[Test]
28+
public async Task The_environment_is_the_default_when_nothing_sets_it()
29+
{
30+
Environment.SetEnvironmentVariable(variable, "10");
31+
MaxInstance.ResetAppDomainValue();
32+
33+
await Assert.That(MaxInstance.MaxInstancesToLaunch).IsEqualTo(10);
34+
}
35+
36+
/// <summary>
37+
/// A user scope save is the later explicit set, so it takes the value back off the app domain
38+
/// rather than losing to it. This is what the tray does on an options save.
39+
/// </summary>
40+
[Test]
41+
public async Task A_user_set_after_an_app_domain_set_wins()
42+
{
43+
// Not 3, so the save is a change and SetForUser does not skip it. Whatever this machine
44+
// happens to have set is not allowed to decide that.
45+
Environment.SetEnvironmentVariable(variable, "10");
46+
DiffRunner.MaxInstancesToLaunch(0);
47+
48+
MaxInstance.SetForUser(3);
49+
50+
await Assert.That(MaxInstance.MaxInstancesToLaunch).IsEqualTo(3);
51+
}
52+
53+
string? original = Environment.GetEnvironmentVariable(variable);
54+
55+
[After(Test)]
56+
public void Restore()
57+
{
58+
Environment.SetEnvironmentVariable(variable, original);
59+
MaxInstance.ResetAppDomainValue();
60+
MaxInstance.ResetCount();
61+
}
62+
}

src/DiffEngine.Tests/MaxInstanceReplacementTests.cs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,11 @@ public async Task ADifferentPairStillHitsTheLimit()
4747
}
4848

4949
/// <summary>
50-
/// Through the environment variable, because that is what MaxInstance reads first and this
51-
/// machine may well have one set - DiffEngine_MaxInstances persists per user, so the app
52-
/// domain setting alone silently loses to it. Process scoped, so nothing outlives the run.
50+
/// The app domain value beats DiffEngine_MaxInstances, which this machine may well have set -
51+
/// it persists per user - so this is all it takes to pin the limit for the test.
5352
/// </summary>
5453
static void LimitTo(int value)
5554
{
56-
Environment.SetEnvironmentVariable(variable, value.ToString());
57-
// Forces MaxInstance to re-read, since it caches the first answer
5855
DiffRunner.MaxInstancesToLaunch(value);
5956
MaxInstance.ResetCount();
6057
}
@@ -105,8 +102,7 @@ string Write(string name)
105102

106103
public void Dispose()
107104
{
108-
Environment.SetEnvironmentVariable(variable, original);
109-
DiffRunner.MaxInstancesToLaunch(5);
105+
MaxInstance.ResetAppDomainValue();
110106
MaxInstance.ResetCount();
111107
try
112108
{
@@ -122,8 +118,6 @@ public void Dispose()
122118

123119
// Per test, not static: two tests sharing paths means the second one's first launch finds
124120
// the first one's tool still open and is treated as a replacement
125-
const string variable = "DiffEngine_MaxInstances";
126-
string? original = Environment.GetEnvironmentVariable(variable);
127121
string directory = Path.Combine(Path.GetTempPath(), $"DiffEngine.MaxInstance.{Guid.NewGuid():N}");
128122
ResolvedTool tool;
129123
string temp;
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/// <summary>
2+
/// The tray persisted DiffEngine_MaxInstances on every settings save, whether or not the limit was
3+
/// part of what changed - the tray's SettingsHelper.Write calls SetForUser unconditionally, and
4+
/// the value it passes is whatever the options form was populated with, which is the value already
5+
/// in effect. Saves of unrelated settings go through the same path, including the "always kill
6+
/// locking processes" prompt, which has no options form at all. So opening the tray once was enough
7+
/// to leave a user scope variable behind on a machine that had never chosen a limit.
8+
/// </summary>
9+
[NotInParallel]
10+
public class MaxInstanceUserWriteTests
11+
{
12+
const string variable = "DiffEngine_MaxInstances";
13+
14+
/// <summary>
15+
/// The case that put the variable on machines that never asked for it: nothing set, so the
16+
/// form opens at the default and saves it straight back.
17+
/// </summary>
18+
[Test]
19+
public async Task Saving_the_default_on_a_machine_with_nothing_set_writes_nothing()
20+
{
21+
Environment.SetEnvironmentVariable(variable, null);
22+
MaxInstance.ResetAppDomainValue();
23+
24+
MaxInstance.SetForUser(MaxInstance.MaxInstancesToLaunch);
25+
26+
await Assert.That(Environment.GetEnvironmentVariable(variable)).IsNull();
27+
}
28+
29+
[Test]
30+
public async Task Saving_the_value_already_set_writes_nothing()
31+
{
32+
Environment.SetEnvironmentVariable(variable, "10");
33+
MaxInstance.ResetAppDomainValue();
34+
35+
MaxInstance.SetForUser(10);
36+
37+
await Assert.That(Environment.GetEnvironmentVariable(variable)).IsEqualTo("10");
38+
await Assert.That(MaxInstance.MaxInstancesToLaunch).IsEqualTo(10);
39+
}
40+
41+
/// <summary>
42+
/// Choosing the default is a way back to an unset machine, rather than a pin at whatever the
43+
/// default happens to be today. Same as SetTargetOnLeft with false.
44+
/// </summary>
45+
[Test]
46+
public async Task Saving_the_default_clears_a_value_that_was_set()
47+
{
48+
var @default = Default();
49+
Environment.SetEnvironmentVariable(variable, "10");
50+
MaxInstance.ResetAppDomainValue();
51+
52+
MaxInstance.SetForUser(@default);
53+
54+
await Assert.That(Environment.GetEnvironmentVariable(variable)).IsNull();
55+
await Assert.That(MaxInstance.MaxInstancesToLaunch).IsEqualTo(@default);
56+
}
57+
58+
/// <summary>
59+
/// Including a value that was pinned at the default explicitly - it says nothing the absent
60+
/// variable does not, and leaving it would keep the machine pinned.
61+
/// </summary>
62+
[Test]
63+
public async Task Saving_the_default_over_a_redundant_pin_clears_it()
64+
{
65+
var @default = Default();
66+
Environment.SetEnvironmentVariable(variable, @default.ToString());
67+
MaxInstance.ResetAppDomainValue();
68+
69+
MaxInstance.SetForUser(@default);
70+
71+
await Assert.That(Environment.GetEnvironmentVariable(variable)).IsNull();
72+
}
73+
74+
/// <summary>
75+
/// What the limit is with nothing set, which is what defaultMax is, without reaching into it.
76+
/// </summary>
77+
static int Default()
78+
{
79+
Environment.SetEnvironmentVariable(variable, null);
80+
MaxInstance.ResetAppDomainValue();
81+
return MaxInstance.MaxInstancesToLaunch;
82+
}
83+
84+
/// <summary>
85+
/// And a save that does change the limit still lands, which is the point of the setting.
86+
/// </summary>
87+
[Test]
88+
public async Task Saving_a_different_value_writes_it()
89+
{
90+
Environment.SetEnvironmentVariable(variable, "10");
91+
MaxInstance.ResetAppDomainValue();
92+
93+
MaxInstance.SetForUser(7);
94+
95+
await Assert.That(Environment.GetEnvironmentVariable(variable)).IsEqualTo("7");
96+
await Assert.That(MaxInstance.MaxInstancesToLaunch).IsEqualTo(7);
97+
}
98+
99+
string? original = Environment.GetEnvironmentVariable(variable);
100+
101+
[After(Test)]
102+
public void Restore()
103+
{
104+
Environment.SetEnvironmentVariable(variable, original);
105+
MaxInstance.ResetAppDomainValue();
106+
MaxInstance.ResetCount();
107+
}
108+
}

src/DiffEngine.Tests/ModuleInitializer.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,18 @@ public static void Initialize()
99
FileExtensions.AddTextFileConvention(_ => _.EndsWith(".txtConvention".AsSpan()));
1010
Logging.Enable();
1111
DiffRunner.Disabled = false;
12+
KeepEnvironmentWritesInProcess();
1213
DetachFromPendingFileSurfaces();
1314
}
1415

16+
/// <summary>
17+
/// Tests must not write the user environment of the machine running them. The test projects
18+
/// run as parallel processes over the one registry key, so a capture in one and a restore in
19+
/// the other race, and the value that loses is gone.
20+
/// </summary>
21+
static void KeepEnvironmentWritesInProcess() =>
22+
EnvironmentHelper.Set = EnvironmentHelper.SetProcessOnly;
23+
1524
/// <summary>
1625
/// Launching sends a real pending move to whatever owns the queue on this machine. On a
1726
/// developer box that is the tray, started at login, and an accept or discard from it kills the
Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
11
static class EnvironmentHelper
22
{
3-
public static void Set(string name, string? value)
3+
/// <summary>
4+
/// Both scopes, so a setting chosen in the tray outlives the process that chose it.
5+
/// <para>
6+
/// Swapped for <see cref="SetProcessOnly" /> by the test projects. A test would otherwise
7+
/// write the user environment of the machine running it, and cannot put it back reliably: the
8+
/// test projects run as parallel processes over the one registry key, so a capture in one and
9+
/// a restore in the other race, and the value that loses is gone.
10+
/// </para>
11+
/// </summary>
12+
internal static Action<string, string?> Set = SetUserAndProcess;
13+
14+
static void SetUserAndProcess(string name, string? value)
415
{
516
Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.User);
617
Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
718
}
8-
}
19+
20+
/// <summary>
21+
/// Nothing outside the process. For tests.
22+
/// </summary>
23+
internal static void SetProcessOnly(string name, string? value) =>
24+
Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
25+
}

src/DiffEngine/MaxInstance.cs

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,20 @@ static class MaxInstance
77
static int launchedInstances;
88
const int defaultMax = 5;
99

10+
/// <summary>
11+
/// An explicit set wins over the environment, which is only the ambient default for a run that
12+
/// sets nothing.
13+
/// <para>
14+
/// DiffEngine_MaxInstances persists per user - DiffEngineTray writes it to the user
15+
/// environment on every options save - so reading it first meant that on a machine which had
16+
/// ever saved Options, <see cref="DiffRunner.MaxInstancesToLaunch" /> silently did nothing. A
17+
/// test suppressing diff windows with it still opened them. Same order as
18+
/// <see cref="DiffRunner.Disabled" />, where an explicit set also pins the value.
19+
/// </para>
20+
/// </summary>
1021
static int GetMaxInstances() =>
11-
GetEnvironmentValue() ??
1222
appDomainMaxInstancesToLaunch ??
23+
GetEnvironmentValue() ??
1324
defaultMax;
1425

1526
static int? GetEnvironmentValue()
@@ -38,10 +49,61 @@ public static void SetForAppDomain(int value)
3849
ResetCapturedValue();
3950
}
4051

52+
/// <summary>
53+
/// Persists a user scope value, as <see cref="TargetPosition.SetTargetOnLeft" /> does for
54+
/// DiffEngine_TargetOnLeft.
55+
/// <para>
56+
/// A save that changes nothing writes nothing. The tray calls this for every settings save -
57+
/// including saves of unrelated settings, like the "always kill locking processes" prompt -
58+
/// and the value it passes is whatever the options form was populated with. So a machine that
59+
/// had never chosen a limit still ended up with DiffEngine_MaxInstances in its user
60+
/// environment, at the value that was already in effect.
61+
/// </para>
62+
/// <para>
63+
/// And choosing the default clears the variable rather than persisting it, so there is a way
64+
/// back to an unset machine through the options form.
65+
/// </para>
66+
/// </summary>
4167
public static void SetForUser(int value)
4268
{
4369
Guard.AgainstNegative(value, nameof(value));
44-
EnvironmentHelper.Set("DiffEngine_MaxInstances", value.ToString());
70+
71+
// The default needs nothing persisted to mean what it means, so returning to it takes the
72+
// variable back out rather than pinning it at what the default happens to be today.
73+
string? desired;
74+
if (value == defaultMax)
75+
{
76+
desired = null;
77+
}
78+
else
79+
{
80+
desired = value.ToString();
81+
}
82+
83+
// Only when what is persisted would actually change. Read raw rather than through
84+
// GetEnvironmentValue, because this is a comparison against the stored text, and because
85+
// a setter is no place to throw over an existing unparseable value it is about to
86+
// overwrite anyway.
87+
if (Environment.GetEnvironmentVariable("DiffEngine_MaxInstances") == desired)
88+
{
89+
return;
90+
}
91+
92+
EnvironmentHelper.Set("DiffEngine_MaxInstances", desired);
93+
// The later explicit set is the one that counts, so an app domain value from earlier in
94+
// the process cannot shadow what the user just chose.
95+
appDomainMaxInstancesToLaunch = null;
96+
ResetCapturedValue();
97+
}
98+
99+
/// <summary>
100+
/// Forgets an explicit <see cref="SetForAppDomain" />, so the value is read from the
101+
/// environment again. For tests, which is where anything sets it and then wants the ambient
102+
/// value back.
103+
/// </summary>
104+
internal static void ResetAppDomainValue()
105+
{
106+
appDomainMaxInstancesToLaunch = null;
45107
ResetCapturedValue();
46108
}
47109

@@ -57,4 +119,4 @@ public static bool Reached()
57119
var count = Interlocked.Increment(ref launchedInstances);
58120
return count > MaxInstancesToLaunch;
59121
}
60-
}
122+
}

src/DiffEngineTray.Tests/DiffRunnerCanKillTest.cs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,14 @@ public class DiffRunnerCanKillTest :
88
{
99
string tempFile = Path.GetTempFileName();
1010
bool originalDisabled = DiffRunner.Disabled;
11-
string? originalMaxInstances = Environment.GetEnvironmentVariable("DiffEngine_MaxInstances");
1211

1312
public DiffRunnerCanKillTest()
1413
{
1514
PiperClient.Port = GetFreePort();
1615
DiffEngine.DiffEngineTray.IsRunning = true;
1716
DiffRunner.Disabled = false;
1817
// Force the "too many running" branch so no real process is launched, while a move
19-
// payload is still sent to the tray. The env var takes precedence over the app-domain
20-
// value, so set it too; MaxInstancesToLaunch resets the cached lookup.
21-
Environment.SetEnvironmentVariable("DiffEngine_MaxInstances", "0");
18+
// payload is still sent to the tray.
2219
DiffRunner.MaxInstancesToLaunch(0);
2320
}
2421

@@ -107,8 +104,7 @@ public void Dispose()
107104
{
108105
DiffEngine.DiffEngineTray.IsRunning = false;
109106
DiffRunner.Disabled = originalDisabled;
110-
Environment.SetEnvironmentVariable("DiffEngine_MaxInstances", originalMaxInstances);
111-
DiffRunner.MaxInstancesToLaunch(5);
107+
MaxInstance.ResetAppDomainValue();
112108
File.Delete(tempFile);
113109
}
114110
}

src/DiffEngineTray.Tests/ModuleInitializer.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@ public static void Initialize()
55
{
66
VerifyWinForms.Initialize();
77
VerifierSettings.UseSsimForPng(PngSsimThreshold);
8+
KeepEnvironmentWritesInProcess();
89
PointAtAClosedPort();
910
}
1011

12+
/// <summary>
13+
/// Tests must not write the user environment of the machine running them. The test projects
14+
/// run as parallel processes over the one registry key, so a capture in one and a restore in
15+
/// the other race, and the value that loses is gone.
16+
/// </summary>
17+
static void KeepEnvironmentWritesInProcess() =>
18+
EnvironmentHelper.Set = EnvironmentHelper.SetProcessOnly;
19+
1120
/// <summary>
1221
/// Effectively "the same pixels", rather than Verify's 0.98 default. These screens are mostly
1322
/// flat background, so 0.98 is far looser than it sounds on them: a whole missing row of text

0 commit comments

Comments
 (0)