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,