feat(launcher): prepare Campaign LA11 user gate
This commit is contained in:
parent
09d84387a8
commit
f881e5b467
24 changed files with 3538 additions and 58 deletions
|
|
@ -25,5 +25,17 @@
|
|||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
<!-- Build ordering only. The Windows CTRL_BREAK gate launches this
|
||||
console fixture in two independent native process groups. -->
|
||||
<ProjectReference Include="..\AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild\AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
<!-- Build ordering only. This WinExe fixture starts with no console and
|
||||
proves the production Avalonia-parent CTRL_BREAK path. -->
|
||||
<ProjectReference Include="..\AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent\AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
|
||||
|
|
@ -6,6 +8,27 @@ namespace AcDream.Launcher.Core.Tests.Launching;
|
|||
|
||||
public sealed class LauncherProcessSupervisorTests
|
||||
{
|
||||
[Fact]
|
||||
public void WindowsFactoryUsesNativeProcessGroupsOnlyForConsoleChildren()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var factory = new SystemChildProcessFactory();
|
||||
using ILauncherChildProcess console = factory.Create(
|
||||
new LauncherProcessSpec("headless.exe", []));
|
||||
using ILauncherChildProcess graphical = factory.Create(
|
||||
new LauncherProcessSpec(
|
||||
"graphical.exe",
|
||||
[],
|
||||
SupportsConsoleGracefulStop: false));
|
||||
|
||||
Assert.IsType<WindowsSystemChildProcess>(console);
|
||||
Assert.IsType<SystemChildProcess>(graphical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartWritesPasswordThenClosesStdinAndTransitionsToRunning()
|
||||
{
|
||||
|
|
@ -197,6 +220,158 @@ public sealed class LauncherProcessSupervisorTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WindowsCtrlBreakStopsOnlyTheTargetProcessGroupWithoutKill()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-la11-ctrl-break",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var targetFactory = new RecordingRealChildProcessFactory();
|
||||
var siblingFactory = new RecordingRealChildProcessFactory();
|
||||
using var target = new LauncherProcessSupervisor(targetFactory);
|
||||
using var sibling = new LauncherProcessSupervisor(siblingFactory);
|
||||
string targetReady = Path.Combine(root, "target.ready.json");
|
||||
string targetBreak = Path.Combine(root, "target.break");
|
||||
string siblingReady = Path.Combine(root, "sibling.ready.json");
|
||||
string siblingBreak = Path.Combine(root, "sibling.break");
|
||||
string[] exactArguments =
|
||||
[
|
||||
"plain",
|
||||
"contains spaces",
|
||||
"quoted-\"value",
|
||||
"ends-with-backslash\\",
|
||||
string.Empty,
|
||||
];
|
||||
|
||||
try
|
||||
{
|
||||
target.Start(
|
||||
ConsoleFixtureSpec(
|
||||
targetReady,
|
||||
targetBreak,
|
||||
"target",
|
||||
exactArguments),
|
||||
"fixture-input");
|
||||
sibling.Start(
|
||||
ConsoleFixtureSpec(
|
||||
siblingReady,
|
||||
siblingBreak,
|
||||
"sibling",
|
||||
["sibling"]),
|
||||
password: null);
|
||||
await WaitForFileAsync(targetReady, targetFactory.LastCreated!);
|
||||
await WaitForFileAsync(siblingReady, siblingFactory.LastCreated!);
|
||||
|
||||
using (JsonDocument ready = JsonDocument.Parse(
|
||||
await File.ReadAllTextAsync(targetReady)))
|
||||
{
|
||||
string[] observed = ready.RootElement
|
||||
.GetProperty("arguments")
|
||||
.EnumerateArray()
|
||||
.Select(value => value.GetString()!)
|
||||
.ToArray();
|
||||
Assert.Equal(exactArguments, observed);
|
||||
Assert.Equal(
|
||||
"fixture-input\n".Length,
|
||||
ready.RootElement.GetProperty("stdinLength").GetInt32());
|
||||
Assert.Equal(
|
||||
1,
|
||||
ready.RootElement.GetProperty("stdinLineCount").GetInt32());
|
||||
}
|
||||
|
||||
target.Stop(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal(0, target.ExitCode);
|
||||
Assert.True(File.Exists(targetBreak),
|
||||
"the target fixture did not observe CTRL_BREAK");
|
||||
Assert.Equal(0, targetFactory.LastCreated!.KillCallCount);
|
||||
Assert.False(siblingFactory.LastCreated!.HasExited);
|
||||
Assert.False(File.Exists(siblingBreak),
|
||||
"CTRL_BREAK spilled into the sibling process group");
|
||||
|
||||
sibling.Stop(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal(0, sibling.ExitCode);
|
||||
Assert.True(File.Exists(siblingBreak));
|
||||
Assert.Equal(0, siblingFactory.LastCreated!.KillCallCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
targetFactory.LastCreated?.ForceCleanup();
|
||||
siblingFactory.LastCreated?.ForceCleanup();
|
||||
try
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WindowsConsolelessParentStillTargetsDistinctChildProcessGroups()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-la11-consoleless-parent",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
string resultPath = Path.Combine(root, "result.json");
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = GetConsolelessParentFixturePath(),
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add(resultPath);
|
||||
startInfo.ArgumentList.Add(FindDotnetExecutable());
|
||||
startInfo.ArgumentList.Add(GetConsoleFixturePath());
|
||||
|
||||
try
|
||||
{
|
||||
using Process process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException(
|
||||
"The consoleless supervisor fixture did not start.");
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(40));
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
|
||||
Assert.True(File.Exists(resultPath),
|
||||
"the consoleless supervisor fixture did not write its result");
|
||||
using JsonDocument result = JsonDocument.Parse(
|
||||
await File.ReadAllTextAsync(resultPath));
|
||||
Assert.Equal(0, process.ExitCode);
|
||||
Assert.False(result.RootElement.GetProperty("parentHadConsoleBefore").GetBoolean());
|
||||
Assert.False(result.RootElement.GetProperty("parentHadConsoleAfterStarts").GetBoolean());
|
||||
Assert.Equal(0, result.RootElement.GetProperty("targetExitCode").GetInt32());
|
||||
Assert.Equal(0, result.RootElement.GetProperty("siblingExitCode").GetInt32());
|
||||
Assert.True(result.RootElement.GetProperty("targetBreakObserved").GetBoolean());
|
||||
Assert.True(result.RootElement.GetProperty("siblingBreakObserved").GetBoolean());
|
||||
Assert.True(result.RootElement.GetProperty("siblingUnaffected").GetBoolean());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartKillsAndDisposesTheChildWhenFeedingStdinThrowsAfterTheProcessHasStarted()
|
||||
{
|
||||
|
|
@ -371,6 +546,22 @@ public sealed class LauncherProcessSupervisorTests
|
|||
private static LauncherProcessSpec Spec() =>
|
||||
new("fake-host", ["--session-config", "session.json"]);
|
||||
|
||||
private static LauncherProcessSpec ConsoleFixtureSpec(
|
||||
string ready,
|
||||
string breakMarker,
|
||||
string label,
|
||||
IReadOnlyList<string> exactArguments) =>
|
||||
new(
|
||||
FindDotnetExecutable(),
|
||||
[
|
||||
GetConsoleFixturePath(),
|
||||
"wait-for-break",
|
||||
ready,
|
||||
breakMarker,
|
||||
label,
|
||||
.. exactArguments,
|
||||
]);
|
||||
|
||||
private static string FindDotnetExecutable() =>
|
||||
// PATH-based resolution: .NET Core's Process.Start searches PATH
|
||||
// for a bare filename when UseShellExecute is false, on both
|
||||
|
|
@ -378,6 +569,133 @@ public sealed class LauncherProcessSupervisorTests
|
|||
// because this test is itself running under `dotnet test`.
|
||||
OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet";
|
||||
|
||||
private static string GetConsoleFixturePath()
|
||||
{
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent?.Name ?? "Release";
|
||||
return Path.Combine(
|
||||
FindRepositoryRoot(),
|
||||
"tests",
|
||||
"AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild",
|
||||
"bin",
|
||||
configuration,
|
||||
"net10.0",
|
||||
"AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.dll");
|
||||
}
|
||||
|
||||
private static string GetConsolelessParentFixturePath()
|
||||
{
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent?.Name ?? "Release";
|
||||
return Path.Combine(
|
||||
FindRepositoryRoot(),
|
||||
"tests",
|
||||
"AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent",
|
||||
"bin",
|
||||
configuration,
|
||||
"net10.0",
|
||||
"AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.exe");
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
for (var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
directory is not null;
|
||||
directory = directory.Parent)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Repository root was not found.");
|
||||
}
|
||||
|
||||
private static async Task WaitForFileAsync(
|
||||
string path,
|
||||
RecordingChildProcess child)
|
||||
{
|
||||
DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10);
|
||||
while (!File.Exists(path))
|
||||
{
|
||||
if (child.HasExited)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Console fixture exited early with {child.ExitCode}.");
|
||||
}
|
||||
|
||||
if (DateTimeOffset.UtcNow >= deadline)
|
||||
{
|
||||
throw new TimeoutException("Console fixture did not become ready.");
|
||||
}
|
||||
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingRealChildProcessFactory : ILauncherChildProcessFactory
|
||||
{
|
||||
private readonly SystemChildProcessFactory _inner = new();
|
||||
|
||||
internal RecordingChildProcess? LastCreated { get; private set; }
|
||||
|
||||
public ILauncherChildProcess Create(LauncherProcessSpec spec)
|
||||
{
|
||||
LastCreated = new RecordingChildProcess(_inner.Create(spec));
|
||||
return LastCreated;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingChildProcess(ILauncherChildProcess inner)
|
||||
: ILauncherChildProcess
|
||||
{
|
||||
public int KillCallCount { get; private set; }
|
||||
|
||||
public bool HasExited => inner.HasExited;
|
||||
|
||||
public int ExitCode => inner.ExitCode;
|
||||
|
||||
public TextWriter StandardInput => inner.StandardInput;
|
||||
|
||||
public event EventHandler? Exited
|
||||
{
|
||||
add => inner.Exited += value;
|
||||
remove => inner.Exited -= value;
|
||||
}
|
||||
|
||||
public void Start() => inner.Start();
|
||||
|
||||
public bool TryRequestGracefulStop() => inner.TryRequestGracefulStop();
|
||||
|
||||
public bool CloseMainWindow() => inner.CloseMainWindow();
|
||||
|
||||
public void Kill()
|
||||
{
|
||||
KillCallCount++;
|
||||
inner.Kill();
|
||||
}
|
||||
|
||||
public bool WaitForExit(TimeSpan timeout) => inner.WaitForExit(timeout);
|
||||
|
||||
public void ForceCleanup()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!HasExited)
|
||||
{
|
||||
inner.Kill();
|
||||
_ = inner.WaitForExit(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => inner.Dispose();
|
||||
}
|
||||
|
||||
private sealed class FakeChildProcessFactory(
|
||||
bool exitsWithinStopTimeout,
|
||||
bool exitDuringStart = false,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,16 @@ public sealed class LauncherExecutableSetTests : IDisposable
|
|||
Assert.Equal(
|
||||
headless,
|
||||
set.CreateProbeSpec("session.json").ExecutablePath);
|
||||
Assert.False(
|
||||
set.CreatePlaySpec(LaunchMode.Gui, "session.json")
|
||||
.SupportsConsoleGracefulStop);
|
||||
Assert.False(
|
||||
set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json")
|
||||
.SupportsConsoleGracefulStop);
|
||||
Assert.True(
|
||||
set.CreatePlaySpec(LaunchMode.Headless, "session.json")
|
||||
.SupportsConsoleGracefulStop);
|
||||
Assert.True(set.CreateProbeSpec("session.json").SupportsConsoleGracefulStop);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue