Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,71 @@ jobs:
if-no-files-found: ignore
retention-days: 14

# A test process inside a WSL distribution can start a diff tool installed on Windows. Every
# part of that which can be tested without a distribution is, in the jobs above. This job is for
# the rest: what a distribution's mount table and ps really say, and whether a program on the
# host can be started from inside one, handed a file by its translated path, found again and
# ended. Under WSL 1 as well as 2, since the two mount drives and reach the host differently.
#
# The tests are built on the runner and run inside the distribution from where they were built,
# which is a Windows drive mounted into it. That is one build rather than two, and needs only
# the .NET runtime inside, not the SDK. It also means WslLiveTests is the only class that can be
# run this way: tests that find their fixtures by the path they were compiled at get a Windows
# path there.
#
# Not among the jobs a release waits for. It is new, and a runner image that stops offering WSL
# should not be what holds a package back.
wsl:
name: wsl ${{ matrix.version }}
runs-on: windows-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
version: [1, 2]
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up .NET
uses: actions/setup-dotnet@v6
with:
global-json-file: global.json

# Debug and one framework: Release also packs, and publishes every viewer head to do it.
- name: Build
run: dotnet build src/DiffEngine.Tests/DiffEngine.Tests.csproj --configuration Debug --framework net10.0

# procps for ps, which is how a running tool is found, and curl for the runtime's installer.
- name: Install a distribution
uses: Vampire/setup-wsl@v7
with:
distribution: Ubuntu-24.04
wsl-version: ${{ matrix.version }}
additional-packages: curl ca-certificates procps

# Build server variables stay on the Windows side, as they do for anyone's build that runs
# its tests in WSL, so in here this is a developer machine. That is what is being tested.
#
# Without ICU, which the distribution does not have and these tests do not need.
- name: Test
shell: wsl-bash {0}
run: |
if [ -z "${WSL_DISTRO_NAME:-}" ]; then
echo "::error::WSL_DISTRO_NAME is not set, so every test would be skipped"
exit 1
fi
echo "WSL $(wslinfo --wsl-version 2>/dev/null || echo unknown), kernel $(uname -r)"
grep -E 'drvfs' /proc/mounts || true

curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
bash /tmp/dotnet-install.sh --channel 10.0 --runtime dotnet --install-dir "$HOME/.dotnet"
export DOTNET_ROOT="$HOME/.dotnet"
export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1

cd "$(wslpath -u '${{ github.workspace }}')/src/DiffEngine.Tests/bin/Debug/net10.0"
"$DOTNET_ROOT/dotnet" DiffEngine.Tests.dll --treenode-filter "/*/*/WslLiveTests/*"

# Neither job above loads a committed binary any more: both build the shim from source so their
# pixel baselines track it. That leaves nothing testing what actually ships, which is this job.
# It loads the committed binaries as they are, which is what catches a wrong architecture, a file
Expand Down
2 changes: 1 addition & 1 deletion claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,7 @@ the comment there about not caching "nothing staged" asks for.
### Key Patterns

- Tool discovery uses wildcard path matching (`WildcardFileFinder`) to find executables in common install locations. A wildcard whose matches are all version-named folders takes the highest version; anything else takes the most recently written
- Inside WSL, which `BuildServerDetector.IsWsl` reports and `Detected` does not count as a build server, a tool with no copy in the distribution is resolved from its Windows definition and started through WSL's interop (`Wsl/`: `WslInterop`, `WslHost`, `WslPaths`; `OsSettingsResolver.TryFindOnHost`). The host's variables come from one `cmd.exe /u /c set`, its drives from `/proc/mounts` and the name of the distribution's share from one `wslpath -w /`. Paths are translated in process, not by `wslpath` per path, because `ResolvedTool.GetArguments` runs for every failing pair, opened or not. A program on the host's PATH is found from one listing of each host directory on it (`WslHost.TryFindOnPath`), a stat on a Windows drive from inside WSL being most of a millisecond. What this side holds for such a tool is WSL's `/init` standing in for it: `ps` lists it as `/init <exe> <exe> <args>` (`WslInterop.StripProxy`), ending it does not close a windowed tool, and it ends with the terminal session while the window stays. So `ResolvedTool.CanKill` is false, which is what anyone handed the process id is told, and the library closes the tool itself on the host: `WslInterop.Kill` has Windows PowerShell end the processes of the tool's image whose command line names both files. That is a third of a second, so it is only asked once the stand-in is found by its command, and a tool whose session has gone is neither refreshed nor closed. `WslKillScriptTests` runs the script on Windows, where it can be run. It is started with all three streams redirected and closed (`WslInterop.Start`), or it would hold the pipe `dotnet test` reads. Not offered: the viewer (the host's loopback is not the distribution's under WSL's default networking), a Windows executable that is a `.cmd`, and Vim and Neovim. `DiffEngine_WslWindowsTools=false` turns it off. None of it can run in CI: `WslHostTests` stand a temp directory in for the drive, and the launch itself was run by hand in an Ubuntu distribution under WSL 2, with Beyond Compare, WinMerge and P4Merge
- Inside WSL, which `BuildServerDetector.IsWsl` reports and `Detected` does not count as a build server, a tool with no copy in the distribution is resolved from its Windows definition and started through WSL's interop (`Wsl/`: `WslInterop`, `WslHost`, `WslPaths`; `OsSettingsResolver.TryFindOnHost`). The host's variables come from one `cmd.exe /u /c set`, its drives from `/proc/mounts` and the name of the distribution's share from one `wslpath -w /`. Paths are translated in process, not by `wslpath` per path, because `ResolvedTool.GetArguments` runs for every failing pair, opened or not. A program on the host's PATH is found from one listing of each host directory on it (`WslHost.TryFindOnPath`), a stat on a Windows drive from inside WSL being most of a millisecond. What this side holds for such a tool is WSL's `/init` standing in for it: `ps` lists it as `/init <exe> <exe> <args>` (`WslInterop.StripProxy`), ending it does not close a windowed tool, and it ends with the terminal session while the window stays. So `ResolvedTool.CanKill` is false, which is what anyone handed the process id is told, and the library closes the tool itself on the host: `WslInterop.Kill` has Windows PowerShell end the processes of the tool's image whose command line names both files. That is a third of a second, so it is only asked once the stand-in is found by its command, and a tool whose session has gone is neither refreshed nor closed. `WslKillScriptTests` runs the script on Windows, where it can be run. It is started with all three streams redirected and closed (`WslInterop.Start`), or it would hold the pipe `dotnet test` reads. Not offered: the viewer (the host's loopback is not the distribution's under WSL's default networking), a Windows executable that is a `.cmd`, and Vim and Neovim. `DiffEngine_WslWindowsTools=false` turns it off. Most of it is tested with no WSL: `WslHostTests` stand a temp directory in for the drive. What only a distribution can answer is `WslLiveTests`, skipped anywhere `WSL_DISTRO_NAME` is not set: the host read, a file opened by a Windows program through its translated path, and a stand-in tool started, found in `ps` and ended on the host. The `wsl` job in `build.yml` runs it under WSL 1 and 2 (`Vampire/setup-wsl` on a Windows runner), from the tests as built on the runner, so only the .NET runtime is installed inside and no other test class can be run there. Its stand-in is one process, Windows PowerShell: WSL's `/init` for a `cmd.exe` stays until the last program that `cmd.exe` started has gone. Real tools were run by hand in an Ubuntu distribution under WSL 2: Beyond Compare, WinMerge and P4Merge
- Tool order can be customized via `DiffEngine_ToolOrder` environment variable
- `DisabledChecker` respects `DiffEngine_Disabled` env var
- `ViewerClient` remembers a port found unowned for ten minutes (`RecheckUnownedAfter`), and the library's telling sends - settle, retire, move, delete, the first inline or diff send - skip the connect while that stands. A refused loopback connection costs two seconds on Windows (firewall stealth mode drops the reset), and a green run settles once per inline verification, which was six minutes for a class of 188 inline tests. Probes (`IsOwned`), the hosts and `InlineQueueClient` always ask and correct the memory; so does `SettleAppliedInline`, being one send per accept. Asking, on Windows, is the operating system's listener table first (`ListenerTable`, shared with `PiperClient`): no listener on the port means nobody to connect to, said without the two seconds, and a listener or a table that cannot be read leaves the connect to answer. So the first telling send of a test process, the launch gate's probe and each of its polls no longer wait to be refused. By port alone, whichever address, since the table is only believed when it says nobody is there. Not for a port that accepted a connection in the last second (`TrustOwnerFor`), because reading the table is reading every connection the machine has, and a run of settles to a live owner would pay more for each than the connect costs. The table is Windows' listeners alone, by `GetExtendedTcpTable` (`ListenerTable.Listeners`), falling back to .NET's list of every connection where that call is not there: 0.32 ms beside 3,000 connections where it was 13. What the table found empty is asked about again after a second (`RecheckUnlistedAfter`), so a tray started after a test process is found; the ten minutes is only for what a connect found. And only a refusal or an unanswered connect is remembered as nobody being there (`NobodyThere`). A viewer launch that failed gives its `MaxInstance` slot back
Expand Down
15 changes: 15 additions & 0 deletions src/DiffEngine.Tests/RequiresWslAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/// <summary>
/// Skips a test that needs a real WSL distribution around it, which is everywhere but the
/// <c>wsl</c> job in CI and a developer who works inside one.
/// <para>
/// Asked of the variable WSL sets and not of <see cref="WslInterop.Host" />. A distribution in
/// which the host cannot be read is exactly what these tests are there to report, and skipping
/// on that would report it as nothing having been asked.
/// </para>
/// </summary>
public sealed class RequiresWslAttribute() :
SkipAttribute("Not running inside a WSL distribution.")
{
public override Task<bool> ShouldSkip(TestRegisteredContext context) =>
Task.FromResult(!BuildServerDetector.IsWsl);
}
200 changes: 200 additions & 0 deletions src/DiffEngine.Tests/WslLiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
#if NET10_0
/// <summary>
/// Windows tools from inside a real WSL distribution: the host read, a tool found on it, started
/// through WSL, found again and closed.
/// <para>
/// Everything else about WSL is tested without one (<see cref="WslHostTests" />,
/// <see cref="WslPathsTests" />, <see cref="WslKillScriptTests" />). These are the parts that
/// only a distribution can answer: what its mount table and <c>ps</c> really say, and whether a
/// program on the host can be started, handed a file, and ended. They run in the <c>wsl</c> job,
/// under WSL 1 and WSL 2, and for anyone running the suite inside a distribution.
/// </para>
/// <para>
/// No diff tool is started. Programs every host has stand in: <c>cmd.exe</c> to read a file,
/// and Windows PowerShell as the tool that is handed two files and then waits.
/// </para>
/// </summary>
[NotInParallel]
[RequiresWsl]
public class WslLiveTests :
IDisposable
{
readonly string directory = Path.Combine(Path.GetTempPath(), $"WslLiveTests_{Guid.NewGuid():N}");

public WslLiveTests() =>
Directory.CreateDirectory(directory);

public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, true);
}
}

static WslHost Host =>
WslInterop.Host ?? throw new("Inside WSL, but the host could not be read.");

/// <summary>
/// <c>cmd.exe</c> as this process sees it, found the way a tool is: by a Windows directory
/// written with one of the host's variables.
/// </summary>
static string Cmd()
{
var settings = new OsSettings("cmd.exe", new((_, _) => "", (_, _) => ""), @"%SystemRoot%\System32\");
if (!OsSettingsResolver.TryFindOnHost(Host, settings, out var path))
{
throw new(@"cmd.exe was not found in %SystemRoot%\System32.");
}

return path;
}

[Test]
public async Task TheHostIsReadAndAProgramFoundOnIt()
{
var cmd = Cmd();

await Assert.That(File.Exists(cmd)).IsTrue();
await Assert.That(WslInterop.IsWindowsProgram(cmd)).IsTrue();
await Assert.That(Host.TryFindPowerShell(out _)).IsTrue();
}

/// <summary>
/// The translation is only right if a program on the host can open the file by it. One file
/// in the distribution, which the host reaches as a share, and one on a drive, where the
/// tests themselves are when the checkout is a Windows one.
/// </summary>
[Test]
public async Task AWindowsProgramReadsAFileByItsTranslatedPath()
{
var cmd = Cmd();
var inDistribution = Path.Combine(directory, "in the distribution.txt");
await File.WriteAllTextAsync(inDistribution, "from the distribution");

await Assert.That(Type(cmd, inDistribution)).IsEqualTo("from the distribution");

var onDrive = Path.Combine(Path.GetDirectoryName(cmd)!, "drivers", "etc", "hosts");
await Assert.That(Host.Paths.IsOnHost(onDrive)).IsTrue();
await Assert.That(Type(cmd, onDrive)).IsNotNull();
}

/// <summary>
/// What <c>ps</c> lists for the stand-in is WSL's own program with the tool's command line
/// behind it. The tool is found by the command it was started with, and ended on the host,
/// which takes the stand-in with it.
/// </summary>
[Test]
public async Task AStartedToolIsFoundByItsCommandAndEndedOnTheHost()
{
var temp = Path.Combine(directory, "Tests.Method.received.txt");
var target = Path.Combine(directory, "Tests.Method.verified.txt");
await File.WriteAllTextAsync(temp, "received");
await File.WriteAllTextAsync(target, "verified");
var tool = StandIn();
tool.CommandAndArguments(temp, target, out var arguments, out var command);

var processId = DiffRunner.LaunchProcess(tool, arguments);
try
{
ProcessCleanup.Track(command, processId);
var listed = await Listed(processId);

await Assert.That(listed).IsEqualTo(command.Replace("\"", ""));
await Assert.That(ProcessCleanup.IsRunning(command)).IsTrue();

await Assert.That(WslInterop.Kill(tool, temp, target)).IsTrue();
await Assert.That(await Gone(processId)).IsTrue();
}
finally
{
LinuxOsxProcess.TryTerminateProcess(processId);
}
}

/// <summary>
/// A pair nothing was opened for costs nothing on the host: with no stand-in to find, the
/// host is not asked.
/// </summary>
[Test]
public async Task APairWithNoToolOpenIsNotLookedForOnTheHost()
{
var temp = Path.Combine(directory, "Tests.Other.received.txt");
var target = Path.Combine(directory, "Tests.Other.verified.txt");

await Assert.That(WslInterop.Kill(StandIn(), temp, target)).IsFalse();
}

/// <summary>
/// A Windows PowerShell that carries both paths on its command line, as a comment, and then
/// waits for twenty seconds saying nothing: what a diff tool looks like to everything on this
/// side.
/// <para>
/// One process, as a diff tool is. A <c>cmd.exe</c> running <c>ping</c> to pass the time was
/// tried first, and WSL's stand-in for it stayed until the <c>ping</c> it had started was
/// done, long after the <c>cmd.exe</c> had been ended.
/// </para>
/// </summary>
static ResolvedTool StandIn()
{
static string Arguments(string temp, string target) =>
$"-NoProfile -NonInteractive -Command \"Start-Sleep 20 # {temp} {target}\"";

if (!Host.TryFindPowerShell(out var powerShell))
{
throw new("Windows PowerShell was not found on the host.");
}

return new(
"WslLiveTestsStandIn",
powerShell,
new(Arguments, Arguments),
isMdi: false,
autoRefresh: false,
binaryExtensions: [],
requiresTarget: false,
supportsText: true,
useShellExecute: false);
}

/// <summary>
/// What <c>cmd.exe</c> on the host prints for a file given by its translated path.
/// </summary>
static string? Type(string cmd, string file) =>
WslHost.Run(cmd, $"/c type \"{WslInterop.ToWindows(file)}\"", Path.GetDirectoryName(cmd)!, Encoding.UTF8)?.Trim();

/// <summary>
/// The command <c>ps</c> has for a process, once it is there to be listed.
/// </summary>
static async Task<string?> Listed(int processId)
{
for (var attempt = 0; attempt < 50; attempt++)
{
var found = ProcessCleanup.FindAll().Where(_ => _.Process == processId).ToList();
if (found.Count == 1)
{
return found[0].Command;
}

await Task.Delay(100);
}

return null;
}

static async Task<bool> Gone(int processId)
{
for (var attempt = 0; attempt < 100; attempt++)
{
if (ProcessCleanup.FindAll().All(_ => _.Process != processId))
{
return true;
}

await Task.Delay(100);
}

return false;
}
}
#endif
Loading