diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index 02cc292c..757827b9 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -172,17 +172,34 @@ jobs: dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Publish the self-contained Linux launcher - if: runner.os == 'Linux' + - name: Publish the self-contained launcher distribution shell: pwsh run: | + $rid = if ($IsWindows) { "win-x64" } else { "linux-x64" } dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj ` -c Release ` - -r linux-x64 ` - -o artifacts/acdream-launcher-linux-x64 + -r $rid ` + -o "artifacts/acdream-launcher-$rid" 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' shell: bash run: | @@ -195,11 +212,17 @@ jobs: -getProperty:SelfContained | tr -d '\r\n ') test "$self_contained" = true test -x "$root/acdream-launcher" + test -x "$root/acdream-bake" test ! -f "$root/acdream-launcher.dll" + test ! -f "$root/acdream-bake.dll" DOTNET_ROOT=/definitely-not-installed \ DOTNET_ROOT_X64=/definitely-not-installed \ DOTNET_MULTILEVEL_LOOKUP=0 \ "$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: runs-on: ubuntu-latest diff --git a/AcDream.slnx b/AcDream.slnx index 0771d163..a332b1db 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -27,6 +27,7 @@ + diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 2b22c7dd..34c9d23b 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -293,7 +293,11 @@ src/ Installation/ -> portable four-DAT validation, Windows retail path discovery, versioned JSONL bake-process 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 + `..acdream-bake..tmp` files are + transaction-owned crash residue -> references Platform only; no Avalonia or game-host dependency AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell @@ -301,6 +305,8 @@ src/ including the first-run DAT/bake wizard -> references Launcher.Core only (Platform transitively); it never owns 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 actions are explicitly disabled until Modern Runtime Slice L resumes diff --git a/src/AcDream.Bake/BakeCommandLine.cs b/src/AcDream.Bake/BakeCommandLine.cs index e5c76139..ffdfb615 100644 --- a/src/AcDream.Bake/BakeCommandLine.cs +++ b/src/AcDream.Bake/BakeCommandLine.cs @@ -15,7 +15,15 @@ internal static class BakeCommandLine internal const string Usage = "usage: acdream-bake --dat-dir [--out ] " + "[--ids 0xId,0xId,...] [--landblocks 0xId,...] " - + "[--threads ] [--progress-json]"; + + "[--threads ] [--progress-json]\n" + + " acdream-bake --help"; + + public static bool IsHelpRequest(IReadOnlyList args) + { + ArgumentNullException.ThrowIfNull(args); + return args.Count == 1 + && args[0] is "--help" or "-h"; + } public static bool TryParse( IReadOnlyList args, diff --git a/src/AcDream.Bake/BakeOutputTransaction.cs b/src/AcDream.Bake/BakeOutputTransaction.cs index 0bc58e16..54416ab5 100644 --- a/src/AcDream.Bake/BakeOutputTransaction.cs +++ b/src/AcDream.Bake/BakeOutputTransaction.cs @@ -9,6 +9,8 @@ namespace AcDream.Bake; /// public static class BakeOutputTransaction { + internal const string StagingMarker = ".acdream-bake."; + public static TResult WriteValidateAndPublish( string destinationPath, Func 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 } } } + + /// + /// 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. + /// + 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"); + } } diff --git a/src/AcDream.Bake/Program.cs b/src/AcDream.Bake/Program.cs index 4abfc862..5aba455e 100644 --- a/src/AcDream.Bake/Program.cs +++ b/src/AcDream.Bake/Program.cs @@ -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; diff --git a/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs new file mode 100644 index 00000000..a6ae15b4 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs @@ -0,0 +1,64 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// 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. +/// +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 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); + } + } + } +} diff --git a/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs b/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs new file mode 100644 index 00000000..65d5eb92 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs @@ -0,0 +1,121 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// 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. +/// +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, + } +} diff --git a/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs b/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs new file mode 100644 index 00000000..1fc7ec8e --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs @@ -0,0 +1,60 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// 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. +/// +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 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; + } +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs index c6815d1d..f88dba2f 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -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 LoadAndVerifyAsync( CancellationToken cancellationToken = default) + { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + DataDirectory, + cancellationToken) + .ConfigureAwait(false); + return await LoadAndVerifyUnderLeaseAsync(cancellationToken) + .ConfigureAwait(false); + } + + internal async Task 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( + 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(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 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 diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs index 88a29074..488f03f5 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs @@ -112,11 +112,26 @@ public sealed class LauncherInstaller : ILauncherInstaller public async Task 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 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) diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index a4c20260..63fa57ff 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -10,7 +10,8 @@ true true true - true + true + true @@ -22,4 +23,20 @@ + + + + + <_BakePublishDirectory Condition="$([System.IO.Path]::IsPathRooted('$(PublishDir)'))">$(PublishDir) + <_BakePublishDirectory Condition="'$(_BakePublishDirectory)' == ''">$(MSBuildProjectDirectory)\$(PublishDir) + + + diff --git a/tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs b/tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs index 4d397817..ea872021 100644 --- a/tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs +++ b/tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs @@ -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] public void Publish_ReplacesExistingDestinationOnlyAfterValidation() { diff --git a/tests/AcDream.Bake.Tests/BakeProgressCliTests.cs b/tests/AcDream.Bake.Tests/BakeProgressCliTests.cs index 7f3915fd..5f8e0658 100644 --- a/tests/AcDream.Bake.Tests/BakeProgressCliTests.cs +++ b/tests/AcDream.Bake.Tests/BakeProgressCliTests.cs @@ -5,6 +5,16 @@ namespace AcDream.Bake.Tests; 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] public void ProgressJsonFlagIsOptInAndDefaultOutputRemainsInTheDatDirectory() { diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj new file mode 100644 index 00000000..bc7176f0 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + latest + false + true + + diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs new file mode 100644 index 00000000..73207808 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs @@ -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; diff --git a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj index 6f77e682..78fa3e4f 100644 --- a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj +++ b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj @@ -19,5 +19,11 @@ + + + false + true + diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressProtocolTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressProtocolTests.cs new file mode 100644 index 00000000..438ced97 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressProtocolTests.cs @@ -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 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), + }, + }; +} diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallRecordStoreTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallRecordStoreTests.cs index cefbdfaa..793dd519 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallRecordStoreTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallRecordStoreTests.cs @@ -6,6 +6,13 @@ using AcDream.Platform; 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 { private readonly string _root = Path.Combine( @@ -134,6 +141,130 @@ public sealed class LauncherInstallRecordStoreTests : IDisposable 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] public async Task StartupRecoversPriorVerifiedPackageAfterInterruptedReplacement() { diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs index 066af070..0ee05773 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.Json.Nodes; using AcDream.Launcher.Core.Integrity; using AcDream.Launcher.Core.Installation; @@ -168,6 +169,33 @@ public sealed class LauncherInstallerTests : IDisposable 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( + () => 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] public async Task CancellationRestoresPriorInstallAndNeverPublishesPartialOutput() { @@ -270,6 +298,187 @@ public sealed class LauncherInstallerTests : IDisposable 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 operationA = + installerA.InstallAsync(_dats, 1); + await childEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + try + { + using var cancellationB = new CancellationTokenSource(); + Task operationB = installerB.InstallAsync( + _dats, + 1, + cancellationToken: cancellationB.Token); + await Task.Delay(150); + cancellationB.Cancel(); + + await Assert.ThrowsAnyAsync(() => 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(() => 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( + () => 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<( LauncherInstaller Installer, LauncherInstallRecordStore Store, @@ -303,6 +512,80 @@ public sealed class LauncherInstallerTests : IDisposable return (installer, store, old); } + private async Task 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) { Directory.CreateDirectory(directory); diff --git a/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs b/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs index 74466630..5a5379a9 100644 --- a/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs @@ -71,6 +71,25 @@ public sealed class LauncherProjectBoundaryTests 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] public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards() { @@ -119,6 +138,9 @@ public sealed class LauncherProjectBoundaryTests Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal); Assert.Contains("DOTNET_ROOT", 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( "test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless", workflow,