fix(launcher): harden updater crash recovery

This commit is contained in:
Erik 2026-08-14 23:12:15 +02:00
parent 2d2a5b5046
commit 1955ca8ab5
27 changed files with 2714 additions and 544 deletions

View file

@ -5,15 +5,176 @@ using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
return args.FirstOrDefault() switch
const string SelfUpdateDataEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_DATA";
const string SelfUpdateTargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET";
const string SelfUpdateHelperPidEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID";
string[] effectiveArgs = args;
string? selfUpdateData = Environment.GetEnvironmentVariable(SelfUpdateDataEnvironment);
string? selfUpdateTarget = Environment.GetEnvironmentVariable(SelfUpdateTargetEnvironment);
if (!string.IsNullOrWhiteSpace(selfUpdateData)
&& !string.IsNullOrWhiteSpace(selfUpdateTarget)
&& IsBootstrapInvocation(effectiveArgs))
{
"hold-install-lease" => await HoldInstallLeaseAsync(args[1..]),
"hold-update-lease" => await HoldUpdateLeaseAsync(args[1..]),
"orphan-parent" => await RunOrphanParentAsync(args[1..]),
"orphan-child" => RunOrphanChild(args[1..]),
if (effectiveArgs[0] == LauncherSelfUpdateBootstrap.HelperArgument
&& Environment.GetEnvironmentVariable(SelfUpdateHelperPidEnvironment) is string helperPid
&& !string.IsNullOrWhiteSpace(helperPid))
{
File.WriteAllText(
Path.GetFullPath(helperPid),
Environment.ProcessId.ToString(
System.Globalization.CultureInfo.InvariantCulture));
}
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(Paths(selfUpdateData), http);
SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync(
effectiveArgs,
manager,
Path.GetFullPath(selfUpdateTarget),
Path.GetFullPath(
Environment.ProcessPath
?? throw new InvalidOperationException("Process path is unavailable.")));
if (startup.ShouldExit)
{
return startup.ExitCode;
}
effectiveArgs = startup.RemainingArguments;
}
return effectiveArgs.FirstOrDefault() switch
{
"hold-install-lease" => await HoldInstallLeaseAsync(effectiveArgs[1..]),
"hold-update-lease" => await HoldUpdateLeaseAsync(effectiveArgs[1..]),
"orphan-parent" => await RunOrphanParentAsync(effectiveArgs[1..]),
"orphan-child" => RunOrphanChild(effectiveArgs[1..]),
"crash-self-update" => await CrashSelfUpdateAsync(effectiveArgs[1..]),
"stage-self-update" => await StageSelfUpdateAsync(effectiveArgs[1..]),
"bootstrap-probe" => await BootstrapProbeAsync(effectiveArgs[1..]),
"canonical-probe" => CanonicalProbe(effectiveArgs[1..]),
_ => 2,
};
static bool IsBootstrapInvocation(string[] arguments) =>
arguments.Length > 0
&& arguments[0] is LauncherSelfUpdateBootstrap.HelperArgument
or LauncherSelfUpdateBootstrap.ConfirmArgument
or LauncherSelfUpdateBootstrap.DeferredArgument
or "canonical-probe";
static ApplicationPathSet Paths(string dataDirectory)
{
string data = Path.GetFullPath(dataDirectory);
return new ApplicationPathSet(
Path.Combine(data, "fixture-config"),
data,
Path.Combine(data, "fixture-cache"),
null);
}
static async Task<int> CrashSelfUpdateAsync(string[] arguments)
{
if (arguments.Length != 4)
{
return 2;
}
string dataDirectory = Path.GetFullPath(arguments[0]);
string targetDirectory = Path.GetFullPath(arguments[1]);
string readyPath = Path.GetFullPath(arguments[2]);
string canonicalName = arguments[3];
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(
Paths(dataDirectory),
http,
null,
observation =>
{
if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation
&& string.Equals(
observation.Path,
canonicalName,
StringComparison.Ordinal))
{
if (!File.Exists(Path.Combine(targetDirectory, canonicalName)))
{
throw new InvalidOperationException(
"The canonical launcher vanished at the apply boundary.");
}
File.WriteAllText(readyPath, Environment.ProcessId.ToString(
System.Globalization.CultureInfo.InvariantCulture));
Thread.Sleep(Timeout.Infinite);
}
});
using UpdateSessionBarrier.ExclusiveLease lease = manager.Barrier.AcquireExclusive();
_ = await manager.ApplyPendingAsync(targetDirectory);
return 0;
}
static async Task<int> StageSelfUpdateAsync(string[] arguments)
{
if (arguments.Length != 7
|| !long.TryParse(
arguments[6],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out long size))
{
return 2;
}
string dataDirectory = Path.GetFullPath(arguments[0]);
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(Paths(dataDirectory), http);
_ = await manager.StageAsync(
LauncherVersion.Parse(arguments[2]),
arguments[3],
new ReleaseArtifact(new Uri(arguments[4]), arguments[5], size),
Path.GetFullPath(arguments[1]),
progress: null,
CancellationToken.None);
return 0;
}
static async Task<int> BootstrapProbeAsync(string[] arguments)
{
if (arguments.Length != 4)
{
return 2;
}
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(Paths(arguments[0]), http);
SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync(
["ordinary"],
manager,
Path.GetFullPath(arguments[1]),
Path.GetFullPath(arguments[2]));
File.WriteAllText(
Path.GetFullPath(arguments[3]),
result.ShouldExit ? "exit" : string.Join("\n", result.RemainingArguments));
return result.ShouldExit ? 3 : 0;
}
static int CanonicalProbe(string[] arguments)
{
if (arguments.Length != 1)
{
return 2;
}
File.WriteAllText(
Path.GetFullPath(arguments[0]),
Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)
+ "|"
+ Path.GetFullPath(
Environment.ProcessPath
?? throw new InvalidOperationException("Process path is unavailable.")));
return 0;
}
static async Task<int> HoldUpdateLeaseAsync(string[] arguments)
{
if (arguments.Length != 4

View file

@ -55,6 +55,42 @@ public sealed class LauncherOrchestratorTests : IDisposable
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
}
[Fact]
public async Task DisposeKeepsUpdateLeaseUntilLiveChildIsObservedTerminal()
{
var supervisors = new BlockingStopSupervisorFactory();
var barrier = new UpdateSessionBarrier(_paths.DataDirectory);
LauncherOrchestrator orchestrator = CreateOrchestrator(
supervisorFactory: supervisors,
updateSessionBarrier: barrier);
try
{
_ = await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless);
BlockingStopSupervisor supervisor = Assert.Single(supervisors.Created);
Task disposal = Task.Run(orchestrator.Dispose);
Assert.True(supervisor.StopEntered.Wait(TimeSpan.FromSeconds(5)));
Assert.False(disposal.IsCompleted);
Assert.Throws<LauncherUpdateException>(barrier.AcquireExclusive);
supervisor.AllowTerminal.Set();
await disposal.WaitAsync(TimeSpan.FromSeconds(5));
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
Assert.True(supervisor.Disposed);
}
finally
{
supervisors.AllowEveryStop();
orchestrator.Dispose();
}
}
[Fact]
public void SnapshotProjectsTheFullHierarchyWithoutTheCredential()
{
@ -756,6 +792,59 @@ public sealed class LauncherOrchestratorTests : IDisposable
}
}
private sealed class BlockingStopSupervisorFactory : ILauncherProcessSupervisorFactory
{
public List<BlockingStopSupervisor> Created { get; } = [];
public ILauncherProcessSupervisor Create()
{
var supervisor = new BlockingStopSupervisor();
Created.Add(supervisor);
return supervisor;
}
public void AllowEveryStop()
{
foreach (BlockingStopSupervisor supervisor in Created)
{
supervisor.AllowTerminal.Set();
}
}
}
private sealed class BlockingStopSupervisor : ILauncherProcessSupervisor
{
public ManualResetEventSlim StopEntered { get; } = new(false);
public ManualResetEventSlim AllowTerminal { get; } = new(false);
public LauncherSessionState State { get; private set; } =
LauncherSessionState.Starting;
public int? ExitCode { get; private set; }
public bool Disposed { get; private set; }
public event EventHandler<LauncherSessionState>? StateChanged;
public void Start(LauncherProcessSpec spec, string? password)
{
State = LauncherSessionState.Running;
StateChanged?.Invoke(this, State);
}
public void Stop(TimeSpan timeout)
{
StopEntered.Set();
AllowTerminal.Wait();
State = LauncherSessionState.Exited;
ExitCode = 0;
StateChanged?.Invoke(this, State);
}
public void Dispose() => Disposed = true;
}
private sealed class QueueStatusSourceFactory : IStatusEventSourceFactory
{
public List<QueueStatusSource> Created { get; } = [];

View file

@ -177,6 +177,61 @@ public sealed class ClientVersionStoreTests : IDisposable
Path.Combine(resolution.Directory!, "AcDream.App" + ExecutableSuffix)));
}
[Fact]
public async Task LinuxTreatsNonCanonicalInstallJsonCasingAsUnrecordedContent()
{
if (!OperatingSystem.IsLinux())
{
return;
}
var store = new ClientVersionStore(_paths);
ClientVersionResolution installed = await PromoteAsync(store, "1.0.0", "linux-case");
await File.WriteAllTextAsync(
Path.Combine(installed.Directory!, "INSTALL.JSON"),
"must-not-be-hidden");
ClientVersionResolution resolution = await new ClientVersionStore(_paths)
.LoadAndRecoverAsync(_rid);
Assert.Equal(ClientVersionState.Invalid, resolution.State);
Assert.Contains("unrecorded", resolution.Status, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ExclusiveStartupReclaimsOnlyCanonicalGuidOwnedResidue()
{
var store = new ClientVersionStore(_paths);
Directory.CreateDirectory(store.AppDirectory);
string id = Guid.NewGuid().ToString("N");
string nearId = id[..31] + "g";
string exactStaging = Path.Combine(store.AppDirectory, ".client-staging-" + id);
string exactCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + id);
string exactDownload = Path.Combine(
store.AppDirectory,
".client-download-" + id + ".zip");
string nearStaging = exactStaging + "-user";
string nearCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + nearId);
string nearDownload = Path.Combine(
store.AppDirectory,
".client-download-" + id + ".zip.user");
Directory.CreateDirectory(exactStaging);
Directory.CreateDirectory(exactCorrupt);
Directory.CreateDirectory(nearStaging);
Directory.CreateDirectory(nearCorrupt);
await File.WriteAllTextAsync(exactDownload, "owned");
await File.WriteAllTextAsync(nearDownload, "preserve");
_ = await store.LoadAndRecoverAsync(_rid);
Assert.False(Directory.Exists(exactStaging));
Assert.False(Directory.Exists(exactCorrupt));
Assert.False(File.Exists(exactDownload));
Assert.True(Directory.Exists(nearStaging));
Assert.True(Directory.Exists(nearCorrupt));
Assert.True(File.Exists(nearDownload));
}
private string ExecutableSuffix => _rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;

View file

@ -1,23 +1,9 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class LauncherSelfUpdateManagerTests : IDisposable
{
private static readonly JsonSerializerOptions PlanOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
Converters =
{
new JsonStringEnumConverter<SelfUpdatePlanState>(
JsonNamingPolicy.CamelCase,
allowIntegerValues: false),
},
};
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-self-update-tests",
@ -47,6 +33,12 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
await File.WriteAllTextAsync(unrelated, "preserve");
Assert.Null(await harness.Manager.LoadPendingAsync());
using UpdateSessionBarrier.ExclusiveLease lease =
harness.Manager.Barrier.AcquireExclusive();
Assert.True(harness.Manager.CleanupOwnedResidueUnderLease(
pending: null,
harness.Target,
lease));
Assert.False(Directory.Exists(orphan));
Assert.False(File.Exists(temporary));
@ -121,49 +113,91 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
}
[Fact]
public async Task CrashDuringApplyingReplaysReverseJournalToStagedState()
public async Task PriorOwnershipRemovesObsoleteFilesAndRollbackRestoresThem()
{
using var harness = new Harness(_root);
byte[] firstArchive = UpdateTestData.CreateZip(
[
(harness.LauncherName, "launcher-v2"u8.ToArray(), 0x81ED),
("support.dat", "support-v2"u8.ToArray(), 0x81A4),
("obsolete.dll", "obsolete-v2"u8.ToArray(), 0x81A4),
]);
_ = await harness.StageAsync("2.0.0", firstArchive);
SelfUpdatePlan first = await harness.Manager.ApplyPendingAsync(harness.Target);
await harness.Manager.ConfirmAsync(
first.TransactionId,
harness.Target,
harness.LauncherPath);
await harness.Manager.CompleteConfirmedAsync(first.TransactionId, harness.Target);
string obsoletePath = Path.Combine(harness.Target, "obsolete.dll");
Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath));
byte[] secondArchive = UpdateTestData.CreateZip(
[
(harness.LauncherName, "launcher-v3"u8.ToArray(), 0x81ED),
("support.dat", "support-v3"u8.ToArray(), 0x81A4),
]);
_ = await harness.StageAsync("3.0.0", secondArchive);
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
Assert.False(File.Exists(obsoletePath));
Assert.Contains(
applied.Apply!,
entry => entry.Path == "obsolete.dll"
&& entry.Operation == SelfUpdateApplyOperation.Remove
&& entry.HadOriginal);
SelfUpdatePlan rolledBack = await harness.Manager
.RollbackAwaitingConfirmationAsync(harness.Target);
Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State);
Assert.Equal("launcher-v2", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("support-v2", await File.ReadAllTextAsync(harness.SupportPath));
Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath));
SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target);
await harness.Manager.ConfirmAsync(
retried.TransactionId,
harness.Target,
harness.LauncherPath);
await harness.Manager.CompleteConfirmedAsync(retried.TransactionId, harness.Target);
Assert.False(File.Exists(obsoletePath));
string ownership = await File.ReadAllTextAsync(Path.Combine(
harness.Target,
LauncherSelfUpdateManager.InstallRecordFileName));
Assert.DoesNotContain("obsolete.dll", ownership, StringComparison.Ordinal);
}
[Fact]
public async Task ApplyFailpointAfterCanonicalAtomicReplaceRollsBackToStagedState()
{
using var harness = new Harness(_root);
_ = await harness.StageAsync();
SelfUpdatePlan staged = Assert.IsType<SelfUpdatePlan>(
await harness.Manager.LoadPendingAsync());
SelfUpdateApplyEntry[] apply = staged.Files
.Select(file => new SelfUpdateApplyEntry(
file.Path,
File.Exists(Path.Combine(
harness.Target,
file.Path.Replace('/', Path.DirectorySeparatorChar)))))
.ToArray();
SelfUpdatePlan applying = staged with
LauncherSelfUpdateManager faulting = harness.CreateManagerWithObserver(observation =>
{
State = SelfUpdatePlanState.Applying,
Apply = apply,
};
await File.WriteAllTextAsync(
harness.Manager.PendingPlanPath,
JsonSerializer.Serialize(applying, PlanOptions));
if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation
&& string.Equals(
observation.Path,
harness.LauncherName,
StringComparison.Ordinal))
{
Assert.True(File.Exists(harness.LauncherPath));
throw new InvalidOperationException("failpoint");
}
});
SelfUpdateApplyEntry first = apply[0];
string payload = Path.Combine(
harness.Manager.GetPayloadDirectory(staged.TransactionId),
first.Path.Replace('/', Path.DirectorySeparatorChar));
string target = Path.Combine(
harness.Target,
first.Path.Replace('/', Path.DirectorySeparatorChar));
string backup = Path.Combine(
harness.Manager.GetBackupDirectory(staged.TransactionId),
first.Path.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(backup)!);
File.Move(target, backup);
File.Move(payload, target);
SelfUpdatePlan recovered = await harness.Manager.RecoverApplyingAsync(harness.Target);
InvalidOperationException failure = await Assert.ThrowsAsync<InvalidOperationException>(
() => faulting.ApplyPendingAsync(harness.Target));
SelfUpdatePlan recovered = Assert.IsType<SelfUpdatePlan>(
await harness.Manager.LoadPendingAsync());
Assert.Equal("failpoint", failure.Message);
Assert.Equal(SelfUpdatePlanState.Staged, recovered.State);
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine(
harness.Manager.GetPayloadDirectory(staged.TransactionId),
harness.Manager.GetPayloadDirectory(recovered.TransactionId),
harness.LauncherName)));
}
@ -219,8 +253,8 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
Assert.False(confirmation.ShouldExit);
Assert.Empty(confirmation.RemainingArguments);
Assert.True(harness.Manager.IsConfirmed(applied.TransactionId));
await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target);
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
}
private sealed class Harness : IDisposable
@ -229,9 +263,11 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
private readonly HttpClient _http = new();
private readonly byte[] _archive;
private readonly ReleaseArtifact _artifact;
private readonly string _root;
public Harness(string root)
{
_root = root;
Target = Path.Combine(root, "published launcher");
Directory.CreateDirectory(Target);
Rid = LauncherRuntimeIdentity.DetectRid();
@ -270,6 +306,25 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
progress: null,
CancellationToken.None);
public Task<SelfUpdateStageResult> StageAsync(string version, byte[] archive)
{
_server.Add("launcher.zip", archive);
return Manager.StageAsync(
LauncherVersion.Parse(version),
Rid,
new ReleaseArtifact(
_server.UriFor("launcher.zip"),
UpdateTestData.Sha256(archive),
archive.LongLength),
Target,
progress: null,
CancellationToken.None);
}
public LauncherSelfUpdateManager CreateManagerWithObserver(
Action<SelfUpdateApplyObservation> observer) =>
new(UpdateTestData.Paths(_root), _http, null, observer);
public void Dispose()
{
_http.Dispose();

View file

@ -0,0 +1,453 @@
using System.Diagnostics;
using AcDream.Launcher.Core.Integrity;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class LauncherSelfUpdateProcessTests : IDisposable
{
private const string FixtureBaseName =
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder";
private const string DataEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_DATA";
private const string TargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET";
private const string HelperPidEnvironment =
"ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID";
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-self-update-process-tests",
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task KilledAfterCanonicalReplaceCanInvokeCanonicalAndConvergeAutomatically()
{
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");
Directory.CreateDirectory(_root);
string rid = LauncherRuntimeIdentity.DetectRid();
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
string oldHash = await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath);
using var server = new LocalHttpFixture();
server.Add("launcher.zip", prepared.NewArchive);
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http);
SelfUpdateStageResult staged = 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());
using Process crash = StartFixture(
["crash-self-update", data, target, ready, prepared.CanonicalName]);
try
{
await WaitForFileAsync(ready, crash, TimeSpan.FromSeconds(20));
Assert.True(File.Exists(prepared.CanonicalPath));
crash.Kill(entireProcessTree: true);
await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
Assert.True(File.Exists(prepared.CanonicalPath));
string boundaryHash = await FileIntegrity.ComputeSha256HexAsync(
prepared.CanonicalPath);
Assert.Contains(boundaryHash, new[] { oldHash, prepared.NewCanonicalHash });
var environment = new Dictionary<string, string>
{
[DataEnvironment] = data,
[TargetEnvironment] = target,
[HelperPidEnvironment] = helperPidPath,
};
using Process canonical = StartProcess(
prepared.CanonicalPath,
["canonical-probe", launched],
environment);
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
Assert.Equal(0, canonical.ExitCode);
await WaitForFileAsync(launched, process: null, TimeSpan.FromSeconds(30));
await WaitUntilAsync(
() => !File.Exists(manager.PendingPlanPath),
TimeSpan.FromSeconds(30),
"The self-update journal did not converge.");
Assert.Equal(
prepared.NewCanonicalHash,
await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath));
Assert.True(File.Exists(Path.Combine(
target,
LauncherSelfUpdateManager.InstallRecordFileName)));
Assert.False(Directory.Exists(manager.GetTransactionDirectory(
plan.TransactionId)));
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);
await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10));
await WaitForProcessExitAsync(helperPid, TimeSpan.FromSeconds(10));
if (OperatingSystem.IsLinux())
{
Assert.True(
(File.GetUnixFileMode(prepared.CanonicalPath)
& (UnixFileMode.UserExecute
| UnixFileMode.GroupExecute
| UnixFileMode.OtherExecute)) != 0);
}
}
finally
{
if (!crash.HasExited)
{
crash.Kill(entireProcessTree: true);
await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
}
}
}
[Fact]
public async Task ConcurrentStartupCannotDeleteAVisibleSlowStageTransaction()
{
string data = Path.Combine(_root, "data");
string target = Path.Combine(_root, "launcher");
string resultPath = Path.Combine(_root, "startup.result");
Directory.CreateDirectory(target);
string rid = LauncherRuntimeIdentity.DetectRid();
string canonicalName = LauncherName(rid);
string canonical = Path.Combine(target, canonicalName);
await File.WriteAllTextAsync(canonical, "running launcher");
byte[] archive = UpdateTestData.LauncherZip(
rid,
new string('s', 2 * 1024 * 1024));
using var server = new LocalHttpFixture();
server.Add(
"slow-launcher.zip",
archive,
chunkSize: 4096,
chunkDelay: TimeSpan.FromMilliseconds(2));
var observer = new LauncherSelfUpdateManager(
UpdateTestData.Paths(_root),
new HttpClient());
using Process staging = StartFixture(
[
"stage-self-update",
data,
target,
"2.0.0",
rid,
server.UriFor("slow-launcher.zip").AbsoluteUri,
UpdateTestData.Sha256(archive),
archive.LongLength.ToString(System.Globalization.CultureInfo.InvariantCulture),
]);
try
{
await WaitUntilAsync(
() => Directory.Exists(observer.TransactionsDirectory)
&& Directory.EnumerateDirectories(observer.TransactionsDirectory).Any(),
TimeSpan.FromSeconds(10),
"The slow staging transaction did not become visible.",
staging);
Assert.False(File.Exists(observer.PendingPlanPath));
string transaction = Assert.Single(
Directory.EnumerateDirectories(observer.TransactionsDirectory));
using Process startup = StartFixture(
["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.True(Directory.Exists(transaction));
Assert.False(File.Exists(observer.PendingPlanPath));
await staging.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30));
Assert.Equal(0, staging.ExitCode);
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(
await observer.LoadPendingAsync());
Assert.Equal(SelfUpdatePlanState.Staged, plan.State);
Assert.True(Directory.Exists(transaction));
}
finally
{
if (!staging.HasExited)
{
staging.Kill(entireProcessTree: true);
await staging.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
}
}
}
[Fact]
public async Task HelperDefersWithoutRestartWhenSharedSessionLeaseAppears()
{
string data = Path.Combine(_root, "data");
string target = Path.Combine(_root, "launcher");
string unexpectedLaunch = Path.Combine(_root, "unexpected-launch");
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");
using var server = new LocalHttpFixture();
server.Add("launcher.zip", archive);
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(archive),
archive.LongLength),
target,
progress: null,
CancellationToken.None);
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync());
using UpdateSessionBarrier.SessionLease session = manager.Barrier.AcquireSession();
var environment = new Dictionary<string, string>
{
[DataEnvironment] = data,
[TargetEnvironment] = target,
[HelperPidEnvironment] = helperPid,
};
using Process helper = StartFixture(
[
LauncherSelfUpdateBootstrap.HelperArgument,
int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
target,
plan.TransactionId,
"canonical-probe",
unexpectedLaunch,
], environment);
await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(LauncherSelfUpdateBootstrap.DeferredLeaseExitCode, helper.ExitCode);
Assert.True(File.Exists(helperPid));
Assert.False(File.Exists(unexpectedLaunch));
Assert.Equal("old-launcher", await File.ReadAllTextAsync(canonical));
SelfUpdatePlan deferred = Assert.IsType<SelfUpdatePlan>(
await manager.LoadPendingAsync());
Assert.Equal(SelfUpdatePlanState.Staged, deferred.State);
Assert.Equal(plan.TransactionId, deferred.TransactionId);
}
private PreparedLauncher PrepareLauncherClosure(string target, string rid)
{
string fixtureDirectory = GetFixtureDirectory();
string fixtureAppHost = Path.Combine(
fixtureDirectory,
FixtureBaseName + (OperatingSystem.IsWindows() ? ".exe" : string.Empty));
Assert.True(File.Exists(fixtureAppHost), $"Missing fixture apphost: {fixtureAppHost}");
Directory.CreateDirectory(target);
string canonicalName = LauncherName(rid);
string canonicalPath = Path.Combine(target, canonicalName);
var archiveEntries = new List<(string Name, byte[] Content, int? UnixAttributes)>();
foreach (string source in Directory.EnumerateFiles(
fixtureDirectory,
"*",
SearchOption.TopDirectoryOnly))
{
string sourceName = Path.GetFileName(source);
bool isAppHost = PathsEqual(source, fixtureAppHost);
string targetName = isAppHost ? canonicalName : sourceName;
byte[] oldContent = File.ReadAllBytes(source);
byte[] newContent = oldContent;
if (isAppHost)
{
oldContent = [.. oldContent, .. "-old"u8.ToArray()];
newContent = [.. newContent, .. "-new"u8.ToArray()];
}
string targetPath = Path.Combine(target, targetName);
File.WriteAllBytes(targetPath, oldContent);
int unixAttributes = 0x81A4;
if (OperatingSystem.IsLinux())
{
UnixFileMode mode = File.GetUnixFileMode(source);
if (isAppHost)
{
mode |= UnixFileMode.UserExecute;
}
File.SetUnixFileMode(targetPath, mode);
unixAttributes = 0x8000 | (int)mode;
}
else if (isAppHost)
{
unixAttributes = 0x81ED;
}
archiveEntries.Add((targetName, newContent, unixAttributes));
}
byte[] archive = UpdateTestData.CreateZip(archiveEntries);
byte[] newCanonical = Assert.Single(
archiveEntries,
entry => entry.Name == canonicalName).Content;
return new PreparedLauncher(
canonicalName,
canonicalPath,
archive,
UpdateTestData.Sha256(newCanonical));
}
private static Process StartFixture(
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string>? environment = null) =>
StartProcess("dotnet", [GetFixtureDllPath(), .. arguments], environment);
private static Process StartProcess(
string executable,
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string>? environment = null)
{
var start = new ProcessStartInfo(executable)
{
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
CreateNoWindow = true,
};
foreach (string argument in arguments)
{
start.ArgumentList.Add(argument);
}
if (environment is not null)
{
foreach ((string name, string value) in environment)
{
start.Environment[name] = value;
}
}
return Process.Start(start)
?? throw new InvalidOperationException($"Could not start '{executable}'.");
}
private static async Task WaitForFileAsync(
string path,
Process? process,
TimeSpan timeout) =>
await WaitUntilAsync(
() => File.Exists(path),
timeout,
$"Timed out waiting for '{path}'.",
process);
private static async Task WaitUntilAsync(
Func<bool> condition,
TimeSpan timeout,
string failure,
Process? process = null)
{
DateTimeOffset deadline = DateTimeOffset.UtcNow + timeout;
while (!condition())
{
if (process?.HasExited == true)
{
throw new InvalidOperationException(
$"{failure} Process exited {process.ExitCode}. stdout: "
+ await process.StandardOutput.ReadToEndAsync()
+ " stderr: "
+ await process.StandardError.ReadToEndAsync());
}
if (DateTimeOffset.UtcNow >= deadline)
{
throw new TimeoutException(failure);
}
await Task.Delay(20);
}
}
private static int ParsePid(string marker)
{
string value = marker.Split('|', 2)[0];
return int.Parse(value, System.Globalization.CultureInfo.InvariantCulture);
}
private static async Task WaitForProcessExitAsync(int pid, TimeSpan timeout)
{
try
{
using Process process = Process.GetProcessById(pid);
await process.WaitForExitAsync().WaitAsync(timeout);
}
catch (ArgumentException)
{
// It exited before the test opened the process handle.
}
}
private static string LauncherName(string rid) =>
"acdream-launcher"
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
private static string GetFixtureDllPath() =>
Path.Combine(GetFixtureDirectory(), FixtureBaseName + ".dll");
private static string GetFixtureDirectory()
{
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent?.Name ?? "Release";
return Path.Combine(
FindRepositoryRoot(),
"tests",
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder",
"bin",
configuration,
"net10.0");
}
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 bool PathsEqual(string left, string right) => string.Equals(
Path.GetFullPath(left),
Path.GetFullPath(right),
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
private sealed record PreparedLauncher(
string CanonicalName,
string CanonicalPath,
byte[] NewArchive,
string NewCanonicalHash);
}

View file

@ -30,7 +30,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
byte[] launcher = UpdateTestData.LauncherZip(_rid, "release-2");
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
using var http = new HttpClient();
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths);
var updater = new LauncherUpdater(
source,
@ -74,7 +75,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
byte[] launcher = UpdateTestData.LauncherZip(_rid);
ConfigureRelease(server, "3.0.0", "2.0.0", _rid, client, launcher);
using var http = new HttpClient();
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths);
var updater = new LauncherUpdater(
source,
@ -108,7 +110,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
byte[] launcher = UpdateTestData.LauncherZip(_rid);
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
using var http = new HttpClient();
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths);
bool running = true;
var updater = new LauncherUpdater(
@ -156,7 +159,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
launcher),
contentType: "application/json");
using var http = new HttpClient();
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths);
var updater = new LauncherUpdater(
source,

View file

@ -1,3 +1,4 @@
using System.Net;
using System.Text;
using AcDream.Launcher.Core.Updates;
@ -63,7 +64,8 @@ public sealed class ReleaseManifestClientTests
launcher),
contentType: "application/json");
using var http = new HttpClient();
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
ReleaseManifest manifest = await source.FetchAsync();
@ -96,16 +98,132 @@ public sealed class ReleaseManifestClientTests
ValidJson().Replace("\"size\":12", "\"size\":0"),
ValidJson().Replace("\"size\":12", "\"size\":12,\"extra\":true"),
ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"2.0.0\",\"version\":\"2.0.1\""),
ValidJson().Replace("http://127.0.0.1", "http://example.test"),
ValidJson().Replace("https://example.test/client", "http://example.test/client"),
};
[Theory]
[InlineData("clients", "client")]
[InlineData("launchers", "launcher")]
public void ProductionManifestRejectsLoopbackHttpArtifacts(
string section,
string artifact)
{
string json = ValidJson().Replace(
$"https://example.test/{artifact}",
$"http://127.0.0.1/{artifact}",
StringComparison.Ordinal);
LauncherUpdateException error = Assert.Throws<LauncherUpdateException>(() =>
ReleaseManifestClient.Parse(Encoding.UTF8.GetBytes(json)));
Assert.Contains(section, error.Message, StringComparison.Ordinal);
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
}
[Fact]
public async Task ProductionRedirectToLoopbackIsRejectedBeforePlaintextRequest()
{
var handler = new SequenceHandler((request, _) => Redirect(
HttpStatusCode.Found,
new Uri("http://127.0.0.1/manifest.json")));
using var source = ReleaseManifestClient.CreateForTransportTest(
ReleaseManifestClient.ProductionManifestUri,
allowLoopbackHttp: false,
handler);
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => source.FetchAsync());
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
Assert.Equal([ReleaseManifestClient.ProductionManifestUri], handler.Requests);
}
[Fact]
public async Task HttpsRedirectDowngradeIsRejectedBeforeIntermediateHop()
{
var start = new Uri("https://example.test/start");
var handler = new SequenceHandler((request, _) => Redirect(
HttpStatusCode.TemporaryRedirect,
new Uri("http://example.test/plaintext-hop")));
using var source = ReleaseManifestClient.CreateForTransportTest(
start,
allowLoopbackHttp: false,
handler);
await Assert.ThrowsAsync<LauncherUpdateException>(() => source.FetchAsync());
Assert.Equal([start], handler.Requests);
}
[Fact]
public async Task RedirectLoopIsRejectedWithoutRepeatingARequest()
{
var first = new Uri("https://example.test/first");
var second = new Uri("https://example.test/second");
var handler = new SequenceHandler((request, _) => Redirect(
HttpStatusCode.PermanentRedirect,
request.RequestUri == first ? second : first));
using var source = ReleaseManifestClient.CreateForTransportTest(
first,
allowLoopbackHttp: false,
handler);
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => source.FetchAsync());
Assert.Contains("loop", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal([first, second], handler.Requests);
}
[Fact]
public async Task RedirectLimitRejectsBeforeRequestingTheSixthHop()
{
var start = new Uri("https://example.test/hop-0");
var handler = new SequenceHandler((_, index) => Redirect(
HttpStatusCode.Found,
new Uri($"https://example.test/hop-{index + 1}")));
using var source = ReleaseManifestClient.CreateForTransportTest(
start,
allowLoopbackHttp: false,
handler);
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => source.FetchAsync());
Assert.Contains("5 redirects", error.Message, StringComparison.Ordinal);
Assert.Equal(6, handler.Requests.Count);
Assert.Equal(new Uri("https://example.test/hop-5"), handler.Requests[^1]);
}
private static string ValidJson() =>
"{\"schemaVersion\":1,\"version\":\"2.0.0\","
+ "\"minimumLauncherVersion\":\"1.0.0\","
+ "\"clients\":{\"win-x64\":{\"url\":\"http://127.0.0.1/client\","
+ $"\"sha256\":\"{new string('a', 64)}\",\"size\":12}},"
+ "\"launchers\":{\"win-x64\":{\"url\":\"https://example.test/launcher\","
+ $"\"sha256\":\"{new string('b', 64)}\",\"size\":12}}}}";
$$$$"""
{"schemaVersion":1,"version":"2.0.0","minimumLauncherVersion":"1.0.0","clients":{"win-x64":{"url":"https://example.test/client","sha256":"{{{{new string('a', 64)}}}}","size":12}},"launchers":{"win-x64":{"url":"https://example.test/launcher","sha256":"{{{{new string('b', 64)}}}}","size":12}}}
""";
private static HttpResponseMessage Redirect(HttpStatusCode status, Uri location)
{
var response = new HttpResponseMessage(status);
response.Headers.Location = location;
return response;
}
private sealed class SequenceHandler(
Func<HttpRequestMessage, int, HttpResponseMessage> respond)
: HttpMessageHandler
{
public List<Uri> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Uri uri = request.RequestUri
?? throw new InvalidOperationException("Test request has no URI.");
int index = Requests.Count;
Requests.Add(uri);
return Task.FromResult(respond(request, index));
}
}
}
public sealed class VerifiedArtifactDownloaderTests : IDisposable
@ -211,9 +329,45 @@ public sealed class VerifiedArtifactDownloaderTests : IDisposable
Assert.Equal("preserve", await File.ReadAllTextAsync(destination));
}
[Fact]
public async Task HttpsArtifactRedirectDowngradeIsRejectedBeforePlaintextHop()
{
var handler = new RedirectHandler();
using var http = new HttpClient(handler);
var downloader = new VerifiedArtifactDownloader(http);
string destination = Path.Combine(_root, "redirect.zip");
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
downloader.DownloadAsync(
new ReleaseArtifact(
new Uri("https://example.test/artifact"),
new string('a', 64),
12),
destination));
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
Assert.Equal([new Uri("https://example.test/artifact")], handler.Requests);
Assert.False(File.Exists(destination));
}
private sealed class ImmediateProgress(Action<ArtifactDownloadProgress> callback)
: IProgress<ArtifactDownloadProgress>
{
public void Report(ArtifactDownloadProgress value) => callback(value);
}
private sealed class RedirectHandler : HttpMessageHandler
{
public List<Uri> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(request.RequestUri!);
var response = new HttpResponseMessage(HttpStatusCode.Found);
response.Headers.Location = new Uri("http://example.test/plaintext");
return Task.FromResult(response);
}
}
}

View file

@ -50,6 +50,12 @@ public sealed class SafeZipExtractorTests : IDisposable
[InlineData("a/./b")]
[InlineData("CON")]
[InlineData("aux.txt")]
[InlineData("CLOCK$/value")]
[InlineData("CONIN$.txt")]
[InlineData("CONOUT$/value")]
[InlineData("COM¹.dll")]
[InlineData("com²/value")]
[InlineData("LPT³.log")]
[InlineData("trailing.")]
[InlineData("trailing ")]
public async Task RejectsTraversalRootedAdsAndPortableUnsafeNames(string entry)
@ -66,6 +72,15 @@ public sealed class SafeZipExtractorTests : IDisposable
Assert.False(File.Exists(Path.Combine(_root, "escape")));
}
[Theory]
[InlineData("CONIN$.txt")]
[InlineData("CONOUT$/child")]
[InlineData("COM¹.dll")]
[InlineData("LPT³/child")]
[InlineData("CLOCK$")]
public void VersionMetadataUsesTheSameCompletePortableDeviceRules(string path) =>
Assert.False(ClientVersionStore.IsNormalizedRelative(path));
[Fact]
public async Task RejectsDuplicateCaseAndFileDirectoryCollisionsBeforeExtraction()
{

View file

@ -0,0 +1,65 @@
using System.Text.Json;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
namespace AcDream.Launcher.Tests;
public sealed class LauncherUpdateCompositionTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-launcher-composition-tests",
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Theory]
[InlineData("io")]
[InlineData("permission")]
[InlineData("corrupt")]
public async Task StartupStorageFailureComposesUnavailableUpdaterWithoutThrowing(
string failure)
{
Directory.CreateDirectory(_root);
var paths = new ApplicationPathSet(
Path.Combine(_root, "config"),
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
null);
Exception exception = failure switch
{
"io" => new IOException("storage offline"),
"permission" => new UnauthorizedAccessException("storage denied"),
"corrupt" => new JsonException("pointer corrupt"),
_ => throw new InvalidOperationException("Unknown fixture failure."),
};
using LauncherUpdateComposition composition = LauncherUpdateComposition.Create(
paths,
LauncherRuntimeIdentity.DetectRid(),
LauncherVersion.Parse("1.0.0"),
_root,
() => false,
(_, _) => throw exception);
Assert.Equal(ClientVersionState.Invalid, composition.Updater.CurrentClient.State);
Assert.Contains(
exception.Message,
composition.Updater.CurrentClient.Status,
StringComparison.Ordinal);
LauncherCapability capability = composition.Executables.GetAvailability(LaunchMode.Gui);
Assert.False(capability.IsAvailable);
Assert.Contains(exception.Message, capability.Reason, StringComparison.Ordinal);
LauncherUpdateException updateError = await Assert.ThrowsAsync<LauncherUpdateException>(
() => composition.Updater.CheckAsync());
Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal);
}
}