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

@ -172,17 +172,34 @@ jobs:
dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Publish the self-contained Linux launcher - name: Publish the self-contained launcher distribution
if: runner.os == 'Linux'
shell: pwsh shell: pwsh
run: | run: |
$rid = if ($IsWindows) { "win-x64" } else { "linux-x64" }
dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj ` dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj `
-c Release ` -c Release `
-r linux-x64 ` -r $rid `
-o artifacts/acdream-launcher-linux-x64 -o "artifacts/acdream-launcher-$rid"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Verify self-contained property and artifact execution - name: Verify self-contained Windows launcher and bake artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$root = "artifacts/acdream-launcher-win-x64"
if (-not (Test-Path -LiteralPath "$root/acdream-launcher.exe" -PathType Leaf)) { throw "launcher executable missing" }
if (-not (Test-Path -LiteralPath "$root/acdream-bake.exe" -PathType Leaf)) { throw "bake executable missing" }
if (Test-Path -LiteralPath "$root/acdream-launcher.dll") { throw "launcher is not single-file" }
if (Test-Path -LiteralPath "$root/acdream-bake.dll") { throw "bake is not single-file" }
$env:DOTNET_ROOT = "Z:\definitely-not-installed"
$env:DOTNET_ROOT_X64 = "Z:\definitely-not-installed"
$env:DOTNET_MULTILEVEL_LOOKUP = "0"
& "$root/acdream-launcher.exe" --verify-publish
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& "$root/acdream-bake.exe" --help
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Verify self-contained Linux launcher and bake artifacts
if: runner.os == 'Linux' if: runner.os == 'Linux'
shell: bash shell: bash
run: | run: |
@ -195,11 +212,17 @@ jobs:
-getProperty:SelfContained | tr -d '\r\n ') -getProperty:SelfContained | tr -d '\r\n ')
test "$self_contained" = true test "$self_contained" = true
test -x "$root/acdream-launcher" test -x "$root/acdream-launcher"
test -x "$root/acdream-bake"
test ! -f "$root/acdream-launcher.dll" test ! -f "$root/acdream-launcher.dll"
test ! -f "$root/acdream-bake.dll"
DOTNET_ROOT=/definitely-not-installed \ DOTNET_ROOT=/definitely-not-installed \
DOTNET_ROOT_X64=/definitely-not-installed \ DOTNET_ROOT_X64=/definitely-not-installed \
DOTNET_MULTILEVEL_LOOKUP=0 \ DOTNET_MULTILEVEL_LOOKUP=0 \
"$root/acdream-launcher" --verify-publish "$root/acdream-launcher" --verify-publish
DOTNET_ROOT=/definitely-not-installed \
DOTNET_ROOT_X64=/definitely-not-installed \
DOTNET_MULTILEVEL_LOOKUP=0 \
"$root/acdream-bake" --help
linux-graphical: linux-graphical:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -27,6 +27,7 @@
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" /> <Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" /> <Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" /> <Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" /> <Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" /> <Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" /> <Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />

View file

@ -293,7 +293,11 @@ src/
Installation/ -> portable four-DAT validation, Windows retail Installation/ -> portable four-DAT validation, Windows retail
path discovery, versioned JSONL bake-process path discovery, versioned JSONL bake-process
orchestration, and atomic SHA/size/tool-version orchestration, and atomic SHA/size/tool-version
install-record verification and recovery install-record verification and recovery; one
OS-handle lease serializes recovery/install per
DataDirectory, and only exact adjacent
`.<pak>.acdream-bake.<guid:N>.tmp` files are
transaction-owned crash residue
-> references Platform only; no Avalonia or game-host dependency -> references Platform only; no Avalonia or game-host dependency
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
@ -301,6 +305,8 @@ src/
including the first-run DAT/bake wizard including the first-run DAT/bake wizard
-> references Launcher.Core only (Platform transitively); it never owns -> references Launcher.Core only (Platform transitively); it never owns
a second profile, process, status, or credential state graph a second profile, process, status, or credential state graph
-> every per-RID publish composes the separately published self-contained
`acdream-bake` executable beside the launcher without a project edge
-> Linux launcher/probe/headless flows remain portable; graphical-client -> Linux launcher/probe/headless flows remain portable; graphical-client
actions are explicitly disabled until Modern Runtime Slice L resumes actions are explicitly disabled until Modern Runtime Slice L resumes

View file

@ -15,7 +15,15 @@ internal static class BakeCommandLine
internal const string Usage = internal const string Usage =
"usage: acdream-bake --dat-dir <path> [--out <file>] " "usage: acdream-bake --dat-dir <path> [--out <file>] "
+ "[--ids 0xId,0xId,...] [--landblocks 0xId,...] " + "[--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( public static bool TryParse(
IReadOnlyList<string> args, IReadOnlyList<string> args,

View file

@ -9,6 +9,8 @@ namespace AcDream.Bake;
/// </summary> /// </summary>
public static class BakeOutputTransaction public static class BakeOutputTransaction
{ {
internal const string StagingMarker = ".acdream-bake.";
public static TResult WriteValidateAndPublish<TResult>( public static TResult WriteValidateAndPublish<TResult>(
string destinationPath, string destinationPath,
Func<string, TResult> writeTemporary, Func<string, TResult> writeTemporary,
@ -25,9 +27,7 @@ public static class BakeOutputTransaction
throw new InvalidOperationException("destination has no parent directory"); throw new InvalidOperationException("destination has no parent directory");
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
string temporaryPath = Path.Combine( string temporaryPath = CreateStagingPath(fullDestination, Guid.NewGuid());
directory,
$".{Path.GetFileName(fullDestination)}.{Guid.NewGuid():N}.tmp");
try 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. // 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)) if (!BakeCommandLine.TryParse(args, Console.Error, out BakeCommandLineOptions? command))
{ {
return 2; 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)); 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( public string PreparedAssetPath => Path.Combine(
_paths.DataDirectory, DataDirectory,
"pak", "pak",
"acdream.pak"); "acdream.pak");
@ -66,6 +68,18 @@ public sealed class LauncherInstallRecordStore
public async Task<InstallRecordVerification> LoadAndVerifyAsync( public async Task<InstallRecordVerification> LoadAndVerifyAsync(
CancellationToken cancellationToken = default) 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)) if (!File.Exists(RecordPath))
{ {
@ -85,11 +99,20 @@ public sealed class LauncherInstallRecordStore
FileShare.Read, FileShare.Read,
bufferSize: 4096, bufferSize: 4096,
options: FileOptions.Asynchronous | FileOptions.SequentialScan); options: FileOptions.Asynchronous | FileOptions.SequentialScan);
record = await JsonSerializer.DeserializeAsync<LauncherInstallRecord>( using JsonDocument document = await JsonDocument.ParseAsync(
stream, stream,
SerializerOptions, cancellationToken: cancellationToken)
cancellationToken)
.ConfigureAwait(false); .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) catch (OperationCanceledException)
{ {
@ -108,7 +131,9 @@ public sealed class LauncherInstallRecordStore
return Invalid("The install record is empty."); return Invalid("The install record is empty.");
} }
string? contractError = ValidateRecordContract(record); string? contractError = ValidateRecordContract(
record,
requireCanonicalSerializedPaths: true);
if (contractError is not null) if (contractError is not null)
{ {
return Invalid(contractError); return Invalid(contractError);
@ -158,9 +183,25 @@ public sealed class LauncherInstallRecordStore
public async Task SaveAtomicallyAsync( public async Task SaveAtomicallyAsync(
LauncherInstallRecord record, LauncherInstallRecord record,
CancellationToken cancellationToken = default) 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); ArgumentNullException.ThrowIfNull(record);
string? contractError = ValidateRecordContract(record); LauncherInstallRecord normalized = NormalizeForSave(record);
string? contractError = ValidateRecordContract(
normalized,
requireCanonicalSerializedPaths: true);
if (contractError is not null) if (contractError is not null)
{ {
throw new InvalidDataException(contractError); throw new InvalidDataException(contractError);
@ -186,7 +227,7 @@ public sealed class LauncherInstallRecordStore
{ {
await JsonSerializer.SerializeAsync( await JsonSerializer.SerializeAsync(
stream, stream,
record, normalized,
SerializerOptions, SerializerOptions,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .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) if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
{ {
@ -226,7 +269,23 @@ public sealed class LauncherInstallRecordStore
return "The install record contains an invalid SHA-256 digest."; 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); string canonicalPreparedPath = Path.GetFullPath(PreparedAssetPath);
if (requireCanonicalSerializedPaths
&& !Path.IsPathFullyQualified(record.PreparedAssetPath))
{
return "The prepared asset path must be absolute.";
}
string recordedPreparedPath; string recordedPreparedPath;
try try
{ {
@ -245,11 +304,87 @@ public sealed class LauncherInstallRecordStore
+ "DataDirectory/pak/acdream.pak path."; + "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 = DatDirectoryValidation datValidation =
_datDirectories.Validate(record.DatDirectory); _datDirectories.Validate(record.DatDirectory);
return datValidation.IsValid if (!datValidation.IsValid)
? null {
: datValidation.Message + FormatMissing(datValidation.MissingFileNames); 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( private async Task<FileVerification> VerifyFileAsync(
@ -317,6 +452,22 @@ public sealed class LauncherInstallRecordStore
? StringComparison.OrdinalIgnoreCase ? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal); : 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) internal static void TryDelete(string path)
{ {
try try

View file

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

View file

@ -10,7 +10,8 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<PublishSingleFile>true</PublishSingleFile> <PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<SelfContained Condition="'$(RuntimeIdentifier)' == 'linux-x64'">true</SelfContained> <SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
<PublishBakeTool Condition="'$(PublishBakeTool)' == ''">true</PublishBakeTool>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -22,4 +23,20 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" /> <ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup> </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> </Project>

View file

@ -23,6 +23,24 @@ public sealed class BakeOutputTransactionTests : IDisposable
} }
} }
[Fact]
public void StagingPathUsesTheDocumentedLauncherRecoveryContract()
{
string destination = Path.Combine(_directory, "pak", "acdream.pak");
Guid transaction = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef");
string staging = BakeOutputTransaction.CreateStagingPath(
destination,
transaction);
Assert.Equal(
Path.Combine(
_directory,
"pak",
".acdream.pak.acdream-bake.0123456789abcdef0123456789abcdef.tmp"),
staging);
}
[Fact] [Fact]
public void Publish_ReplacesExistingDestinationOnlyAfterValidation() public void Publish_ReplacesExistingDestinationOnlyAfterValidation()
{ {

View file

@ -5,6 +5,16 @@ namespace AcDream.Bake.Tests;
public sealed class BakeProgressCliTests public sealed class BakeProgressCliTests
{ {
[Theory]
[InlineData("--help")]
[InlineData("-h")]
public void HelpIsAZeroDatArgumentProbe(string argument)
{
Assert.True(BakeCommandLine.IsHelpRequest([argument]));
Assert.False(BakeCommandLine.IsHelpRequest([argument, "extra"]));
Assert.Contains("--help", BakeCommandLine.Usage, StringComparison.Ordinal);
}
[Fact] [Fact]
public void ProgressJsonFlagIsOptInAndDefaultOutputRemainsInTheDatDirectory() public void ProgressJsonFlagIsOptInAndDefaultOutputRemainsInTheDatDirectory()
{ {

View file

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,24 @@
if (args.Length != 3)
{
return 2;
}
string lockPath = Path.GetFullPath(args[0]);
string stagingPath = Path.GetFullPath(args[1]);
string readyPath = Path.GetFullPath(args[2]);
Directory.CreateDirectory(
Path.GetDirectoryName(lockPath)
?? throw new InvalidOperationException("lock path has no parent"));
Directory.CreateDirectory(
Path.GetDirectoryName(stagingPath)
?? throw new InvalidOperationException("staging path has no parent"));
using var lease = new FileStream(
lockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None);
File.WriteAllText(stagingPath, "abandoned bake staging");
File.WriteAllText(readyPath, "ready");
await Task.Delay(Timeout.InfiniteTimeSpan);
return 0;

View file

@ -19,5 +19,11 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" /> <ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
<ProjectReference Include="..\..\src\AcDream.Platform\AcDream.Platform.csproj" /> <ProjectReference Include="..\..\src\AcDream.Platform\AcDream.Platform.csproj" />
<!-- Build ordering only. The crash-recovery test launches this fixture in
a separate process so the OS owns and releases the install lease. -->
<ProjectReference Include="..\AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder\AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,109 @@
using AcDream.Launcher.Core.Installation;
namespace AcDream.Launcher.Core.Tests.Installation;
public sealed class BakeProgressProtocolTests
{
[Fact]
public void OneStartedProgressAndCompletedSequenceIsAccepted()
{
var protocol = new BakeProgressProtocol();
Assert.True(protocol.Observe(new BakeHumanOutputEvent("human")));
Assert.True(protocol.Observe(new UnknownBakeProgressEvent(
1,
"newMetric",
"{}")));
Assert.True(protocol.Observe(new FutureBakeProgressEvent(
2,
"started",
"{}")));
Assert.True(protocol.Observe(new BakeStartedEvent(1, 4, "pak")));
Assert.True(protocol.Observe(new BakeWorkProgressEvent(
1,
"mesh",
1,
2,
0,
1,
1)));
Assert.True(protocol.Observe(new BakeCompletedEvent(1, 4, 100, 0)));
protocol.CompleteInput();
Assert.Null(protocol.Violation);
Assert.NotNull(protocol.Started);
Assert.NotNull(protocol.Completed);
Assert.Null(protocol.Error);
}
[Theory]
[MemberData(nameof(InvalidKnownSequences))]
public void OutOfOrderDuplicateAndPostTerminalKnownEventsAreRejected(
BakeProgressEvent[] events)
{
var protocol = new BakeProgressProtocol();
foreach (BakeProgressEvent progressEvent in events)
{
protocol.Observe(progressEvent);
}
protocol.CompleteInput();
Assert.NotNull(protocol.Violation);
}
[Fact]
public void ErrorTerminalCannotBeOverwrittenByContradictoryCompletion()
{
var protocol = new BakeProgressProtocol();
var failure = new BakeErrorEvent(1, "first failure");
Assert.True(protocol.Observe(new BakeStartedEvent(1, 4, null)));
Assert.True(protocol.Observe(failure));
Assert.False(protocol.Observe(new BakeCompletedEvent(1, 4, 10, 0)));
protocol.CompleteInput();
Assert.Same(failure, protocol.Error);
Assert.Null(protocol.Completed);
Assert.Contains("after", protocol.Violation, StringComparison.OrdinalIgnoreCase);
}
public static TheoryData<BakeProgressEvent[]> InvalidKnownSequences => new()
{
new BakeProgressEvent[]
{
new BakeWorkProgressEvent(1, "mesh", 0, 1, 0, 0, 0),
},
new BakeProgressEvent[]
{
new BakeCompletedEvent(1, 4, 10, 0),
},
new BakeProgressEvent[]
{
new BakeErrorEvent(1, "before start"),
},
new BakeProgressEvent[]
{
new BakeStartedEvent(1, 4, null),
new BakeStartedEvent(1, 4, null),
},
new BakeProgressEvent[]
{
new BakeStartedEvent(1, 4, null),
new BakeCompletedEvent(1, 4, 10, 0),
new BakeCompletedEvent(1, 4, 10, 0),
},
new BakeProgressEvent[]
{
new BakeStartedEvent(1, 4, null),
new BakeCompletedEvent(1, 4, 10, 0),
new BakeWorkProgressEvent(1, "mesh", 1, 1, 0, 1, 0),
},
new BakeProgressEvent[]
{
new BakeStartedEvent(1, 4, null),
},
};
}

View file

@ -6,6 +6,13 @@ using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Installation; namespace AcDream.Launcher.Core.Tests.Installation;
[CollectionDefinition(WorkingDirectoryCollection.Name, DisableParallelization = true)]
public sealed class WorkingDirectoryCollection
{
public const string Name = "Launcher install-record working directory";
}
[Collection(WorkingDirectoryCollection.Name)]
public sealed class LauncherInstallRecordStoreTests : IDisposable public sealed class LauncherInstallRecordStoreTests : IDisposable
{ {
private readonly string _root = Path.Combine( private readonly string _root = Path.Combine(
@ -134,6 +141,130 @@ public sealed class LauncherInstallRecordStoreTests : IDisposable
Assert.Contains("missing", verification.Status, StringComparison.OrdinalIgnoreCase); Assert.Contains("missing", verification.Status, StringComparison.OrdinalIgnoreCase);
} }
[Fact]
public async Task MissingExplicitVersionIsRejectedBeforeAdmission()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(_paths.DataDirectory);
await File.WriteAllTextAsync(
store.RecordPath,
JsonSerializer.Serialize(new
{
datDirectory = Path.GetFullPath(_dats),
preparedAssetPath = Path.GetFullPath(store.PreparedAssetPath),
preparedAssetSha256 = new string('a', 64),
preparedAssetSize = 12,
bakeToolVersion =
LauncherInstallRecordStore.CurrentBakeToolVersion,
}));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
Assert.Contains("explicit", verification.Status, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SaveNormalizesCanonicalAbsoluteDatAndPreparedPaths()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
var info = new FileInfo(store.PreparedAssetPath);
var nonCanonical = new LauncherInstallRecord(
Path.Combine(_dats, "..", Path.GetFileName(_dats), "."),
Path.Combine(
Path.GetDirectoryName(store.PreparedAssetPath)!,
"..",
"pak",
Path.GetFileName(store.PreparedAssetPath)),
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
info.Length,
LauncherInstallRecordStore.CurrentBakeToolVersion);
await store.SaveAtomicallyAsync(nonCanonical);
using JsonDocument document = JsonDocument.Parse(
await File.ReadAllTextAsync(store.RecordPath));
Assert.Equal(
Path.GetFullPath(_dats),
document.RootElement.GetProperty("datDirectory").GetString());
Assert.Equal(
Path.GetFullPath(store.PreparedAssetPath),
document.RootElement.GetProperty("preparedAssetPath").GetString());
Assert.Equal(
LauncherInstallRecord.CurrentRecordVersion,
document.RootElement.GetProperty("version").GetInt32());
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
}
[Fact]
public async Task RelativeDatRecordCannotChangeMeaningWithWorkingDirectory()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
string alternateWorkingDirectory = Path.Combine(_root, "alternate-cwd");
string alternateDats = Path.Combine(alternateWorkingDirectory, "retail-dats");
CreateCompleteDatDirectory(alternateDats);
var info = new FileInfo(store.PreparedAssetPath);
var relative = new LauncherInstallRecord(
"retail-dats",
Path.GetFullPath(store.PreparedAssetPath),
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
info.Length,
LauncherInstallRecordStore.CurrentBakeToolVersion);
Directory.CreateDirectory(_paths.DataDirectory);
await File.WriteAllTextAsync(
store.RecordPath,
JsonSerializer.Serialize(relative, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}));
string originalWorkingDirectory = Environment.CurrentDirectory;
try
{
Environment.CurrentDirectory = alternateWorkingDirectory;
InstallRecordVerification verification =
await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
Assert.Contains("absolute", verification.Status, StringComparison.OrdinalIgnoreCase);
}
finally
{
Environment.CurrentDirectory = originalWorkingDirectory;
}
}
[Fact]
public async Task OlderAbsoluteButNonCanonicalDatDocumentIsRejected()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
var info = new FileInfo(store.PreparedAssetPath);
var nonCanonical = new LauncherInstallRecord(
Path.Combine(_dats, "..", Path.GetFileName(_dats)),
Path.GetFullPath(store.PreparedAssetPath),
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
info.Length,
LauncherInstallRecordStore.CurrentBakeToolVersion);
Directory.CreateDirectory(_paths.DataDirectory);
await File.WriteAllTextAsync(
store.RecordPath,
JsonSerializer.Serialize(nonCanonical, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
Assert.Contains("canonical", verification.Status, StringComparison.OrdinalIgnoreCase);
}
[Fact] [Fact]
public async Task StartupRecoversPriorVerifiedPackageAfterInterruptedReplacement() public async Task StartupRecoversPriorVerifiedPackageAfterInterruptedReplacement()
{ {

View file

@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using AcDream.Launcher.Core.Integrity; using AcDream.Launcher.Core.Integrity;
using AcDream.Launcher.Core.Installation; using AcDream.Launcher.Core.Installation;
@ -168,6 +169,33 @@ public sealed class LauncherInstallerTests : IDisposable
Assert.Equal(LauncherInstallPhase.Failed, progress[^1].Phase); Assert.Equal(LauncherInstallPhase.Failed, progress[^1].Phase);
} }
[Fact]
public async Task ContradictoryTerminalCannotReplaceFirstFailureOrPriorInstall()
{
(LauncherInstaller installer, LauncherInstallRecordStore store, LauncherInstallRecord old) =
await CreateInstallerWithPriorRecordAsync(
async (request, output, _) =>
{
await File.WriteAllTextAsync(request.OutputPath, "contradictory output");
long bytes = new FileInfo(request.OutputPath).Length;
output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
output("{\"v\":1,\"e\":\"error\",\"message\":\"first failure\"}\n");
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4,"
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
return new BakeProcessResult(0, string.Empty);
});
LauncherInstallException exception =
await Assert.ThrowsAsync<LauncherInstallException>(
() => installer.InstallAsync(_dats, 2));
Assert.Contains("after", exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(
"previous verified package",
await File.ReadAllTextAsync(store.PreparedAssetPath));
Assert.Equal(old, (await store.LoadAndVerifyAsync()).Record);
}
[Fact] [Fact]
public async Task CancellationRestoresPriorInstallAndNeverPublishesPartialOutput() public async Task CancellationRestoresPriorInstallAndNeverPublishesPartialOutput()
{ {
@ -270,6 +298,187 @@ public sealed class LauncherInstallerTests : IDisposable
Assert.False(File.Exists(store.RecordPath)); Assert.False(File.Exists(store.RecordPath));
} }
[Fact]
public async Task IndependentInstallersSerializeAndWaitingCancellationTouchesNothing()
{
var storeA = new LauncherInstallRecordStore(_paths);
LauncherInstallRecord old = await CreatePriorRecordAsync(storeA);
string backupPath = LauncherInstallRecordStore.GetBackupPath(
storeA.PreparedAssetPath);
var childEntered = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var releaseChild = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var runnerA = new FakeBakeProcessRunner(async (request, _, _) =>
{
await File.WriteAllTextAsync(request.OutputPath, "installer A in progress");
childEntered.SetResult();
await releaseChild.Task;
return new BakeProcessResult(1, "fixture A failed");
});
bool runnerBEntered = false;
var runnerB = new FakeBakeProcessRunner((_, _, _) =>
{
runnerBEntered = true;
return Task.FromResult(new BakeProcessResult(1, "must not run"));
});
var installerA = new LauncherInstaller(
_paths,
_bakeExecutable,
recordStore: storeA,
processRunner: runnerA);
var installerB = new LauncherInstaller(
_paths,
_bakeExecutable,
recordStore: new LauncherInstallRecordStore(_paths),
processRunner: runnerB);
Task<LauncherInstallResult> operationA =
installerA.InstallAsync(_dats, 1);
await childEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
try
{
using var cancellationB = new CancellationTokenSource();
Task<LauncherInstallResult> operationB = installerB.InstallAsync(
_dats,
1,
cancellationToken: cancellationB.Token);
await Task.Delay(150);
cancellationB.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operationB);
Assert.False(runnerBEntered);
Assert.Equal(
"installer A in progress",
await File.ReadAllTextAsync(storeA.PreparedAssetPath));
Assert.Equal(
"previous verified package",
await File.ReadAllTextAsync(backupPath));
}
finally
{
releaseChild.TrySetResult();
}
await Assert.ThrowsAsync<LauncherInstallException>(() => operationA);
Assert.Equal(
"previous verified package",
await File.ReadAllTextAsync(storeA.PreparedAssetPath));
Assert.False(File.Exists(backupPath));
Assert.Equal(old, (await storeA.LoadAndVerifyAsync()).Record);
}
[Fact]
public void StagingCleanupDeletesOnlyExactBakeTransactionNames()
{
var store = new LauncherInstallRecordStore(_paths);
string outputPath = store.PreparedAssetPath;
string directory = Path.GetDirectoryName(outputPath)!;
Directory.CreateDirectory(directory);
string owned = BakeOutputStagingContract.CreateStagingPath(
outputPath,
Guid.Parse("01234567-89ab-cdef-0123-456789abcdef"));
string canonical = outputPath;
string backup = LauncherInstallRecordStore.GetBackupPath(outputPath);
string oldPattern = Path.Combine(
directory,
$".{Path.GetFileName(outputPath)}.{Guid.NewGuid():N}.tmp");
string invalidTransaction = Path.Combine(
directory,
$".{Path.GetFileName(outputPath)}.acdream-bake.not-a-guid.tmp");
string unrelated = Path.Combine(directory, "unrelated.tmp");
Assert.Equal(
Path.Combine(
directory,
".acdream.pak.acdream-bake.0123456789abcdef0123456789abcdef.tmp"),
owned);
foreach (string path in new[]
{
owned,
canonical,
backup,
oldPattern,
invalidTransaction,
unrelated,
})
{
File.WriteAllText(path, Path.GetFileName(path));
}
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
Assert.False(File.Exists(owned));
Assert.True(File.Exists(canonical));
Assert.True(File.Exists(backup));
Assert.True(File.Exists(oldPattern));
Assert.True(File.Exists(invalidTransaction));
Assert.True(File.Exists(unrelated));
}
[Fact]
public async Task KilledProcessReleasesLeaseAndRestartReclaimsOnlyBakeStaging()
{
var store = new LauncherInstallRecordStore(_paths);
LauncherInstallRecord old = await CreatePriorRecordAsync(store);
string staging = BakeOutputStagingContract.CreateStagingPath(
store.PreparedAssetPath,
Guid.Parse("fedcba98-7654-3210-fedc-ba9876543210"));
string ready = Path.Combine(_root, "fixture-ready");
string fixtureDll = GetInstallLeaseFixturePath();
Assert.True(File.Exists(fixtureDll), $"Missing fixture: {fixtureDll}");
var startInfo = new ProcessStartInfo("dotnet")
{
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
startInfo.ArgumentList.Add(fixtureDll);
startInfo.ArgumentList.Add(
InstallerTransactionLease.GetLockPath(store.DataDirectory));
startInfo.ArgumentList.Add(staging);
startInfo.ArgumentList.Add(ready);
using Process helper = Process.Start(startInfo)
?? throw new InvalidOperationException("Could not start lease fixture.");
try
{
await WaitForFileAsync(ready, helper, TimeSpan.FromSeconds(10));
Assert.True(File.Exists(staging));
var blockedInstaller = new LauncherInstaller(
_paths,
_bakeExecutable,
recordStore: new LauncherInstallRecordStore(_paths));
using var blockedCancellation = new CancellationTokenSource(
TimeSpan.FromMilliseconds(200));
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => blockedInstaller.LoadExistingAsync(blockedCancellation.Token));
Assert.True(File.Exists(staging));
}
finally
{
if (!helper.HasExited)
{
helper.Kill(entireProcessTree: true);
}
await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
}
var restarted = new LauncherInstaller(
_paths,
_bakeExecutable,
recordStore: new LauncherInstallRecordStore(_paths));
InstallRecordVerification recovered = await restarted.LoadExistingAsync();
Assert.True(recovered.IsVerified);
Assert.Equal(old, recovered.Record);
Assert.False(File.Exists(staging));
Assert.Equal(
"previous verified package",
await File.ReadAllTextAsync(store.PreparedAssetPath));
}
private async Task<( private async Task<(
LauncherInstaller Installer, LauncherInstaller Installer,
LauncherInstallRecordStore Store, LauncherInstallRecordStore Store,
@ -303,6 +512,80 @@ public sealed class LauncherInstallerTests : IDisposable
return (installer, store, old); return (installer, store, old);
} }
private async Task<LauncherInstallRecord> CreatePriorRecordAsync(
LauncherInstallRecordStore store)
{
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(
store.PreparedAssetPath,
"previous verified package");
var old = new LauncherInstallRecord(
Path.GetFullPath(_dats),
Path.GetFullPath(store.PreparedAssetPath),
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
new FileInfo(store.PreparedAssetPath).Length,
LauncherInstallRecordStore.CurrentBakeToolVersion);
await store.SaveAtomicallyAsync(old);
return old;
}
private static async Task WaitForFileAsync(
string path,
Process process,
TimeSpan timeout)
{
using var cancellation = new CancellationTokenSource(timeout);
while (!File.Exists(path))
{
if (process.HasExited)
{
throw new InvalidOperationException(
$"Lease fixture exited with {process.ExitCode}: "
+ await process.StandardError.ReadToEndAsync());
}
await Task.Delay(25, cancellation.Token);
}
}
private static string GetInstallLeaseFixturePath()
{
string root = FindRepositoryRoot();
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent?.Name
?? "Release";
return Path.Combine(
root,
"tests",
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder",
"bin",
configuration,
"net10.0",
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.dll");
}
private static string FindRepositoryRoot()
{
foreach (string start in new[]
{
AppContext.BaseDirectory,
Environment.CurrentDirectory,
})
{
for (var directory = new DirectoryInfo(start);
directory is not null;
directory = directory.Parent)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
{
return directory.FullName;
}
}
}
throw new DirectoryNotFoundException("Could not locate repository root.");
}
private static void CreateCompleteDatDirectory(string directory) private static void CreateCompleteDatDirectory(string directory)
{ {
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);

View file

@ -71,6 +71,25 @@ public sealed class LauncherProjectBoundaryTests
Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile")); Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile"));
} }
[Fact]
public void RidPublishComposesBakeWithoutAProjectReference()
{
string project = File.ReadAllText(Path.Combine(
FindRepositoryRoot(),
"src",
"AcDream.Launcher",
"AcDream.Launcher.csproj"));
Assert.DoesNotContain(
"ProjectReference Include=\"..\\AcDream.Bake",
project,
StringComparison.Ordinal);
Assert.Contains("PublishCoDeployedBakeTool", project, StringComparison.Ordinal);
Assert.Contains("..\\AcDream.Bake\\AcDream.Bake.csproj", project, StringComparison.Ordinal);
Assert.Contains("SelfContained=true", project, StringComparison.Ordinal);
Assert.Contains("PublishSingleFile=true", project, StringComparison.Ordinal);
}
[Fact] [Fact]
public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards() public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards()
{ {
@ -119,6 +138,9 @@ public sealed class LauncherProjectBoundaryTests
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal); Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal); Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal); Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
Assert.Contains("acdream-bake.exe\" --help", workflow, StringComparison.Ordinal);
Assert.Contains("\"$root/acdream-bake\" --help", workflow, StringComparison.Ordinal);
Assert.Contains("test -x \"$root/acdream-bake\"", workflow, StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
"test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless", "test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless",
workflow, workflow,