feat(launcher): implement verified atomic updates

This commit is contained in:
Erik 2026-08-14 22:09:34 +02:00
parent 2198a0cc8e
commit 2d2a5b5046
34 changed files with 6755 additions and 61 deletions

View file

@ -0,0 +1,299 @@
using System.Diagnostics;
namespace AcDream.Launcher.Core.Updates;
public sealed record SelfUpdateStartupResult(
bool ShouldExit,
int ExitCode,
string[] RemainingArguments);
/// <summary>
/// Process-level rename dance for launcher self-update. Every child argument
/// is passed through <see cref="ProcessStartInfo.ArgumentList"/> with
/// <c>UseShellExecute=false</c>; no path or PID is ever interpolated into a
/// shell command.
/// </summary>
public static class LauncherSelfUpdateBootstrap
{
public const string HelperArgument = "--acdream-self-update-helper-v1";
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
public static async Task<SelfUpdateStartupResult> HandleAsync(
string[] args,
LauncherSelfUpdateManager manager,
string launcherBaseDirectory,
string currentExecutablePath,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(manager);
string baseDirectory = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(launcherBaseDirectory));
string executable = Path.GetFullPath(currentExecutablePath);
if (args.Length > 0
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
{
if (args.Length != 4
|| !int.TryParse(
args[1],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out int parentPid)
|| parentPid <= 0)
{
return new SelfUpdateStartupResult(true, 64, []);
}
int exitCode = await RunHelperAsync(
manager,
parentPid,
args[2],
args[3],
cancellationToken)
.ConfigureAwait(false);
return new SelfUpdateStartupResult(true, exitCode, []);
}
if (args.Length > 0
&& string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal))
{
if (args.Length != 2)
{
return new SelfUpdateStartupResult(true, 64, []);
}
await manager.ConfirmAsync(
args[1],
baseDirectory,
executable,
cancellationToken)
.ConfigureAwait(false);
return new SelfUpdateStartupResult(false, 0, []);
}
SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false);
if (plan is null)
{
return new SelfUpdateStartupResult(false, 0, args);
}
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
{
throw new LauncherUpdateException(
"The pending self-update targets a different launcher directory.");
}
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
{
if (!manager.IsConfirmed(plan.TransactionId))
{
await manager.ConfirmAsync(
plan.TransactionId,
baseDirectory,
executable,
cancellationToken)
.ConfigureAwait(false);
}
await manager.CompleteConfirmedAsync(
plan.TransactionId,
baseDirectory,
cancellationToken)
.ConfigureAwait(false);
return new SelfUpdateStartupResult(false, 0, args);
}
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
string expectedExecutable = ClientVersionStore.ResolveContained(
baseDirectory,
"acdream-launcher" + suffix);
if (!PathsEqual(executable, expectedExecutable))
{
throw new LauncherUpdateException(
"Self-update can start only from the published acdream-launcher executable.");
}
string helperPath = manager.GetHelperPath(plan.TransactionId);
Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!);
VerifiedArtifactDownloader.TryDelete(helperPath);
File.Copy(executable, helperPath, overwrite: false);
if (OperatingSystem.IsLinux())
{
File.SetUnixFileMode(
helperPath,
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute);
}
var startInfo = new ProcessStartInfo(helperPath)
{
UseShellExecute = false,
WorkingDirectory = manager.GetTransactionDirectory(plan.TransactionId),
};
startInfo.ArgumentList.Add(HelperArgument);
startInfo.ArgumentList.Add(
Environment.ProcessId.ToString(
System.Globalization.CultureInfo.InvariantCulture));
startInfo.ArgumentList.Add(baseDirectory);
startInfo.ArgumentList.Add(plan.TransactionId);
_ = Process.Start(startInfo)
?? throw new LauncherUpdateException(
"The launcher self-update helper could not be started.");
return new SelfUpdateStartupResult(true, 0, []);
}
private static async Task<int> RunHelperAsync(
LauncherSelfUpdateManager manager,
int parentPid,
string targetDirectory,
string transactionId,
CancellationToken cancellationToken)
{
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("The helper found no pending self-update.");
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal))
{
throw new LauncherUpdateException(
"The helper transaction does not match the pending self-update.");
}
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
string launcherPath = ClientVersionStore.ResolveContained(
targetDirectory,
"acdream-launcher" + suffix);
var startInfo = new ProcessStartInfo(launcherPath)
{
UseShellExecute = false,
WorkingDirectory = Path.GetFullPath(targetDirectory),
};
startInfo.ArgumentList.Add(ConfirmArgument);
startInfo.ArgumentList.Add(transactionId);
Process? replacement = null;
UpdateSessionBarrier.ExclusiveLease? updateLease = null;
bool appliedByThisHelper = false;
try
{
await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false);
updateLease = manager.Barrier.AcquireExclusive();
plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken)
.ConfigureAwait(false);
appliedByThisHelper = true;
replacement = Process.Start(startInfo)
?? throw new LauncherUpdateException(
"The updated launcher could not be started.");
DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout;
while (!manager.IsConfirmed(transactionId))
{
cancellationToken.ThrowIfCancellationRequested();
if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline)
{
throw new LauncherUpdateException(
replacement.HasExited
? $"The updated launcher exited with code {replacement.ExitCode} "
+ "before confirming startup."
: "The updated launcher did not confirm startup in time.");
}
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
await manager.CompleteConfirmedAsync(
transactionId,
targetDirectory,
cancellationToken)
.ConfigureAwait(false);
return 0;
}
catch
{
if (replacement is { HasExited: false })
{
replacement.Kill(entireProcessTree: true);
await replacement.WaitForExitAsync(CancellationToken.None)
.ConfigureAwait(false);
}
try
{
if (appliedByThisHelper)
{
SelfUpdatePlan? pending = await manager.LoadPendingAsync(
CancellationToken.None)
.ConfigureAwait(false);
if (pending?.State == SelfUpdatePlanState.Applying)
{
_ = await manager.RecoverApplyingAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
}
else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation)
{
_ = await manager.RollbackAwaitingConfirmationAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
}
}
}
catch
{
// Do not start an executable from an ambiguous half-applied
// state. A subsequent startup replays the durable journal.
return 75;
}
var restored = new ProcessStartInfo(launcherPath)
{
UseShellExecute = false,
WorkingDirectory = Path.GetFullPath(targetDirectory),
};
_ = Process.Start(restored);
return 74;
}
finally
{
replacement?.Dispose();
updateLease?.Dispose();
}
}
private static async Task WaitForParentExitAsync(
int parentPid,
CancellationToken cancellationToken)
{
try
{
using Process parent = Process.GetProcessById(parentPid);
if (parent.Id == Environment.ProcessId)
{
throw new LauncherUpdateException(
"The self-update helper cannot wait on itself.");
}
await parent.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
}
catch (ArgumentException)
{
// The parent exited before the helper opened it.
}
}
private static bool PathsEqual(string left, string right) =>
string.Equals(
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
}