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

@ -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();