Skip to content

Commit 8a591fd

Browse files
authored
Relaunch a tracked move the way the tool says to (#849)
DiffRunner starts a diff tool with the UseShellExecute and CreateNoWindow the tool declares. The tray's "Open diff tool" hard coded ShellExecute and left CreateNoWindow off, so the same tool was started two different ways depending on which surface asked - and the flag a console subsystem tool sets specifically to keep a window off the screen was the one being dropped. Measured before changing anything, because the comment that flag carries says a window flashes without it. On Windows 11 nothing flashes: the console window the hard coded pair produces is zero sized, WS_EX_TOOLWINDOW and WS_EX_NOACTIVATE, so nobody sees it, and a conhost child is attached either way. What the tool's own flags change here is that the window object is not created at all. The reason to do it is still the first paragraph: one launch contract rather than two, on a machine where that comment may well have been earned. The flags are resolved rather than carried, because the payload has no room for them - PiperServer's format is frozen, and every stable DiffEngine embeds the client that writes it. By path first, and then by the executable's name, which is what the bundled viewer needs: the path a move carries is the sending process's, and that one is inside that project's package folder, somewhere the tray has never looked. An exe that resolves to neither keeps the pair this always used. Note that following the tool now also means following UseShellExecute, so a viewer reopened from the tray inherits the tray's handles rather than being detached from them. The tray is a long lived process whose output nobody is reading, which is not the case the ShellExecute default was there to protect.
1 parent 57cb77c commit 8a591fd

2 files changed

Lines changed: 121 additions & 4 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/// <summary>
2+
/// The start flags the tray relaunches a tracked move with, which have to be the tool's own rather
3+
/// than a fixed pair.
4+
/// <para>
5+
/// They were fixed at ShellExecute with no CreateNoWindow, so a console subsystem tool - the
6+
/// bundled viewer is one - came up from "Open diff tool" with a console attached, while
7+
/// DiffEngine's own launch of the very same tool did not. The window that console puts on screen
8+
/// is zero sized and never activates, so nobody saw it; what it left behind was a conhost process
9+
/// per relaunch and two launch paths that disagreed.
10+
/// </para>
11+
/// </summary>
12+
public class DiffToolLauncherFlagsTest :
13+
IDisposable
14+
{
15+
[Test]
16+
public async Task AToolsOwnFlagsAreUsed()
17+
{
18+
var registered = DiffTools.AddTool(
19+
name: "FakeConsoleTool",
20+
autoRefresh: false,
21+
isMdi: false,
22+
supportsText: true,
23+
requiresTarget: false,
24+
useShellExecute: false,
25+
launchArguments: new(
26+
Left: (temp, target) => $"\"{target}\" \"{temp}\"",
27+
Right: (temp, target) => $"\"{temp}\" \"{target}\""),
28+
exePath: exe,
29+
binaryExtensions: [],
30+
createNoWindow: true);
31+
32+
await Assert.That(registered).IsNotNull();
33+
await Assert.That(DiffToolLauncher.FlagsFor(exe)).IsEqualTo((false, true));
34+
}
35+
36+
/// <summary>
37+
/// The path a move carries is the sending process's. For the bundled viewer that is inside
38+
/// that project's package folder, which this process has never looked in, so the exact path
39+
/// misses and the executable's name is what is left to go on.
40+
/// </summary>
41+
[Test]
42+
public async Task TheSameToolAtAnotherPathIsStillThatTool()
43+
{
44+
DiffTools.AddTool(
45+
name: "FakeConsoleTool",
46+
autoRefresh: false,
47+
isMdi: false,
48+
supportsText: true,
49+
requiresTarget: false,
50+
useShellExecute: false,
51+
launchArguments: new(
52+
Left: (temp, target) => $"\"{target}\" \"{temp}\"",
53+
Right: (temp, target) => $"\"{temp}\" \"{target}\""),
54+
exePath: exe,
55+
binaryExtensions: [],
56+
createNoWindow: true);
57+
58+
var elsewhere = Path.Combine(@"c:\somewhere\else", Path.GetFileName(exe));
59+
60+
await Assert.That(DiffToolLauncher.FlagsFor(elsewhere)).IsEqualTo((false, true));
61+
}
62+
63+
/// <summary>
64+
/// Nothing resolves for a tool that is no longer installed, or an exe a payload named that
65+
/// never was one. That keeps what this has always done, and ShellExecute is the safe end of
66+
/// it: without it the launched tool inherits the launching process's handles.
67+
/// </summary>
68+
[Test]
69+
public async Task AnUnknownExeKeepsTheOldPair() =>
70+
await Assert.That(DiffToolLauncher.FlagsFor(@"c:\nothing\here.exe")).IsEqualTo((true, false));
71+
72+
readonly string exe = Environment.ProcessPath!;
73+
74+
public void Dispose() =>
75+
// Registered into the static lookup, so the rest of the run has to get it back.
76+
DiffTools.Reset();
77+
}

src/DiffEngineTray/DiffToolLauncher.cs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,47 @@ static class DiffToolLauncher
66
public static void Launch(TrackedMove move) =>
77
Launch(move.Exe!, move.Arguments!, move.CanKill, move.Process, _ => move.Process = _);
88

9+
/// <summary>
10+
/// The two start flags the tool itself declares, which is what <c>DiffRunner.LaunchProcess</c>
11+
/// launches it with. Hard coded here before, so a console subsystem tool - the bundled viewer
12+
/// is one - was started without CreateNoWindow and came up with a console attached, which
13+
/// DiffEngine's own launch of the same tool does not do.
14+
/// <para>
15+
/// Resolved by path rather than carried on the move, because the payload the tray receives has
16+
/// no room for them: PiperServer's format is frozen, every stable DiffEngine embeds the client
17+
/// that writes it, and a new field would be read as nothing by all of them.
18+
/// </para>
19+
/// <para>
20+
/// By name when the path misses, which for the bundled viewer it does: the path a move carries
21+
/// is the sending process's, and that one sits inside that project's package folder, somewhere
22+
/// this process has never looked. The same tool found somewhere else is still that tool, and
23+
/// its start flags belong to the executable rather than to where it was installed.
24+
/// </para>
25+
/// <para>
26+
/// An exe that resolves to neither keeps what this always did. ShellExecute is the safe end of
27+
/// that: a tool started without it inherits the launching process's handles, which is what
28+
/// <see href="https://github.com/VerifyTests/Verify/issues/1229" /> is about.
29+
/// </para>
30+
/// </summary>
31+
internal static (bool useShellExecute, bool createNoWindow) FlagsFor(string exe)
32+
{
33+
if (DiffTools.TryFindByPath(exe, out var tool))
34+
{
35+
return (tool.UseShellExecute, tool.CreateNoWindow);
36+
}
37+
38+
var name = Path.GetFileName(exe);
39+
foreach (var candidate in DiffTools.Resolved)
40+
{
41+
if (string.Equals(Path.GetFileName(candidate.ExePath), name, StringComparison.OrdinalIgnoreCase))
42+
{
43+
return (candidate.UseShellExecute, candidate.CreateNoWindow);
44+
}
45+
}
46+
47+
return (true, false);
48+
}
49+
950
static void Launch(string exe, string arguments, bool canKill, Process? process, Action<Process?> assign)
1051
{
1152
if (process is { HasExited: false })
@@ -24,12 +65,11 @@ static void Launch(string exe, string arguments, bool canKill, Process? process,
2465
process?.Dispose();
2566
assign(null);
2667

68+
var (useShellExecute, createNoWindow) = FlagsFor(exe);
2769
var startInfo = new ProcessStartInfo(exe, arguments)
2870
{
29-
// Given the full exe path is known we dont need UseShellExecute https://stackoverflow.com/a/5255335
30-
// however UseShellExecute allows the test running to not block when the difftool is launched
31-
// https://github.com/VerifyTests/Verify/issues/1229
32-
UseShellExecute = true
71+
UseShellExecute = useShellExecute,
72+
CreateNoWindow = createNoWindow
3373
};
3474

3575
try

0 commit comments

Comments
 (0)