477 lines
19 KiB
C#
477 lines
19 KiB
C#
using System.Diagnostics;
|
|
|
|
namespace AcDream.Launcher.Core.Updates;
|
|
|
|
public sealed record SelfUpdateStartupResult(
|
|
bool ShouldExit,
|
|
int ExitCode,
|
|
string[] RemainingArguments);
|
|
|
|
/// <summary>
|
|
/// Process-level self-update bootstrap. Every child argument is passed through
|
|
/// <see cref="ProcessStartInfo.ArgumentList"/> with shell execution disabled.
|
|
/// </summary>
|
|
public static class LauncherSelfUpdateBootstrap
|
|
{
|
|
public const string HelperArgument = "--acdream-self-update-helper-v1";
|
|
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
|
|
internal const int UpdateLeaseBusyExitCode = 73;
|
|
private const string InternalArgumentPrefix = "--acdream-self-update-";
|
|
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,
|
|
baseDirectory,
|
|
executable,
|
|
parentPid,
|
|
args[2],
|
|
args[3],
|
|
args[4..],
|
|
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, []);
|
|
}
|
|
|
|
if (manager.Barrier.TryAcquireSession(
|
|
out UpdateSessionBarrier.SessionLease? unexpectedSharedLease))
|
|
{
|
|
unexpectedSharedLease?.Dispose();
|
|
throw new LauncherUpdateException(
|
|
"Self-update confirmation is trusted only while its helper owns "
|
|
+ "the exclusive update lease.");
|
|
}
|
|
|
|
await manager.ConfirmAsync(
|
|
args[1],
|
|
baseDirectory,
|
|
executable,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
// The helper that owns the exclusive lease observes this durable
|
|
// receipt and performs authoritative completion. A later ordinary
|
|
// startup also completes it if that helper crashes after receipt.
|
|
return new SelfUpdateStartupResult(false, 0, args[2..]);
|
|
}
|
|
|
|
if (args.Length > 0
|
|
&& args[0].StartsWith(InternalArgumentPrefix, StringComparison.Ordinal))
|
|
{
|
|
// Internal modes are an exact vocabulary. In particular, an old
|
|
// deferred-restart marker must never become an authorization to
|
|
// skip a pending recovery state.
|
|
return new SelfUpdateStartupResult(true, 64, []);
|
|
}
|
|
|
|
// Load first: an invalid/ambiguous journal must fail closed even when
|
|
// another process currently owns the update barrier.
|
|
_ = await manager.LoadPendingAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!manager.Barrier.TryAcquireExclusive(
|
|
out UpdateSessionBarrier.ExclusiveLease? startupLease))
|
|
{
|
|
if (!manager.Barrier.TryAcquireSession(
|
|
out UpdateSessionBarrier.SessionLease? sharedLease))
|
|
{
|
|
throw new LauncherUpdateException(
|
|
"Launcher startup is blocked by an active update or recovery transaction.");
|
|
}
|
|
|
|
using (sharedLease
|
|
?? throw new InvalidOperationException("Shared startup lease is missing."))
|
|
{
|
|
SelfUpdatePlan? blockedPlan = await manager.LoadPendingAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (blockedPlan is null)
|
|
{
|
|
return new SelfUpdateStartupResult(false, 0, args);
|
|
}
|
|
|
|
ValidateCanonicalStartup(blockedPlan, baseDirectory, executable);
|
|
if (blockedPlan.State != SelfUpdatePlanState.Staged)
|
|
{
|
|
throw new LauncherUpdateException(
|
|
$"Self-update state '{blockedPlan.State}' requires exclusive recovery.");
|
|
}
|
|
|
|
// A verified staged update may wait while an already-running
|
|
// session holds the shared lease. No helper is spawned, so a
|
|
// late session lease cannot create a restart loop.
|
|
return new SelfUpdateStartupResult(false, 0, args);
|
|
}
|
|
}
|
|
|
|
using (UpdateSessionBarrier.ExclusiveLease lease = startupLease
|
|
?? throw new InvalidOperationException("Exclusive startup lease is missing."))
|
|
{
|
|
SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
_ = manager.CleanupOwnedResidueUnderLease(
|
|
plan,
|
|
baseDirectory,
|
|
lease);
|
|
if (plan is null)
|
|
{
|
|
return new SelfUpdateStartupResult(false, 0, args);
|
|
}
|
|
|
|
ValidateCanonicalStartup(plan, baseDirectory, executable);
|
|
|
|
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);
|
|
_ = manager.CleanupOwnedResidueUnderLease(
|
|
pending: null,
|
|
baseDirectory,
|
|
lease);
|
|
return new SelfUpdateStartupResult(false, 0, args);
|
|
}
|
|
|
|
if (plan.State is SelfUpdatePlanState.Applying
|
|
or SelfUpdatePlanState.RolledBack)
|
|
{
|
|
if (plan.State == SelfUpdatePlanState.Applying)
|
|
{
|
|
plan = await manager.RecoverApplyingAsync(
|
|
baseDirectory,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
if (plan.State != SelfUpdatePlanState.RolledBack)
|
|
{
|
|
throw new LauncherUpdateException(
|
|
"The interrupted self-update did not produce a rollback receipt.");
|
|
}
|
|
|
|
await manager.CompleteRolledBackAsync(
|
|
plan.TransactionId,
|
|
baseDirectory,
|
|
lease,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
_ = manager.CleanupOwnedResidueUnderLease(
|
|
pending: null,
|
|
baseDirectory,
|
|
lease);
|
|
return new SelfUpdateStartupResult(false, 0, args);
|
|
}
|
|
|
|
if (plan.State != SelfUpdatePlanState.Staged)
|
|
{
|
|
throw new LauncherUpdateException(
|
|
$"Self-update state '{plan.State}' cannot start a helper.");
|
|
}
|
|
|
|
string helperPath = manager.GetStagedLauncherPath(plan);
|
|
var startInfo = new ProcessStartInfo(helperPath)
|
|
{
|
|
UseShellExecute = false,
|
|
WorkingDirectory = manager.GetPayloadDirectory(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);
|
|
foreach (string argument in args)
|
|
{
|
|
startInfo.ArgumentList.Add(argument);
|
|
}
|
|
|
|
_ = 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,
|
|
string helperBaseDirectory,
|
|
string currentExecutablePath,
|
|
int parentPid,
|
|
string targetDirectory,
|
|
string transactionId,
|
|
IReadOnlyList<string> publicArguments,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
|
|
.ConfigureAwait(false)
|
|
?? throw new LauncherUpdateException("The helper found no pending self-update.");
|
|
if (plan.State != SelfUpdatePlanState.Staged
|
|
|| !string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal))
|
|
{
|
|
throw new LauncherUpdateException(
|
|
"The helper mode does not match a staged self-update transaction.");
|
|
}
|
|
|
|
if (!PathsEqual(plan.TargetDirectory, targetDirectory))
|
|
{
|
|
throw new LauncherUpdateException(
|
|
"The helper target does not match the pending self-update.");
|
|
}
|
|
|
|
string expectedHelperDirectory = manager.GetPayloadDirectory(plan.TransactionId);
|
|
string expectedHelperPath = manager.GetStagedLauncherPath(plan);
|
|
if (!PathsEqual(helperBaseDirectory, expectedHelperDirectory)
|
|
|| !PathsEqual(currentExecutablePath, expectedHelperPath))
|
|
{
|
|
throw new LauncherUpdateException(
|
|
"Self-update helper mode is trusted only from the staged launcher payload.");
|
|
}
|
|
|
|
string launcherPath = ClientVersionStore.ResolveContained(
|
|
targetDirectory,
|
|
GetLauncherFileName(plan.Rid));
|
|
var startInfo = new ProcessStartInfo(launcherPath)
|
|
{
|
|
UseShellExecute = false,
|
|
WorkingDirectory = Path.GetFullPath(targetDirectory),
|
|
};
|
|
startInfo.ArgumentList.Add(ConfirmArgument);
|
|
startInfo.ArgumentList.Add(transactionId);
|
|
foreach (string argument in publicArguments)
|
|
{
|
|
startInfo.ArgumentList.Add(argument);
|
|
}
|
|
|
|
await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false);
|
|
if (!manager.Barrier.TryAcquireExclusive(
|
|
out UpdateSessionBarrier.ExclusiveLease? updateLease))
|
|
{
|
|
// Do not restart the canonical launcher: it would immediately see
|
|
// the same staged plan and create an unbounded helper loop.
|
|
return UpdateLeaseBusyExitCode;
|
|
}
|
|
|
|
ProcessStartInfo? restoredStart = null;
|
|
using (UpdateSessionBarrier.ExclusiveLease lease = updateLease
|
|
?? throw new InvalidOperationException("Exclusive update lease is missing."))
|
|
{
|
|
plan = await manager.LoadPendingAsync(cancellationToken)
|
|
.ConfigureAwait(false)
|
|
?? throw new LauncherUpdateException(
|
|
"The helper found no pending self-update after acquiring the lease.");
|
|
if (plan.State != SelfUpdatePlanState.Staged
|
|
|| !string.Equals(
|
|
plan.TransactionId,
|
|
transactionId,
|
|
StringComparison.Ordinal)
|
|
|| !PathsEqual(plan.TargetDirectory, targetDirectory))
|
|
{
|
|
throw new LauncherUpdateException(
|
|
"The pending self-update changed before the helper acquired its lease.");
|
|
}
|
|
|
|
_ = manager.CleanupOwnedResidueUnderLease(
|
|
plan,
|
|
targetDirectory,
|
|
lease);
|
|
Process? replacement = null;
|
|
try
|
|
{
|
|
plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
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
|
|
{
|
|
SelfUpdatePlan? pending = await manager.LoadPendingAsync(
|
|
CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
SelfUpdatePlan? rollbackReceipt = pending?.State switch
|
|
{
|
|
SelfUpdatePlanState.Applying =>
|
|
await manager.RecoverApplyingAsync(
|
|
targetDirectory,
|
|
CancellationToken.None)
|
|
.ConfigureAwait(false),
|
|
SelfUpdatePlanState.AwaitingConfirmation =>
|
|
await manager.RollbackAwaitingConfirmationAsync(
|
|
targetDirectory,
|
|
CancellationToken.None)
|
|
.ConfigureAwait(false),
|
|
SelfUpdatePlanState.RolledBack => pending,
|
|
_ => null,
|
|
};
|
|
if (rollbackReceipt?.State != SelfUpdatePlanState.RolledBack)
|
|
{
|
|
return 75;
|
|
}
|
|
|
|
await manager.VerifyRestoredPriorAsync(
|
|
targetDirectory,
|
|
CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
// An ambiguous state must not start either executable.
|
|
return 75;
|
|
}
|
|
|
|
restoredStart = new ProcessStartInfo(launcherPath)
|
|
{
|
|
UseShellExecute = false,
|
|
WorkingDirectory = Path.GetFullPath(targetDirectory),
|
|
};
|
|
foreach (string argument in publicArguments)
|
|
{
|
|
restoredStart.ArgumentList.Add(argument);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
replacement?.Dispose();
|
|
}
|
|
}
|
|
|
|
// Release the helper's exclusive barrier before restarting the
|
|
// restored canonical launcher. It will observe the durable RolledBack
|
|
// receipt through the ordinary startup path, re-verify it, finalize
|
|
// recovery, and continue with no privileged bypass argument.
|
|
if (restoredStart is null || Process.Start(restoredStart) is null)
|
|
{
|
|
return 75;
|
|
}
|
|
|
|
return 74;
|
|
}
|
|
|
|
private static string GetLauncherFileName(string rid) =>
|
|
"acdream-launcher"
|
|
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
|
|
|
|
private static void ValidateCanonicalStartup(
|
|
SelfUpdatePlan plan,
|
|
string baseDirectory,
|
|
string executable)
|
|
{
|
|
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
|
|
{
|
|
throw new LauncherUpdateException(
|
|
"The pending self-update targets a different launcher directory.");
|
|
}
|
|
|
|
string expectedExecutable = ClientVersionStore.ResolveContained(
|
|
baseDirectory,
|
|
GetLauncherFileName(plan.Rid));
|
|
if (!PathsEqual(executable, expectedExecutable))
|
|
{
|
|
throw new LauncherUpdateException(
|
|
"Self-update can run only from the published acdream-launcher executable.");
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|