feat(launcher): prepare Campaign LA11 user gate

This commit is contained in:
Erik 2026-08-14 23:09:08 +02:00
parent 09d84387a8
commit f881e5b467
24 changed files with 3538 additions and 58 deletions

View file

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,54 @@
using System.Text.Json;
if (args.Length < 4
|| args[0] != "wait-for-break"
|| string.IsNullOrWhiteSpace(args[1])
|| string.IsNullOrWhiteSpace(args[2]))
{
return 64;
}
string readyPath = Path.GetFullPath(args[1]);
string breakPath = Path.GetFullPath(args[2]);
string label = args[3];
string[] payloadArguments = args.Skip(4).ToArray();
using var stopped = new ManualResetEventSlim(false);
ConsoleCancelEventHandler handler = (_, eventArgs) =>
{
if (eventArgs.SpecialKey != ConsoleSpecialKey.ControlBreak)
{
return;
}
eventArgs.Cancel = true;
try
{
File.WriteAllText(breakPath, label);
}
finally
{
stopped.Set();
}
};
Console.CancelKeyPress += handler;
try
{
string stdin = Console.In.ReadToEnd();
Directory.CreateDirectory(Path.GetDirectoryName(readyPath)!);
File.WriteAllText(
readyPath,
JsonSerializer.Serialize(new
{
processId = Environment.ProcessId,
label,
arguments = payloadArguments,
stdinLength = stdin.Length,
stdinLineCount = stdin.Count(character => character == '\n'),
}));
return stopped.Wait(TimeSpan.FromSeconds(30)) ? 0 : 75;
}
finally
{
Console.CancelKeyPress -= handler;
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,135 @@
using System.Runtime.InteropServices;
using System.Text.Json;
using AcDream.Launcher.Core.Launching;
if (args.Length != 3)
{
return 64;
}
string resultPath = Path.GetFullPath(args[0]);
string dotnetPath = args[1];
string childAssembly = Path.GetFullPath(args[2]);
string root = Path.Combine(
Path.GetDirectoryName(resultPath)!,
"consoleless-children");
Directory.CreateDirectory(root);
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");
bool parentHadConsoleBefore = HasConsole();
using var target = new LauncherProcessSupervisor();
using var sibling = new LauncherProcessSupervisor();
try
{
target.Start(
Spec(dotnetPath, childAssembly, targetReady, targetBreak, "target"),
"stdin-from-consoleless-parent");
sibling.Start(
Spec(dotnetPath, childAssembly, siblingReady, siblingBreak, "sibling"),
password: null);
WaitForFile(targetReady, target);
WaitForFile(siblingReady, sibling);
bool parentHadConsoleAfterStarts = HasConsole();
target.Stop(TimeSpan.FromSeconds(10));
bool siblingUnaffected = sibling.State != LauncherSessionState.Exited
&& !File.Exists(siblingBreak);
sibling.Stop(TimeSpan.FromSeconds(10));
Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!);
File.WriteAllText(
resultPath,
JsonSerializer.Serialize(new
{
parentHadConsoleBefore,
parentHadConsoleAfterStarts,
targetExitCode = target.ExitCode,
siblingExitCode = sibling.ExitCode,
targetBreakObserved = File.Exists(targetBreak),
siblingBreakObserved = File.Exists(siblingBreak),
siblingUnaffected,
}));
return 0;
}
catch (Exception error)
{
Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!);
File.WriteAllText(
resultPath,
JsonSerializer.Serialize(new
{
parentHadConsoleBefore,
error = error.GetType().Name + ": " + error.Message,
}));
return 1;
}
finally
{
ForceStop(target);
ForceStop(sibling);
}
static LauncherProcessSpec Spec(
string dotnetPath,
string childAssembly,
string ready,
string breakMarker,
string label) =>
new(
dotnetPath,
[
childAssembly,
"wait-for-break",
ready,
breakMarker,
label,
"argument with spaces",
]);
static void WaitForFile(string path, LauncherProcessSupervisor supervisor)
{
DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (!File.Exists(path))
{
if (supervisor.State == LauncherSessionState.Exited)
{
throw new InvalidOperationException(
$"Child exited early with {supervisor.ExitCode}.");
}
if (DateTime.UtcNow >= deadline)
{
throw new TimeoutException("Child did not become ready.");
}
Thread.Sleep(20);
}
}
static void ForceStop(LauncherProcessSupervisor supervisor)
{
try
{
supervisor.Stop(TimeSpan.Zero);
}
catch
{
}
}
static bool HasConsole()
{
uint[] processes = new uint[1];
return Native.GetConsoleProcessList(processes, 1) != 0;
}
internal static partial class Native
{
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial uint GetConsoleProcessList(
[Out] uint[] processList,
uint processCount);
}

View file

@ -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>

View file

@ -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,

View file

@ -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]

View file

@ -0,0 +1,182 @@
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
namespace AcDream.Launcher.Tests;
public sealed class LauncherStartupOptionsTests
{
[Fact]
public void ExplicitIsolationRootsAreNormalizedAndNeverResolveCanonicalPaths()
{
string root = Path.Combine(Path.GetTempPath(), "acdream-la11 options", "..", "isolation");
string config = Path.Combine(root, "config") + Path.DirectorySeparatorChar;
string data = Path.Combine(root, "data", ".", "state");
string cache = Path.Combine(root, "cache") + Path.DirectorySeparatorChar;
bool defaultResolverCalled = false;
LauncherStartupOptions options = LauncherStartupOptions.Parse(
[
"--config-dir", config,
"--data-dir", data,
"--cache-dir", cache,
"--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
],
() =>
{
defaultResolverCalled = true;
throw new InvalidOperationException("canonical path resolver was touched");
});
Assert.False(defaultResolverCalled);
Assert.Equal(
Path.TrimEndingDirectorySeparator(Path.GetFullPath(config)),
options.Paths.ConfigDirectory);
Assert.Equal(
Path.TrimEndingDirectorySeparator(Path.GetFullPath(data)),
options.Paths.DataDirectory);
Assert.Equal(
Path.TrimEndingDirectorySeparator(Path.GetFullPath(cache)),
options.Paths.CacheDirectory);
Assert.Null(options.Paths.LegacyConfigDirectory);
Assert.Equal(
"http://127.0.0.1:43119/manifest.json",
options.UpdateManifestUri.AbsoluteUri);
Assert.Equal(LauncherStartupMode.Desktop, options.Mode);
}
[Fact]
public void NoOverridesResolveDefaultsExactlyOnce()
{
var expected = new ApplicationPathSet("config", "data", "cache", "legacy");
int calls = 0;
LauncherStartupOptions options = LauncherStartupOptions.Parse(
[],
() =>
{
calls++;
return expected;
});
Assert.Same(expected, options.Paths);
Assert.Equal(1, calls);
Assert.Equal(ReleaseManifestClient.ProductionManifestUri, options.UpdateManifestUri);
}
[Fact]
public void VerifyPublishIsExclusiveAndDoesNotResolvePaths()
{
int calls = 0;
LauncherStartupOptions options = LauncherStartupOptions.Parse(
["--verify-publish"],
() =>
{
calls++;
throw new InvalidOperationException();
});
Assert.Equal(LauncherStartupMode.VerifyPublish, options.Mode);
Assert.Equal(0, calls);
Assert.Throws<LauncherStartupOptionsException>(() =>
LauncherStartupOptions.Parse(
["--verify-publish", "--cache-dir", Path.GetTempPath()]));
}
[Theory]
[MemberData(nameof(InvalidArguments))]
public void RejectsInvalidPublicArguments(string[] arguments)
{
Assert.Throws<LauncherStartupOptionsException>(() =>
LauncherStartupOptions.Parse(
arguments,
() => new ApplicationPathSet("c", "d", "x", null)));
}
[Theory]
[InlineData("https://updates.example.test/manifest.json")]
[InlineData("http://localhost:8123/manifest.json")]
[InlineData("http://[::1]:8123/manifest.json")]
public void AcceptsHttpsAndLoopbackHttpFeeds(string value)
{
LauncherStartupOptions options = LauncherStartupOptions.Parse(
["--update-manifest-uri", value],
() => new ApplicationPathSet("c", "d", "x", null));
Assert.Equal(new Uri(value), options.UpdateManifestUri);
}
[Fact]
public void SelfUpdatePrefixesRetainOnlyTheValidatedPublicSuffix()
{
string root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11-self"));
string[] suffix =
[
"--config-dir", Path.Combine(root, "config"),
"--data-dir", Path.Combine(root, "data"),
"--cache-dir", Path.Combine(root, "cache"),
"--update-manifest-uri", "http://localhost:8123/manifest.json",
];
string[] helper =
[
LauncherSelfUpdateBootstrap.HelperArgument,
"123",
root,
"0123456789abcdef0123456789abcdef",
.. suffix,
];
string[] confirmation =
[
LauncherSelfUpdateBootstrap.ConfirmArgument,
"0123456789abcdef0123456789abcdef",
.. suffix,
];
LauncherStartupOptions helperOptions = LauncherStartupOptions.Parse(helper);
LauncherStartupOptions confirmationOptions =
LauncherStartupOptions.Parse(confirmation);
Assert.Equal(LauncherStartupMode.SelfUpdateHelper, helperOptions.Mode);
Assert.Equal(
LauncherStartupMode.SelfUpdateConfirmation,
confirmationOptions.Mode);
Assert.Equal(suffix, helperOptions.PublicArguments);
Assert.Equal(suffix, confirmationOptions.PublicArguments);
Assert.Equal(helperOptions.Paths, confirmationOptions.Paths);
Assert.Equal(helperOptions.UpdateManifestUri, confirmationOptions.UpdateManifestUri);
}
public static TheoryData<string[]> InvalidArguments()
{
string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11"));
var data = new TheoryData<string[]>();
data.Add(["--unknown", "value"]);
data.Add(["--config-dir"]);
data.Add(["--config-dir", "relative"]);
data.Add(["--config-dir", absolute]);
data.Add(
[
"--config-dir", absolute,
"--data-dir", absolute,
]);
data.Add(
[
"--config-dir", absolute,
"--data-dir", absolute,
"--cache-dir", absolute,
"--cache-dir", absolute,
]);
data.Add(
["--update-manifest-uri", "http://updates.example.test/manifest.json"]);
data.Add(["--update-manifest-uri", "file:///tmp/manifest.json"]);
data.Add(
["--update-manifest-uri", "https://user:secret@example.test/manifest.json"]);
data.Add(["--update-manifest-uri", "not-a-uri"]);
data.Add(
[
"--update-manifest-uri", "https://example.test/a",
"--update-manifest-uri", "https://example.test/b",
]);
return data;
}
}