merge: Campaign LA LA11 - automated closeout review-closed
# Conflicts: # docs/plans/2026-08-14-launcher-campaign.md
This commit is contained in:
commit
d39f3098d5
39 changed files with 6097 additions and 204 deletions
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ if (!string.IsNullOrWhiteSpace(selfUpdateData)
|
|||
SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
effectiveArgs,
|
||||
manager,
|
||||
Path.GetFullPath(selfUpdateTarget),
|
||||
Path.GetFullPath(AppContext.BaseDirectory),
|
||||
Path.GetFullPath(
|
||||
Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Process path is unavailable.")));
|
||||
|
|
@ -53,6 +53,8 @@ return effectiveArgs.FirstOrDefault() switch
|
|||
"stage-self-update" => await StageSelfUpdateAsync(effectiveArgs[1..]),
|
||||
"bootstrap-probe" => await BootstrapProbeAsync(effectiveArgs[1..]),
|
||||
"canonical-probe" => CanonicalProbe(effectiveArgs[1..]),
|
||||
"hold-campaign-la-process" =>
|
||||
await HoldCampaignLaProcessAsync(effectiveArgs[1..]),
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
|
|
@ -60,7 +62,7 @@ static bool IsBootstrapInvocation(string[] arguments) =>
|
|||
arguments.Length > 0
|
||||
&& arguments[0] is LauncherSelfUpdateBootstrap.HelperArgument
|
||||
or LauncherSelfUpdateBootstrap.ConfirmArgument
|
||||
or LauncherSelfUpdateBootstrap.DeferredArgument
|
||||
or "--acdream-self-update-deferred-v1"
|
||||
or "canonical-probe";
|
||||
|
||||
static ApplicationPathSet Paths(string dataDirectory)
|
||||
|
|
@ -160,18 +162,51 @@ static async Task<int> BootstrapProbeAsync(string[] arguments)
|
|||
|
||||
static int CanonicalProbe(string[] arguments)
|
||||
{
|
||||
if (arguments.Length != 1)
|
||||
if (arguments.Length < 1)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
string suffix = arguments.Length == 1
|
||||
? string.Empty
|
||||
: Environment.NewLine
|
||||
+ string.Join(Environment.NewLine, arguments[1..]);
|
||||
File.WriteAllText(
|
||||
Path.GetFullPath(arguments[0]),
|
||||
Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
+ "|"
|
||||
+ Path.GetFullPath(
|
||||
Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Process path is unavailable.")));
|
||||
?? throw new InvalidOperationException("Process path is unavailable."))
|
||||
+ suffix);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> HoldCampaignLaProcessAsync(string[] arguments)
|
||||
{
|
||||
if (arguments.Length != 4
|
||||
|| arguments[0] is not ("--config" or "--session-config"))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
string configPath = Path.GetFullPath(arguments[1]);
|
||||
string readyPath = Path.GetFullPath(arguments[2]);
|
||||
string releasePath = Path.GetFullPath(arguments[3]);
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
readyPath,
|
||||
Environment.ProcessId.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture));
|
||||
while (!File.Exists(releasePath))
|
||||
{
|
||||
await Task.Delay(10);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -279,27 +279,244 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
|||
public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
string[] publicArguments =
|
||||
[
|
||||
"--config-dir", Path.Combine(_root, "config with spaces"),
|
||||
"--data-dir", Path.Combine(_root, "data & literal"),
|
||||
"--cache-dir", Path.Combine(_root, "cache"),
|
||||
"--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
|
||||
];
|
||||
SelfUpdateStartupResult ordinary = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["--literal", "argument with spaces & metacharacters"],
|
||||
publicArguments,
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
Assert.False(ordinary.ShouldExit);
|
||||
Assert.Equal(["--literal", "argument with spaces & metacharacters"],
|
||||
ordinary.RemainingArguments);
|
||||
Assert.Equal(publicArguments, ordinary.RemainingArguments);
|
||||
|
||||
SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["--acdream-self-update-deferred-v1", .. publicArguments],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
Assert.True(deferred.ShouldExit);
|
||||
Assert.Equal(64, deferred.ExitCode);
|
||||
Assert.Empty(deferred.RemainingArguments);
|
||||
|
||||
_ = await harness.StageAsync();
|
||||
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
[LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId],
|
||||
SelfUpdateStartupResult confirmation;
|
||||
using (UpdateSessionBarrier.ExclusiveLease helperLease =
|
||||
harness.Manager.Barrier.AcquireExclusive())
|
||||
{
|
||||
confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
[
|
||||
LauncherSelfUpdateBootstrap.ConfirmArgument,
|
||||
applied.TransactionId,
|
||||
.. publicArguments,
|
||||
],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
}
|
||||
|
||||
Assert.False(confirmation.ShouldExit);
|
||||
Assert.Equal(publicArguments, confirmation.RemainingArguments);
|
||||
Assert.True(File.Exists(harness.Manager.PendingPlanPath));
|
||||
Assert.True(harness.Manager.IsConfirmed(applied.TransactionId));
|
||||
await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target);
|
||||
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ContendedOrdinaryStartupAllowsOnlyNoPlanOrValidatedStagedPlan()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
using (UpdateSessionBarrier.SessionLease session =
|
||||
harness.Manager.Barrier.AcquireSession())
|
||||
{
|
||||
SelfUpdateStartupResult empty = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["ordinary"],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
Assert.False(empty.ShouldExit);
|
||||
}
|
||||
|
||||
_ = await harness.StageAsync();
|
||||
using (UpdateSessionBarrier.SessionLease session =
|
||||
harness.Manager.Barrier.AcquireSession())
|
||||
{
|
||||
SelfUpdateStartupResult staged = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["ordinary"],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
Assert.False(staged.ShouldExit);
|
||||
}
|
||||
|
||||
SelfUpdatePlan awaiting = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
using (UpdateSessionBarrier.SessionLease session =
|
||||
harness.Manager.Barrier.AcquireSession())
|
||||
{
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["ordinary"],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath));
|
||||
}
|
||||
|
||||
SelfUpdatePlan rolledBack = await harness.Manager
|
||||
.RollbackAwaitingConfirmationAsync(harness.Target);
|
||||
using (UpdateSessionBarrier.SessionLease session =
|
||||
harness.Manager.Barrier.AcquireSession())
|
||||
{
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["ordinary"],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath));
|
||||
}
|
||||
|
||||
await SetPlanStateAsync(harness.Manager.PendingPlanPath, "applying");
|
||||
using (UpdateSessionBarrier.SessionLease session =
|
||||
harness.Manager.Barrier.AcquireSession())
|
||||
{
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["ordinary"],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath));
|
||||
}
|
||||
|
||||
Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, awaiting.State);
|
||||
Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrdinaryStartupRecoversApplyingAndFinalizesVerifiedRollback()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
_ = await harness.StageAsync();
|
||||
_ = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
await SetPlanStateAsync(harness.Manager.PendingPlanPath, "applying");
|
||||
|
||||
SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["ordinary"],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
|
||||
Assert.False(confirmation.ShouldExit);
|
||||
Assert.Empty(confirmation.RemainingArguments);
|
||||
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
|
||||
Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
|
||||
Assert.False(result.ShouldExit);
|
||||
Assert.Equal(["ordinary"], result.RemainingArguments);
|
||||
Assert.Null(await harness.Manager.LoadPendingAsync());
|
||||
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InternalPrefixSpoofsCannotCrossPlanStateOrExecutableTrust()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
_ = await harness.StageAsync();
|
||||
SelfUpdatePlan staged = Assert.IsType<SelfUpdatePlan>(
|
||||
await harness.Manager.LoadPendingAsync());
|
||||
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
[
|
||||
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||
int.MaxValue.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
harness.Target,
|
||||
staged.TransactionId,
|
||||
],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath));
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
[LauncherSelfUpdateBootstrap.ConfirmArgument, staged.TransactionId],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath));
|
||||
|
||||
SelfUpdatePlan awaiting = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
[LauncherSelfUpdateBootstrap.ConfirmArgument, awaiting.TransactionId],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
Path.Combine(harness.Target, "spoof-launcher")));
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
[
|
||||
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||
int.MaxValue.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
harness.Target,
|
||||
awaiting.TransactionId,
|
||||
],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath));
|
||||
|
||||
SelfUpdatePlan rolledBack = await harness.Manager
|
||||
.RollbackAwaitingConfirmationAsync(harness.Target);
|
||||
foreach (string prefix in new[]
|
||||
{
|
||||
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||
LauncherSelfUpdateBootstrap.ConfirmArgument,
|
||||
})
|
||||
{
|
||||
string[] arguments = prefix == LauncherSelfUpdateBootstrap.HelperArgument
|
||||
? [prefix, int.MaxValue.ToString(), harness.Target, rolledBack.TransactionId]
|
||||
: [prefix, rolledBack.TransactionId];
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
arguments,
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath));
|
||||
}
|
||||
|
||||
SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["--acdream-self-update-deferred-v1"],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
Assert.True(deferred.ShouldExit);
|
||||
Assert.Equal(64, deferred.ExitCode);
|
||||
|
||||
await File.WriteAllTextAsync(harness.Manager.PendingPlanPath, "{ambiguous");
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
[LauncherSelfUpdateBootstrap.ConfirmArgument, rolledBack.TransactionId],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath));
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
[
|
||||
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||
int.MaxValue.ToString(),
|
||||
harness.Target,
|
||||
rolledBack.TransactionId,
|
||||
],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath));
|
||||
}
|
||||
|
||||
private static async Task SetPlanStateAsync(string path, string state)
|
||||
{
|
||||
JsonObject plan = Assert.IsType<JsonObject>(JsonNode.Parse(
|
||||
await File.ReadAllTextAsync(path)));
|
||||
plan["state"] = state;
|
||||
await File.WriteAllTextAsync(path, plan.ToJsonString());
|
||||
}
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
|
|
|
|||
|
|
@ -27,13 +27,20 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KilledAfterCanonicalReplaceCanInvokeCanonicalAndConvergeAutomatically()
|
||||
public async Task KilledApplyingPlanRecoversPriorAndContinuesCanonicalWithoutRetryLoop()
|
||||
{
|
||||
string data = Path.Combine(_root, "data");
|
||||
string target = Path.Combine(_root, "launcher");
|
||||
string ready = Path.Combine(_root, "crash.ready");
|
||||
string launched = Path.Combine(_root, "replacement.ready");
|
||||
string helperPidPath = Path.Combine(_root, "helper.pid");
|
||||
string[] processLocalSuffix =
|
||||
[
|
||||
"--config-dir", Path.Combine(_root, "isolated config"),
|
||||
"--data-dir", Path.Combine(_root, "isolated data"),
|
||||
"--cache-dir", Path.Combine(_root, "isolated cache"),
|
||||
"--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
|
||||
];
|
||||
Directory.CreateDirectory(_root);
|
||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
|
||||
|
|
@ -76,7 +83,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
};
|
||||
using Process canonical = StartProcess(
|
||||
prepared.CanonicalPath,
|
||||
["canonical-probe", launched],
|
||||
["canonical-probe", launched, .. processLocalSuffix],
|
||||
environment);
|
||||
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(0, canonical.ExitCode);
|
||||
|
|
@ -87,25 +94,35 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
"The self-update journal did not converge.");
|
||||
|
||||
Assert.Equal(
|
||||
prepared.NewCanonicalHash,
|
||||
oldHash,
|
||||
await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath));
|
||||
Assert.True(File.Exists(Path.Combine(
|
||||
Assert.False(File.Exists(Path.Combine(
|
||||
target,
|
||||
LauncherSelfUpdateManager.InstallRecordFileName)));
|
||||
Assert.False(Directory.Exists(manager.GetTransactionDirectory(
|
||||
plan.TransactionId)));
|
||||
using (UpdateSessionBarrier.ExclusiveLease cleanupLease =
|
||||
manager.Barrier.AcquireExclusive())
|
||||
{
|
||||
Assert.True(manager.CleanupOwnedResidueUnderLease(
|
||||
pending: null,
|
||||
target,
|
||||
cleanupLease));
|
||||
}
|
||||
Assert.Empty(Directory.EnumerateDirectories(
|
||||
target,
|
||||
".acdream-self-update-*",
|
||||
SearchOption.TopDirectoryOnly));
|
||||
Assert.Null(await manager.LoadPendingAsync());
|
||||
|
||||
int replacementPid = ParsePid(await File.ReadAllTextAsync(launched));
|
||||
int helperPid = int.Parse(
|
||||
await File.ReadAllTextAsync(helperPidPath),
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
string launchMarker = await File.ReadAllTextAsync(launched);
|
||||
Assert.EndsWith(
|
||||
Environment.NewLine + string.Join(Environment.NewLine, processLocalSuffix),
|
||||
launchMarker,
|
||||
StringComparison.Ordinal);
|
||||
int replacementPid = ParsePid(launchMarker);
|
||||
await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10));
|
||||
await WaitForProcessExitAsync(helperPid, TimeSpan.FromSeconds(10));
|
||||
Assert.False(File.Exists(helperPidPath));
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Assert.True(
|
||||
|
|
@ -144,13 +161,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
["canonical-probe", launched],
|
||||
BootstrapEnvironment(crashed, helperPidPath));
|
||||
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(0, canonical.ExitCode);
|
||||
await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20));
|
||||
await WaitForProcessExitAsync(
|
||||
int.Parse(
|
||||
await File.ReadAllTextAsync(helperPidPath),
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
TimeSpan.FromSeconds(20));
|
||||
Assert.NotEqual(0, canonical.ExitCode);
|
||||
Assert.False(File.Exists(helperPidPath));
|
||||
|
||||
Assert.False(File.Exists(launched));
|
||||
SelfUpdatePlan preserved = Assert.IsType<SelfUpdatePlan>(
|
||||
|
|
@ -192,13 +204,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
["canonical-probe", launched],
|
||||
BootstrapEnvironment(crashed, helperPidPath));
|
||||
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(0, canonical.ExitCode);
|
||||
await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20));
|
||||
await WaitForProcessExitAsync(
|
||||
int.Parse(
|
||||
await File.ReadAllTextAsync(helperPidPath),
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
TimeSpan.FromSeconds(20));
|
||||
Assert.NotEqual(0, canonical.ExitCode);
|
||||
Assert.False(File.Exists(helperPidPath));
|
||||
|
||||
Assert.False(File.Exists(launched));
|
||||
Assert.True(File.Exists(outsideCanonical));
|
||||
|
|
@ -285,8 +292,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
["bootstrap-probe", data, target, canonical, resultPath]);
|
||||
await startup.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal(0, startup.ExitCode);
|
||||
Assert.Equal("ordinary", await File.ReadAllTextAsync(resultPath));
|
||||
Assert.NotEqual(0, startup.ExitCode);
|
||||
Assert.False(File.Exists(resultPath));
|
||||
Assert.True(Directory.Exists(transaction));
|
||||
Assert.False(File.Exists(observer.PendingPlanPath));
|
||||
|
||||
|
|
@ -316,9 +323,9 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
string helperPid = Path.Combine(_root, "helper.pid");
|
||||
Directory.CreateDirectory(target);
|
||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||
string canonical = Path.Combine(target, LauncherName(rid));
|
||||
await File.WriteAllTextAsync(canonical, "old-launcher");
|
||||
byte[] archive = UpdateTestData.LauncherZip(rid, "new-launcher");
|
||||
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
|
||||
string canonical = prepared.CanonicalPath;
|
||||
byte[] archive = prepared.NewArchive;
|
||||
using var server = new LocalHttpFixture();
|
||||
server.Add("launcher.zip", archive);
|
||||
using var http = new HttpClient();
|
||||
|
|
@ -342,7 +349,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
[HelperPidEnvironment] = helperPid,
|
||||
};
|
||||
|
||||
using Process helper = StartFixture(
|
||||
using Process helper = StartProcess(
|
||||
manager.GetStagedLauncherPath(plan),
|
||||
[
|
||||
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||
int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
|
|
@ -353,16 +361,151 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
], environment);
|
||||
await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal(LauncherSelfUpdateBootstrap.DeferredLeaseExitCode, helper.ExitCode);
|
||||
string helperError = await helper.StandardError.ReadToEndAsync();
|
||||
string helperOutput = await helper.StandardOutput.ReadToEndAsync();
|
||||
Assert.True(
|
||||
helper.ExitCode == LauncherSelfUpdateBootstrap.UpdateLeaseBusyExitCode,
|
||||
$"helper exit {helper.ExitCode}; stdout: {helperOutput}; stderr: {helperError}");
|
||||
Assert.True(File.Exists(helperPid));
|
||||
Assert.False(File.Exists(unexpectedLaunch));
|
||||
Assert.Equal("old-launcher", await File.ReadAllTextAsync(canonical));
|
||||
Assert.NotEqual(
|
||||
prepared.NewCanonicalHash,
|
||||
await FileIntegrity.ComputeSha256HexAsync(canonical));
|
||||
SelfUpdatePlan deferred = Assert.IsType<SelfUpdatePlan>(
|
||||
await manager.LoadPendingAsync());
|
||||
Assert.Equal(SelfUpdatePlanState.Staged, deferred.State);
|
||||
Assert.Equal(plan.TransactionId, deferred.TransactionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SpoofedInternalPrefixesCannotBypassAnyDurablePlanState()
|
||||
{
|
||||
string data = Path.Combine(_root, "data");
|
||||
string target = Path.Combine(_root, "launcher");
|
||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
|
||||
using var server = new LocalHttpFixture();
|
||||
server.Add("launcher.zip", prepared.NewArchive);
|
||||
using var http = new HttpClient();
|
||||
var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http);
|
||||
_ = await manager.StageAsync(
|
||||
LauncherVersion.Parse("2.0.0"),
|
||||
rid,
|
||||
new ReleaseArtifact(
|
||||
server.UriFor("launcher.zip"),
|
||||
UpdateTestData.Sha256(prepared.NewArchive),
|
||||
prepared.NewArchive.LongLength),
|
||||
target,
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync());
|
||||
var environment = new Dictionary<string, string>
|
||||
{
|
||||
[DataEnvironment] = data,
|
||||
[TargetEnvironment] = target,
|
||||
};
|
||||
|
||||
await AssertInternalSpoofsRejectedAsync(
|
||||
prepared.CanonicalPath,
|
||||
target,
|
||||
plan.TransactionId,
|
||||
environment,
|
||||
"staged");
|
||||
|
||||
plan = await manager.ApplyPendingAsync(target);
|
||||
await SetPlanStateAsync(manager.PendingPlanPath, "applying");
|
||||
await AssertInternalSpoofsRejectedAsync(
|
||||
prepared.CanonicalPath,
|
||||
target,
|
||||
plan.TransactionId,
|
||||
environment,
|
||||
"applying");
|
||||
|
||||
plan = await manager.RecoverApplyingAsync(target);
|
||||
plan = await manager.ApplyPendingAsync(target);
|
||||
Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, plan.State);
|
||||
await AssertInternalSpoofsRejectedAsync(
|
||||
prepared.CanonicalPath,
|
||||
target,
|
||||
plan.TransactionId,
|
||||
environment,
|
||||
"awaitingConfirmation");
|
||||
|
||||
plan = await manager.RollbackAwaitingConfirmationAsync(target);
|
||||
await AssertInternalSpoofsRejectedAsync(
|
||||
prepared.CanonicalPath,
|
||||
target,
|
||||
plan.TransactionId,
|
||||
environment,
|
||||
"rolledBack");
|
||||
|
||||
await File.WriteAllTextAsync(manager.PendingPlanPath, "{ambiguous");
|
||||
await AssertInternalSpoofsRejectedAsync(
|
||||
prepared.CanonicalPath,
|
||||
target,
|
||||
plan.TransactionId,
|
||||
environment,
|
||||
"ambiguous");
|
||||
}
|
||||
|
||||
private static async Task AssertInternalSpoofsRejectedAsync(
|
||||
string canonicalPath,
|
||||
string targetDirectory,
|
||||
string transactionId,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
string state)
|
||||
{
|
||||
(string Name, string[] Arguments, int? ExactExit)[] attempts =
|
||||
[
|
||||
(
|
||||
"deferred",
|
||||
["--acdream-self-update-deferred-v1"],
|
||||
64),
|
||||
(
|
||||
"helper",
|
||||
[
|
||||
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||
int.MaxValue.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
targetDirectory,
|
||||
transactionId,
|
||||
],
|
||||
null),
|
||||
(
|
||||
"confirm",
|
||||
[LauncherSelfUpdateBootstrap.ConfirmArgument, transactionId],
|
||||
null),
|
||||
];
|
||||
|
||||
foreach ((string name, string[] arguments, int? exactExit) in attempts)
|
||||
{
|
||||
using Process process = StartProcess(canonicalPath, arguments, environment);
|
||||
await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
string stderr = await process.StandardError.ReadToEndAsync();
|
||||
if (exactExit.HasValue)
|
||||
{
|
||||
Assert.True(
|
||||
process.ExitCode == exactExit.Value,
|
||||
$"{state}/{name} exited {process.ExitCode}: {stderr}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.True(
|
||||
process.ExitCode != 0,
|
||||
$"{state}/{name} unexpectedly succeeded.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SetPlanStateAsync(string path, string state)
|
||||
{
|
||||
System.Text.Json.Nodes.JsonObject plan = Assert.IsType<
|
||||
System.Text.Json.Nodes.JsonObject>(
|
||||
System.Text.Json.Nodes.JsonNode.Parse(await File.ReadAllTextAsync(path)));
|
||||
plan["state"] = state;
|
||||
await File.WriteAllTextAsync(path, plan.ToJsonString());
|
||||
}
|
||||
|
||||
private PreparedLauncher PrepareLauncherClosure(string target, string rid)
|
||||
{
|
||||
string fixtureDirectory = GetFixtureDirectory();
|
||||
|
|
|
|||
|
|
@ -44,6 +44,26 @@ public sealed class LauncherVersionTests
|
|||
|
||||
public sealed class ReleaseManifestClientTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("https://updates.example.test/manifest.json")]
|
||||
[InlineData("http://127.0.0.1:43119/manifest.json")]
|
||||
[InlineData("http://localhost:43119/manifest.json")]
|
||||
public void LocalUpdateFeedOverrideAcceptsOnlySecureOrLoopbackFeeds(string value)
|
||||
{
|
||||
using ReleaseManifestClient source =
|
||||
ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://updates.example.test/manifest.json")]
|
||||
[InlineData("file:///tmp/manifest.json")]
|
||||
[InlineData("https://user:secret@updates.example.test/manifest.json")]
|
||||
[InlineData("https://updates.example.test/manifest.json?token=secret")]
|
||||
[InlineData("https://updates.example.test/manifest.json#fragment")]
|
||||
public void LocalUpdateFeedOverrideRejectsRemoteHttpAndCredentialLikeUris(string value) =>
|
||||
Assert.Throws<LauncherUpdateException>(() =>
|
||||
ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value)));
|
||||
|
||||
[Fact]
|
||||
public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed()
|
||||
{
|
||||
|
|
|
|||
253
tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
Normal file
253
tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyDeferredSelfUpdatePrefixIsRejectedAsUntrustedInput()
|
||||
{
|
||||
string root = Path.GetFullPath(
|
||||
Path.Combine(Path.GetTempPath(), "acdream-la11-deferred"));
|
||||
string[] suffix =
|
||||
[
|
||||
"--config-dir", Path.Combine(root, "config"),
|
||||
"--data-dir", Path.Combine(root, "data"),
|
||||
"--cache-dir", Path.Combine(root, "cache"),
|
||||
"--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
|
||||
];
|
||||
|
||||
Assert.Throws<LauncherStartupOptionsException>(() =>
|
||||
LauncherStartupOptions.Parse(
|
||||
["--acdream-self-update-deferred-v1", .. suffix],
|
||||
() => throw new InvalidOperationException(
|
||||
"canonical path resolver was touched")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AvaloniaCompositionRetainsTheExactParsedOptionsAndPathSet()
|
||||
{
|
||||
string root = Path.GetFullPath(
|
||||
Path.Combine(Path.GetTempPath(), "acdream-la11-app-composition"));
|
||||
LauncherStartupOptions options = LauncherStartupOptions.Parse(
|
||||
[
|
||||
"--config-dir", Path.Combine(root, "config"),
|
||||
"--data-dir", Path.Combine(root, "data"),
|
||||
"--cache-dir", Path.Combine(root, "cache"),
|
||||
"--update-manifest-uri", "https://updates.example.test/manifest.json",
|
||||
],
|
||||
() => throw new InvalidOperationException(
|
||||
"canonical path resolver was touched"));
|
||||
|
||||
var app = new App(options);
|
||||
|
||||
Assert.Same(options, app.StartupOptions);
|
||||
Assert.Same(options.Paths, app.StartupOptions.Paths);
|
||||
Assert.Equal(
|
||||
new Uri("https://updates.example.test/manifest.json"),
|
||||
app.StartupOptions.UpdateManifestUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompositionRejectsAnyBootstrapArgumentDrift()
|
||||
{
|
||||
var paths = new ApplicationPathSet("config", "data", "cache", null);
|
||||
LauncherStartupOptions options = LauncherStartupOptions.Parse(
|
||||
["--update-manifest-uri", "https://updates.example.test/manifest.json"],
|
||||
() => paths);
|
||||
|
||||
Program.RequireUnchangedPublicArguments(
|
||||
options,
|
||||
new SelfUpdateStartupResult(
|
||||
false,
|
||||
0,
|
||||
options.PublicArguments.ToArray()));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
Program.RequireUnchangedPublicArguments(
|
||||
options,
|
||||
new SelfUpdateStartupResult(
|
||||
false,
|
||||
0,
|
||||
["--update-manifest-uri", "https://other.example.test/manifest.json"])));
|
||||
}
|
||||
|
||||
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", "https://example.test/manifest.json?token=secret"]);
|
||||
data.Add(
|
||||
["--update-manifest-uri", "https://example.test/manifest.json#fragment"]);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -62,4 +62,44 @@ public sealed class LauncherUpdateCompositionTests : IDisposable
|
|||
() => composition.Updater.CheckAsync());
|
||||
Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://updates.example.test/manifest.json")]
|
||||
[InlineData("http://127.0.0.1:43119/manifest.json")]
|
||||
public void ProcessLocalManifestOverrideReachesOnlyUpdateComposition(string value)
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
var paths = new ApplicationPathSet(
|
||||
Path.Combine(_root, "config"),
|
||||
Path.Combine(_root, "data"),
|
||||
Path.Combine(_root, "cache"),
|
||||
null);
|
||||
var manifestUri = new Uri(value);
|
||||
|
||||
using LauncherUpdateComposition composition = LauncherUpdateComposition.Create(
|
||||
paths,
|
||||
LauncherRuntimeIdentity.DetectRid(),
|
||||
LauncherVersion.Parse("1.0.0"),
|
||||
_root,
|
||||
() => false,
|
||||
initialize: (_, _) => new ClientVersionResolution(
|
||||
ClientVersionState.Missing,
|
||||
"No client version is installed.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
updateManifestUri: manifestUri);
|
||||
|
||||
Assert.Same(manifestUri, composition.UpdateManifestUri);
|
||||
Assert.Equal(
|
||||
Path.Combine(paths.DataDirectory, "app"),
|
||||
composition.Versions.AppDirectory);
|
||||
Assert.False(File.Exists(
|
||||
Path.Combine(paths.ConfigDirectory, "launcher-profiles.json")));
|
||||
Assert.Empty(Directory.EnumerateFiles(
|
||||
_root,
|
||||
"*",
|
||||
SearchOption.AllDirectories));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue