feat(launcher): implement verified atomic updates
This commit is contained in:
parent
2198a0cc8e
commit
2d2a5b5046
34 changed files with 6755 additions and 61 deletions
299
src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs
Normal file
299
src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue