fix(launcher): harden installer transactions

This commit is contained in:
Erik 2026-08-14 20:36:11 +02:00
parent ff6ebb6a6a
commit 3f68895120
21 changed files with 1164 additions and 61 deletions

View file

@ -15,7 +15,15 @@ internal static class BakeCommandLine
internal const string Usage =
"usage: acdream-bake --dat-dir <path> [--out <file>] "
+ "[--ids 0xId,0xId,...] [--landblocks 0xId,...] "
+ "[--threads <n>] [--progress-json]";
+ "[--threads <n>] [--progress-json]\n"
+ " acdream-bake --help";
public static bool IsHelpRequest(IReadOnlyList<string> args)
{
ArgumentNullException.ThrowIfNull(args);
return args.Count == 1
&& args[0] is "--help" or "-h";
}
public static bool TryParse(
IReadOnlyList<string> args,

View file

@ -9,6 +9,8 @@ namespace AcDream.Bake;
/// </summary>
public static class BakeOutputTransaction
{
internal const string StagingMarker = ".acdream-bake.";
public static TResult WriteValidateAndPublish<TResult>(
string destinationPath,
Func<string, TResult> writeTemporary,
@ -25,9 +27,7 @@ public static class BakeOutputTransaction
throw new InvalidOperationException("destination has no parent directory");
Directory.CreateDirectory(directory);
string temporaryPath = Path.Combine(
directory,
$".{Path.GetFileName(fullDestination)}.{Guid.NewGuid():N}.tmp");
string temporaryPath = CreateStagingPath(fullDestination, Guid.NewGuid());
try
{
@ -60,4 +60,21 @@ public static class BakeOutputTransaction
}
}
}
/// <summary>
/// Exact adjacent staging-name contract shared, by documentation and
/// conformance tests, with Launcher.Core. Keeping this tiny contract in
/// each BCL-facing assembly avoids an otherwise inverted project edge.
/// </summary>
internal static string CreateStagingPath(string destinationPath, Guid transactionId)
{
string fullDestination = Path.GetFullPath(destinationPath);
string directory = Path.GetDirectoryName(fullDestination)
?? throw new InvalidOperationException(
"destination has no parent directory");
return Path.Combine(
directory,
$".{Path.GetFileName(fullDestination)}{StagingMarker}"
+ $"{transactionId:N}.tmp");
}
}

View file

@ -9,6 +9,12 @@ using AcDream.Bake;
//
// Plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5.
if (BakeCommandLine.IsHelpRequest(args))
{
Console.Out.WriteLine(BakeCommandLine.Usage);
return 0;
}
if (!BakeCommandLine.TryParse(args, Console.Error, out BakeCommandLineOptions? command))
{
return 2;

View file

@ -0,0 +1,64 @@
namespace AcDream.Launcher.Core.Installation;
/// <summary>
/// Exact adjacent temporary-file contract emitted by AcDream.Bake's
/// BakeOutputTransaction. This class intentionally has no Bake project
/// dependency: both sides pin the same documented format with conformance
/// tests so Launcher.Core remains BCL-only.
/// </summary>
internal static class BakeOutputStagingContract
{
internal const string StagingMarker = ".acdream-bake.";
private const string Suffix = ".tmp";
internal static string CreateStagingPath(
string destinationPath,
Guid transactionId)
{
string fullDestination = Path.GetFullPath(destinationPath);
string directory = Path.GetDirectoryName(fullDestination)
?? throw new InvalidOperationException(
"The prepared package path has no parent directory.");
return Path.Combine(
directory,
$".{Path.GetFileName(fullDestination)}{StagingMarker}"
+ $"{transactionId:N}{Suffix}");
}
internal static bool IsOwnedStagingFileName(
string fileName,
string destinationFileName)
{
string prefix = $".{destinationFileName}{StagingMarker}";
if (!fileName.StartsWith(prefix, StringComparison.Ordinal)
|| !fileName.EndsWith(Suffix, StringComparison.Ordinal)
|| fileName.Length != prefix.Length + 32 + Suffix.Length)
{
return false;
}
ReadOnlySpan<char> transaction = fileName.AsSpan(prefix.Length, 32);
return Guid.TryParseExact(transaction, "N", out _);
}
internal static void DeleteOwnedStagingFiles(string destinationPath)
{
string fullDestination = Path.GetFullPath(destinationPath);
string? directory = Path.GetDirectoryName(fullDestination);
if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory))
{
return;
}
string destinationFileName = Path.GetFileName(fullDestination);
foreach (string candidate in Directory.EnumerateFiles(directory))
{
if (IsOwnedStagingFileName(
Path.GetFileName(candidate),
destinationFileName))
{
LauncherInstallRecordStore.TryDelete(candidate);
}
}
}
}

View file

@ -0,0 +1,121 @@
namespace AcDream.Launcher.Core.Installation;
/// <summary>
/// Strict state machine for known v1 bake events. Human output, unknown v1
/// events, and future versions are deliberately transparent; known v1 events
/// cannot be reordered, duplicated, or appended after the first terminal.
/// </summary>
internal sealed class BakeProgressProtocol
{
private BakeProgressProtocolState _state;
internal BakeStartedEvent? Started { get; private set; }
internal BakeCompletedEvent? Completed { get; private set; }
internal BakeErrorEvent? Error { get; private set; }
internal string? Violation { get; private set; }
internal bool Observe(BakeProgressEvent progressEvent)
{
ArgumentNullException.ThrowIfNull(progressEvent);
if (Violation is not null)
{
return false;
}
switch (progressEvent)
{
case BakeHumanOutputEvent:
case UnknownBakeProgressEvent:
case FutureBakeProgressEvent:
return true;
case MalformedBakeProgressEvent malformed:
Reject($"Malformed bake progress: {malformed.Reason}");
return false;
case BakeStartedEvent started:
if (_state != BakeProgressProtocolState.AwaitingStarted)
{
Reject(_state == BakeProgressProtocolState.Running
? "The bake protocol emitted more than one v1 started event."
: "The bake protocol emitted a known event after its terminal event.");
return false;
}
Started = started;
_state = BakeProgressProtocolState.Running;
return true;
case BakeWorkProgressEvent:
if (_state != BakeProgressProtocolState.Running)
{
Reject(KnownEventStateViolation("progress"));
return false;
}
return true;
case BakeCompletedEvent completed:
if (_state != BakeProgressProtocolState.Running)
{
Reject(KnownEventStateViolation("completed"));
return false;
}
Completed = completed;
_state = BakeProgressProtocolState.Completed;
return true;
case BakeErrorEvent error:
if (_state != BakeProgressProtocolState.Running)
{
Reject(KnownEventStateViolation("error"));
return false;
}
Error = error;
_state = BakeProgressProtocolState.Error;
return true;
default:
Reject("The bake protocol emitted an unsupported known event.");
return false;
}
}
internal void CompleteInput()
{
if (Violation is not null)
{
return;
}
if (_state == BakeProgressProtocolState.AwaitingStarted)
{
Reject("The bake protocol did not emit a v1 started event first.");
}
else if (_state == BakeProgressProtocolState.Running)
{
Reject("The bake protocol ended without exactly one terminal event.");
}
}
private string KnownEventStateViolation(string eventName) => _state switch
{
BakeProgressProtocolState.AwaitingStarted =>
$"The bake protocol emitted v1 {eventName} before v1 started.",
BakeProgressProtocolState.Running =>
$"The bake protocol emitted an invalid v1 {eventName} event.",
_ => "The bake protocol emitted a known event after its terminal event.",
};
private void Reject(string message)
{
Violation ??= message;
}
private enum BakeProgressProtocolState
{
AwaitingStarted,
Running,
Completed,
Error,
}
}

View file

@ -0,0 +1,60 @@
namespace AcDream.Launcher.Core.Installation;
/// <summary>
/// Cross-process ownership for every mutation or recovery of one launcher
/// DataDirectory. The persistent lock pathname is harmless; exclusivity is
/// owned by the open OS handle and therefore disappears if the process dies.
/// </summary>
internal sealed class InstallerTransactionLease : IAsyncDisposable
{
internal const string LockFileName = ".install.lock";
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
private readonly FileStream _stream;
private InstallerTransactionLease(FileStream stream)
{
_stream = stream;
}
internal static string GetLockPath(string dataDirectory) =>
Path.Combine(Path.GetFullPath(dataDirectory), LockFileName);
internal static async ValueTask<InstallerTransactionLease> AcquireAsync(
string dataDirectory,
CancellationToken cancellationToken = default)
{
string lockPath = GetLockPath(dataDirectory);
Directory.CreateDirectory(
Path.GetDirectoryName(lockPath)
?? throw new InvalidOperationException(
"The installer lock path has no parent directory."));
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var stream = new FileStream(
lockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 1,
FileOptions.None);
return new InstallerTransactionLease(stream);
}
catch (IOException)
{
await Task.Delay(RetryDelay, cancellationToken)
.ConfigureAwait(false);
}
}
}
public ValueTask DisposeAsync()
{
_stream.Dispose();
return ValueTask.CompletedTask;
}
}

View file

@ -54,10 +54,12 @@ public sealed class LauncherInstallRecordStore
FileIntegrity.ComputeSha256HexAsync(path, cancellationToken));
}
public string RecordPath => Path.Combine(_paths.DataDirectory, "install.json");
public string DataDirectory => Path.GetFullPath(_paths.DataDirectory);
public string RecordPath => Path.Combine(DataDirectory, "install.json");
public string PreparedAssetPath => Path.Combine(
_paths.DataDirectory,
DataDirectory,
"pak",
"acdream.pak");
@ -66,6 +68,18 @@ public sealed class LauncherInstallRecordStore
public async Task<InstallRecordVerification> LoadAndVerifyAsync(
CancellationToken cancellationToken = default)
{
await using InstallerTransactionLease lease =
await InstallerTransactionLease.AcquireAsync(
DataDirectory,
cancellationToken)
.ConfigureAwait(false);
return await LoadAndVerifyUnderLeaseAsync(cancellationToken)
.ConfigureAwait(false);
}
internal async Task<InstallRecordVerification> LoadAndVerifyUnderLeaseAsync(
CancellationToken cancellationToken = default)
{
if (!File.Exists(RecordPath))
{
@ -85,11 +99,20 @@ public sealed class LauncherInstallRecordStore
FileShare.Read,
bufferSize: 4096,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
record = await JsonSerializer.DeserializeAsync<LauncherInstallRecord>(
using JsonDocument document = await JsonDocument.ParseAsync(
stream,
SerializerOptions,
cancellationToken)
cancellationToken: cancellationToken)
.ConfigureAwait(false);
JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("version", out JsonElement version)
|| !version.TryGetInt32(out _))
{
return Invalid(
"The install record must contain an explicit integer version.");
}
record = root.Deserialize<LauncherInstallRecord>(SerializerOptions);
}
catch (OperationCanceledException)
{
@ -108,7 +131,9 @@ public sealed class LauncherInstallRecordStore
return Invalid("The install record is empty.");
}
string? contractError = ValidateRecordContract(record);
string? contractError = ValidateRecordContract(
record,
requireCanonicalSerializedPaths: true);
if (contractError is not null)
{
return Invalid(contractError);
@ -158,9 +183,25 @@ public sealed class LauncherInstallRecordStore
public async Task SaveAtomicallyAsync(
LauncherInstallRecord record,
CancellationToken cancellationToken = default)
{
await using InstallerTransactionLease lease =
await InstallerTransactionLease.AcquireAsync(
DataDirectory,
cancellationToken)
.ConfigureAwait(false);
await SaveAtomicallyUnderLeaseAsync(record, cancellationToken)
.ConfigureAwait(false);
}
internal async Task SaveAtomicallyUnderLeaseAsync(
LauncherInstallRecord record,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
string? contractError = ValidateRecordContract(record);
LauncherInstallRecord normalized = NormalizeForSave(record);
string? contractError = ValidateRecordContract(
normalized,
requireCanonicalSerializedPaths: true);
if (contractError is not null)
{
throw new InvalidDataException(contractError);
@ -186,7 +227,7 @@ public sealed class LauncherInstallRecordStore
{
await JsonSerializer.SerializeAsync(
stream,
record,
normalized,
SerializerOptions,
cancellationToken)
.ConfigureAwait(false);
@ -203,7 +244,9 @@ public sealed class LauncherInstallRecordStore
}
}
private string? ValidateRecordContract(LauncherInstallRecord record)
private string? ValidateRecordContract(
LauncherInstallRecord record,
bool requireCanonicalSerializedPaths)
{
if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
{
@ -226,7 +269,23 @@ public sealed class LauncherInstallRecordStore
return "The install record contains an invalid SHA-256 digest.";
}
if (string.IsNullOrWhiteSpace(record.PreparedAssetPath))
{
return "The prepared asset path is missing.";
}
if (string.IsNullOrWhiteSpace(record.DatDirectory))
{
return "The DAT directory path is missing.";
}
string canonicalPreparedPath = Path.GetFullPath(PreparedAssetPath);
if (requireCanonicalSerializedPaths
&& !Path.IsPathFullyQualified(record.PreparedAssetPath))
{
return "The prepared asset path must be absolute.";
}
string recordedPreparedPath;
try
{
@ -245,11 +304,87 @@ public sealed class LauncherInstallRecordStore
+ "DataDirectory/pak/acdream.pak path.";
}
if (requireCanonicalSerializedPaths
&& !CanonicalSpellingEquals(
record.PreparedAssetPath,
recordedPreparedPath,
trimEndingSeparator: false))
{
return "The prepared asset path is not canonical.";
}
if (requireCanonicalSerializedPaths
&& !Path.IsPathFullyQualified(record.DatDirectory))
{
return "The DAT directory path must be absolute.";
}
DatDirectoryValidation datValidation =
_datDirectories.Validate(record.DatDirectory);
return datValidation.IsValid
? null
: datValidation.Message + FormatMissing(datValidation.MissingFileNames);
if (!datValidation.IsValid)
{
return datValidation.Message
+ FormatMissing(datValidation.MissingFileNames);
}
return requireCanonicalSerializedPaths
&& !CanonicalSpellingEquals(
record.DatDirectory,
datValidation.Directory,
trimEndingSeparator: true)
? "The DAT directory path is not canonical."
: null;
}
private LauncherInstallRecord NormalizeForSave(LauncherInstallRecord record)
{
if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
{
throw new InvalidDataException(
$"Install record version {record.Version} is not supported.");
}
if (string.IsNullOrWhiteSpace(record.PreparedAssetPath))
{
throw new InvalidDataException("The prepared asset path is missing.");
}
DatDirectoryValidation datValidation =
_datDirectories.Validate(record.DatDirectory);
if (!datValidation.IsValid)
{
throw new InvalidDataException(
datValidation.Message
+ FormatMissing(datValidation.MissingFileNames));
}
string recordedPreparedPath;
try
{
recordedPreparedPath = Path.GetFullPath(record.PreparedAssetPath);
}
catch (Exception ex) when (ex is ArgumentException
or NotSupportedException
or PathTooLongException)
{
throw new InvalidDataException(
$"The prepared asset path is invalid: {ex.Message}",
ex);
}
if (!PathsEqual(recordedPreparedPath, PreparedAssetPath))
{
throw new InvalidDataException(
"The install record does not point to the launcher's canonical "
+ "DataDirectory/pak/acdream.pak path.");
}
return record with
{
Version = LauncherInstallRecord.CurrentRecordVersion,
DatDirectory = datValidation.Directory,
PreparedAssetPath = Path.GetFullPath(PreparedAssetPath),
};
}
private async Task<FileVerification> VerifyFileAsync(
@ -317,6 +452,22 @@ public sealed class LauncherInstallRecordStore
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
private static bool CanonicalSpellingEquals(
string serialized,
string canonical,
bool trimEndingSeparator)
{
string candidate = trimEndingSeparator
? Path.TrimEndingDirectorySeparator(serialized)
: serialized;
return string.Equals(
candidate,
canonical,
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
}
internal static void TryDelete(string path)
{
try

View file

@ -112,11 +112,26 @@ public sealed class LauncherInstaller : ILauncherInstaller
public async Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default)
{
InstallRecordVerification verification = await _recordStore
.LoadAndVerifyAsync(cancellationToken)
.ConfigureAwait(false);
_verifiedRecord = verification.Record;
return verification;
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using InstallerTransactionLease lease =
await InstallerTransactionLease.AcquireAsync(
_recordStore.DataDirectory,
cancellationToken)
.ConfigureAwait(false);
BakeOutputStagingContract.DeleteOwnedStagingFiles(
_recordStore.PreparedAssetPath);
InstallRecordVerification verification = await _recordStore
.LoadAndVerifyUnderLeaseAsync(cancellationToken)
.ConfigureAwait(false);
_verifiedRecord = verification.Record;
return verification;
}
finally
{
_installGate.Release();
}
}
public async Task<LauncherInstallResult> InstallAsync(
@ -135,6 +150,11 @@ public sealed class LauncherInstaller : ILauncherInstaller
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using InstallerTransactionLease lease =
await InstallerTransactionLease.AcquireAsync(
_recordStore.DataDirectory,
cancellationToken)
.ConfigureAwait(false);
return await InstallCoreAsync(
datDirectory,
threads,
@ -177,13 +197,11 @@ public sealed class LauncherInstaller : ILauncherInstaller
string outputPath = _recordStore.PreparedAssetPath;
string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath);
if (_verifiedRecord is null)
{
InstallRecordVerification existing = await _recordStore
.LoadAndVerifyAsync(cancellationToken)
.ConfigureAwait(false);
_verifiedRecord = existing.Record;
}
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
InstallRecordVerification existing = await _recordStore
.LoadAndVerifyUnderLeaseAsync(cancellationToken)
.ConfigureAwait(false);
_verifiedRecord = existing.Record;
Directory.CreateDirectory(
Path.GetDirectoryName(outputPath)
@ -201,19 +219,14 @@ public sealed class LauncherInstaller : ILauncherInstaller
}
var parser = new BakeProgressJsonlParser();
BakeStartedEvent? started = null;
BakeCompletedEvent? completed = null;
string? protocolError = null;
string? childError = null;
var protocol = new BakeProgressProtocol();
void Observe(BakeProgressEvent progressEvent)
{
bool accepted = protocol.Observe(progressEvent);
switch (progressEvent)
{
case BakeStartedEvent value:
started = value;
break;
case BakeWorkProgressEvent value:
case BakeWorkProgressEvent value when accepted:
LauncherInstallPhase phase = value.Phase switch
{
"mesh" => LauncherInstallPhase.BakingMeshes,
@ -231,18 +244,13 @@ public sealed class LauncherInstaller : ILauncherInstaller
value.Failures,
value.EtaSeconds);
break;
case BakeCompletedEvent value:
completed = value;
break;
case BakeErrorEvent value:
childError = value.Message;
case BakeErrorEvent value when accepted:
Report(
progress,
LauncherInstallPhase.Failed,
$"Bake tool error: {value.Message}");
break;
case MalformedBakeProgressEvent value:
protocolError ??= value.Reason;
Report(
progress,
LauncherInstallPhase.Failed,
@ -278,34 +286,35 @@ public sealed class LauncherInstaller : ILauncherInstaller
{
Observe(progressEvent);
}
protocol.CompleteInput();
cancellationToken.ThrowIfCancellationRequested();
if (protocol.Violation is not null)
{
throw new LauncherInstallException(protocol.Violation);
}
if (processResult.ExitCode != 0)
{
throw new LauncherInstallException(
BuildChildFailure(
processResult.ExitCode,
childError,
protocol.Error?.Message,
processResult.StandardError));
}
if (!string.IsNullOrWhiteSpace(childError))
if (protocol.Error is not null)
{
throw new LauncherInstallException(
$"The bake tool reported an error: {childError}");
}
if (protocolError is not null)
{
throw new LauncherInstallException(
$"The bake tool emitted malformed JSON progress: {protocolError}");
$"The bake tool reported an error: {protocol.Error.Message}");
}
BakeStartedEvent? started = protocol.Started;
BakeCompletedEvent? completed = protocol.Completed;
if (started is null || completed is null)
{
throw new LauncherInstallException(
"The bake tool exited without the required v1 started/completed "
+ "progress records.");
"The bake protocol did not finish with a v1 completed event.");
}
if (started.BakeToolVersion != completed.BakeToolVersion
@ -355,7 +364,9 @@ public sealed class LauncherInstaller : ILauncherInstaller
progress,
LauncherInstallPhase.SavingRecord,
"Saving the verified install record...");
await _recordStore.SaveAtomicallyAsync(record, cancellationToken)
await _recordStore.SaveAtomicallyUnderLeaseAsync(
record,
cancellationToken)
.ConfigureAwait(false);
_verifiedRecord = record;
@ -391,6 +402,10 @@ public sealed class LauncherInstaller : ILauncherInstaller
throw new LauncherInstallException("Installation failed.", ex);
}
finally
{
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
}
}
private bool PreservePreviousPackage(string outputPath, string backupPath)

View file

@ -10,7 +10,8 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<SelfContained Condition="'$(RuntimeIdentifier)' == 'linux-x64'">true</SelfContained>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
<PublishBakeTool Condition="'$(PublishBakeTool)' == ''">true</PublishBakeTool>
</PropertyGroup>
<ItemGroup>
@ -22,4 +23,20 @@
<ItemGroup>
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup>
<!-- Distribution composition only: do not add a Launcher -> Bake project
reference. A per-RID launcher publish explicitly publishes the GL-free
CLI as its own self-contained single file into the same directory. -->
<Target Name="PublishCoDeployedBakeTool"
AfterTargets="Publish"
Condition="'$(RuntimeIdentifier)' != '' and '$(PublishBakeTool)' == 'true'">
<PropertyGroup>
<_BakePublishDirectory Condition="$([System.IO.Path]::IsPathRooted('$(PublishDir)'))">$(PublishDir)</_BakePublishDirectory>
<_BakePublishDirectory Condition="'$(_BakePublishDirectory)' == ''">$(MSBuildProjectDirectory)\$(PublishDir)</_BakePublishDirectory>
</PropertyGroup>
<MSBuild Projects="$(MSBuildProjectDirectory)\..\AcDream.Bake\AcDream.Bake.csproj"
Targets="Restore;Publish"
BuildInParallel="false"
Properties="Configuration=$(Configuration);RuntimeIdentifier=$(RuntimeIdentifier);SelfContained=true;PublishSingleFile=true;IncludeNativeLibrariesForSelfExtract=true;EnableSingleFileAnalyzer=false;PublishDir=$(_BakePublishDirectory);PublishBakeTool=false" />
</Target>
</Project>