using System.Diagnostics;
namespace AcDream.Launcher.Core.Updates;
public sealed record SelfUpdateStartupResult(
bool ShouldExit,
int ExitCode,
string[] RemainingArguments);
///
/// Process-level self-update bootstrap. Every child argument is passed through
/// with shell execution disabled.
///
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 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 RunHelperAsync(
LauncherSelfUpdateManager manager,
string helperBaseDirectory,
string currentExecutablePath,
int parentPid,
string targetDirectory,
string transactionId,
IReadOnlyList 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);
}