fix(launcher): harden installer transactions
This commit is contained in:
parent
ff6ebb6a6a
commit
3f68895120
21 changed files with 1164 additions and 61 deletions
33
.github/workflows/headless-portability.yml
vendored
33
.github/workflows/headless-portability.yml
vendored
|
|
@ -172,17 +172,34 @@ jobs:
|
||||||
dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release
|
dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
- name: Publish the self-contained Linux launcher
|
- name: Publish the self-contained launcher distribution
|
||||||
if: runner.os == 'Linux'
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
|
$rid = if ($IsWindows) { "win-x64" } else { "linux-x64" }
|
||||||
dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj `
|
dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj `
|
||||||
-c Release `
|
-c Release `
|
||||||
-r linux-x64 `
|
-r $rid `
|
||||||
-o artifacts/acdream-launcher-linux-x64
|
-o "artifacts/acdream-launcher-$rid"
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
- name: Verify self-contained property and artifact execution
|
- name: Verify self-contained Windows launcher and bake artifacts
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$root = "artifacts/acdream-launcher-win-x64"
|
||||||
|
if (-not (Test-Path -LiteralPath "$root/acdream-launcher.exe" -PathType Leaf)) { throw "launcher executable missing" }
|
||||||
|
if (-not (Test-Path -LiteralPath "$root/acdream-bake.exe" -PathType Leaf)) { throw "bake executable missing" }
|
||||||
|
if (Test-Path -LiteralPath "$root/acdream-launcher.dll") { throw "launcher is not single-file" }
|
||||||
|
if (Test-Path -LiteralPath "$root/acdream-bake.dll") { throw "bake is not single-file" }
|
||||||
|
$env:DOTNET_ROOT = "Z:\definitely-not-installed"
|
||||||
|
$env:DOTNET_ROOT_X64 = "Z:\definitely-not-installed"
|
||||||
|
$env:DOTNET_MULTILEVEL_LOOKUP = "0"
|
||||||
|
& "$root/acdream-launcher.exe" --verify-publish
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
& "$root/acdream-bake.exe" --help
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
- name: Verify self-contained Linux launcher and bake artifacts
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -195,11 +212,17 @@ jobs:
|
||||||
-getProperty:SelfContained | tr -d '\r\n ')
|
-getProperty:SelfContained | tr -d '\r\n ')
|
||||||
test "$self_contained" = true
|
test "$self_contained" = true
|
||||||
test -x "$root/acdream-launcher"
|
test -x "$root/acdream-launcher"
|
||||||
|
test -x "$root/acdream-bake"
|
||||||
test ! -f "$root/acdream-launcher.dll"
|
test ! -f "$root/acdream-launcher.dll"
|
||||||
|
test ! -f "$root/acdream-bake.dll"
|
||||||
DOTNET_ROOT=/definitely-not-installed \
|
DOTNET_ROOT=/definitely-not-installed \
|
||||||
DOTNET_ROOT_X64=/definitely-not-installed \
|
DOTNET_ROOT_X64=/definitely-not-installed \
|
||||||
DOTNET_MULTILEVEL_LOOKUP=0 \
|
DOTNET_MULTILEVEL_LOOKUP=0 \
|
||||||
"$root/acdream-launcher" --verify-publish
|
"$root/acdream-launcher" --verify-publish
|
||||||
|
DOTNET_ROOT=/definitely-not-installed \
|
||||||
|
DOTNET_ROOT_X64=/definitely-not-installed \
|
||||||
|
DOTNET_MULTILEVEL_LOOKUP=0 \
|
||||||
|
"$root/acdream-bake" --help
|
||||||
|
|
||||||
linux-graphical:
|
linux-graphical:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
|
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
|
||||||
|
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj" />
|
||||||
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
|
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
|
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
|
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -293,7 +293,11 @@ src/
|
||||||
Installation/ -> portable four-DAT validation, Windows retail
|
Installation/ -> portable four-DAT validation, Windows retail
|
||||||
path discovery, versioned JSONL bake-process
|
path discovery, versioned JSONL bake-process
|
||||||
orchestration, and atomic SHA/size/tool-version
|
orchestration, and atomic SHA/size/tool-version
|
||||||
install-record verification and recovery
|
install-record verification and recovery; one
|
||||||
|
OS-handle lease serializes recovery/install per
|
||||||
|
DataDirectory, and only exact adjacent
|
||||||
|
`.<pak>.acdream-bake.<guid:N>.tmp` files are
|
||||||
|
transaction-owned crash residue
|
||||||
-> references Platform only; no Avalonia or game-host dependency
|
-> references Platform only; no Avalonia or game-host dependency
|
||||||
|
|
||||||
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
|
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
|
||||||
|
|
@ -301,6 +305,8 @@ src/
|
||||||
including the first-run DAT/bake wizard
|
including the first-run DAT/bake wizard
|
||||||
-> references Launcher.Core only (Platform transitively); it never owns
|
-> references Launcher.Core only (Platform transitively); it never owns
|
||||||
a second profile, process, status, or credential state graph
|
a second profile, process, status, or credential state graph
|
||||||
|
-> every per-RID publish composes the separately published self-contained
|
||||||
|
`acdream-bake` executable beside the launcher without a project edge
|
||||||
-> Linux launcher/probe/headless flows remain portable; graphical-client
|
-> Linux launcher/probe/headless flows remain portable; graphical-client
|
||||||
actions are explicitly disabled until Modern Runtime Slice L resumes
|
actions are explicitly disabled until Modern Runtime Slice L resumes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,15 @@ internal static class BakeCommandLine
|
||||||
internal const string Usage =
|
internal const string Usage =
|
||||||
"usage: acdream-bake --dat-dir <path> [--out <file>] "
|
"usage: acdream-bake --dat-dir <path> [--out <file>] "
|
||||||
+ "[--ids 0xId,0xId,...] [--landblocks 0xId,...] "
|
+ "[--ids 0xId,0xId,...] [--landblocks 0xId,...] "
|
||||||
+ "[--threads <n>] [--progress-json]";
|
+ "[--threads <n>] [--progress-json]\n"
|
||||||
|
+ " acdream-bake --help";
|
||||||
|
|
||||||
|
public static bool IsHelpRequest(IReadOnlyList<string> args)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(args);
|
||||||
|
return args.Count == 1
|
||||||
|
&& args[0] is "--help" or "-h";
|
||||||
|
}
|
||||||
|
|
||||||
public static bool TryParse(
|
public static bool TryParse(
|
||||||
IReadOnlyList<string> args,
|
IReadOnlyList<string> args,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ namespace AcDream.Bake;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BakeOutputTransaction
|
public static class BakeOutputTransaction
|
||||||
{
|
{
|
||||||
|
internal const string StagingMarker = ".acdream-bake.";
|
||||||
|
|
||||||
public static TResult WriteValidateAndPublish<TResult>(
|
public static TResult WriteValidateAndPublish<TResult>(
|
||||||
string destinationPath,
|
string destinationPath,
|
||||||
Func<string, TResult> writeTemporary,
|
Func<string, TResult> writeTemporary,
|
||||||
|
|
@ -25,9 +27,7 @@ public static class BakeOutputTransaction
|
||||||
throw new InvalidOperationException("destination has no parent directory");
|
throw new InvalidOperationException("destination has no parent directory");
|
||||||
Directory.CreateDirectory(directory);
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
string temporaryPath = Path.Combine(
|
string temporaryPath = CreateStagingPath(fullDestination, Guid.NewGuid());
|
||||||
directory,
|
|
||||||
$".{Path.GetFileName(fullDestination)}.{Guid.NewGuid():N}.tmp");
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -60,4 +60,21 @@ public static class BakeOutputTransaction
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exact adjacent staging-name contract shared, by documentation and
|
||||||
|
/// conformance tests, with Launcher.Core. Keeping this tiny contract in
|
||||||
|
/// each BCL-facing assembly avoids an otherwise inverted project edge.
|
||||||
|
/// </summary>
|
||||||
|
internal static string CreateStagingPath(string destinationPath, Guid transactionId)
|
||||||
|
{
|
||||||
|
string fullDestination = Path.GetFullPath(destinationPath);
|
||||||
|
string directory = Path.GetDirectoryName(fullDestination)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"destination has no parent directory");
|
||||||
|
return Path.Combine(
|
||||||
|
directory,
|
||||||
|
$".{Path.GetFileName(fullDestination)}{StagingMarker}"
|
||||||
|
+ $"{transactionId:N}.tmp");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ using AcDream.Bake;
|
||||||
//
|
//
|
||||||
// Plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5.
|
// Plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5.
|
||||||
|
|
||||||
|
if (BakeCommandLine.IsHelpRequest(args))
|
||||||
|
{
|
||||||
|
Console.Out.WriteLine(BakeCommandLine.Usage);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (!BakeCommandLine.TryParse(args, Console.Error, out BakeCommandLineOptions? command))
|
if (!BakeCommandLine.TryParse(args, Console.Error, out BakeCommandLineOptions? command))
|
||||||
{
|
{
|
||||||
return 2;
|
return 2;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
namespace AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exact adjacent temporary-file contract emitted by AcDream.Bake's
|
||||||
|
/// BakeOutputTransaction. This class intentionally has no Bake project
|
||||||
|
/// dependency: both sides pin the same documented format with conformance
|
||||||
|
/// tests so Launcher.Core remains BCL-only.
|
||||||
|
/// </summary>
|
||||||
|
internal static class BakeOutputStagingContract
|
||||||
|
{
|
||||||
|
internal const string StagingMarker = ".acdream-bake.";
|
||||||
|
private const string Suffix = ".tmp";
|
||||||
|
|
||||||
|
internal static string CreateStagingPath(
|
||||||
|
string destinationPath,
|
||||||
|
Guid transactionId)
|
||||||
|
{
|
||||||
|
string fullDestination = Path.GetFullPath(destinationPath);
|
||||||
|
string directory = Path.GetDirectoryName(fullDestination)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"The prepared package path has no parent directory.");
|
||||||
|
return Path.Combine(
|
||||||
|
directory,
|
||||||
|
$".{Path.GetFileName(fullDestination)}{StagingMarker}"
|
||||||
|
+ $"{transactionId:N}{Suffix}");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsOwnedStagingFileName(
|
||||||
|
string fileName,
|
||||||
|
string destinationFileName)
|
||||||
|
{
|
||||||
|
string prefix = $".{destinationFileName}{StagingMarker}";
|
||||||
|
if (!fileName.StartsWith(prefix, StringComparison.Ordinal)
|
||||||
|
|| !fileName.EndsWith(Suffix, StringComparison.Ordinal)
|
||||||
|
|| fileName.Length != prefix.Length + 32 + Suffix.Length)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadOnlySpan<char> transaction = fileName.AsSpan(prefix.Length, 32);
|
||||||
|
return Guid.TryParseExact(transaction, "N", out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void DeleteOwnedStagingFiles(string destinationPath)
|
||||||
|
{
|
||||||
|
string fullDestination = Path.GetFullPath(destinationPath);
|
||||||
|
string? directory = Path.GetDirectoryName(fullDestination);
|
||||||
|
if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string destinationFileName = Path.GetFileName(fullDestination);
|
||||||
|
foreach (string candidate in Directory.EnumerateFiles(directory))
|
||||||
|
{
|
||||||
|
if (IsOwnedStagingFileName(
|
||||||
|
Path.GetFileName(candidate),
|
||||||
|
destinationFileName))
|
||||||
|
{
|
||||||
|
LauncherInstallRecordStore.TryDelete(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs
Normal file
121
src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
namespace AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strict state machine for known v1 bake events. Human output, unknown v1
|
||||||
|
/// events, and future versions are deliberately transparent; known v1 events
|
||||||
|
/// cannot be reordered, duplicated, or appended after the first terminal.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class BakeProgressProtocol
|
||||||
|
{
|
||||||
|
private BakeProgressProtocolState _state;
|
||||||
|
|
||||||
|
internal BakeStartedEvent? Started { get; private set; }
|
||||||
|
|
||||||
|
internal BakeCompletedEvent? Completed { get; private set; }
|
||||||
|
|
||||||
|
internal BakeErrorEvent? Error { get; private set; }
|
||||||
|
|
||||||
|
internal string? Violation { get; private set; }
|
||||||
|
|
||||||
|
internal bool Observe(BakeProgressEvent progressEvent)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(progressEvent);
|
||||||
|
if (Violation is not null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (progressEvent)
|
||||||
|
{
|
||||||
|
case BakeHumanOutputEvent:
|
||||||
|
case UnknownBakeProgressEvent:
|
||||||
|
case FutureBakeProgressEvent:
|
||||||
|
return true;
|
||||||
|
case MalformedBakeProgressEvent malformed:
|
||||||
|
Reject($"Malformed bake progress: {malformed.Reason}");
|
||||||
|
return false;
|
||||||
|
case BakeStartedEvent started:
|
||||||
|
if (_state != BakeProgressProtocolState.AwaitingStarted)
|
||||||
|
{
|
||||||
|
Reject(_state == BakeProgressProtocolState.Running
|
||||||
|
? "The bake protocol emitted more than one v1 started event."
|
||||||
|
: "The bake protocol emitted a known event after its terminal event.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Started = started;
|
||||||
|
_state = BakeProgressProtocolState.Running;
|
||||||
|
return true;
|
||||||
|
case BakeWorkProgressEvent:
|
||||||
|
if (_state != BakeProgressProtocolState.Running)
|
||||||
|
{
|
||||||
|
Reject(KnownEventStateViolation("progress"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
case BakeCompletedEvent completed:
|
||||||
|
if (_state != BakeProgressProtocolState.Running)
|
||||||
|
{
|
||||||
|
Reject(KnownEventStateViolation("completed"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Completed = completed;
|
||||||
|
_state = BakeProgressProtocolState.Completed;
|
||||||
|
return true;
|
||||||
|
case BakeErrorEvent error:
|
||||||
|
if (_state != BakeProgressProtocolState.Running)
|
||||||
|
{
|
||||||
|
Reject(KnownEventStateViolation("error"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Error = error;
|
||||||
|
_state = BakeProgressProtocolState.Error;
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
Reject("The bake protocol emitted an unsupported known event.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void CompleteInput()
|
||||||
|
{
|
||||||
|
if (Violation is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_state == BakeProgressProtocolState.AwaitingStarted)
|
||||||
|
{
|
||||||
|
Reject("The bake protocol did not emit a v1 started event first.");
|
||||||
|
}
|
||||||
|
else if (_state == BakeProgressProtocolState.Running)
|
||||||
|
{
|
||||||
|
Reject("The bake protocol ended without exactly one terminal event.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string KnownEventStateViolation(string eventName) => _state switch
|
||||||
|
{
|
||||||
|
BakeProgressProtocolState.AwaitingStarted =>
|
||||||
|
$"The bake protocol emitted v1 {eventName} before v1 started.",
|
||||||
|
BakeProgressProtocolState.Running =>
|
||||||
|
$"The bake protocol emitted an invalid v1 {eventName} event.",
|
||||||
|
_ => "The bake protocol emitted a known event after its terminal event.",
|
||||||
|
};
|
||||||
|
|
||||||
|
private void Reject(string message)
|
||||||
|
{
|
||||||
|
Violation ??= message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum BakeProgressProtocolState
|
||||||
|
{
|
||||||
|
AwaitingStarted,
|
||||||
|
Running,
|
||||||
|
Completed,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
namespace AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-process ownership for every mutation or recovery of one launcher
|
||||||
|
/// DataDirectory. The persistent lock pathname is harmless; exclusivity is
|
||||||
|
/// owned by the open OS handle and therefore disappears if the process dies.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class InstallerTransactionLease : IAsyncDisposable
|
||||||
|
{
|
||||||
|
internal const string LockFileName = ".install.lock";
|
||||||
|
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
|
||||||
|
|
||||||
|
private readonly FileStream _stream;
|
||||||
|
|
||||||
|
private InstallerTransactionLease(FileStream stream)
|
||||||
|
{
|
||||||
|
_stream = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string GetLockPath(string dataDirectory) =>
|
||||||
|
Path.Combine(Path.GetFullPath(dataDirectory), LockFileName);
|
||||||
|
|
||||||
|
internal static async ValueTask<InstallerTransactionLease> AcquireAsync(
|
||||||
|
string dataDirectory,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string lockPath = GetLockPath(dataDirectory);
|
||||||
|
Directory.CreateDirectory(
|
||||||
|
Path.GetDirectoryName(lockPath)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"The installer lock path has no parent directory."));
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stream = new FileStream(
|
||||||
|
lockPath,
|
||||||
|
FileMode.OpenOrCreate,
|
||||||
|
FileAccess.ReadWrite,
|
||||||
|
FileShare.None,
|
||||||
|
bufferSize: 1,
|
||||||
|
FileOptions.None);
|
||||||
|
return new InstallerTransactionLease(stream);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
await Task.Delay(RetryDelay, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_stream.Dispose();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,10 +54,12 @@ public sealed class LauncherInstallRecordStore
|
||||||
FileIntegrity.ComputeSha256HexAsync(path, cancellationToken));
|
FileIntegrity.ComputeSha256HexAsync(path, cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
public string RecordPath => Path.Combine(_paths.DataDirectory, "install.json");
|
public string DataDirectory => Path.GetFullPath(_paths.DataDirectory);
|
||||||
|
|
||||||
|
public string RecordPath => Path.Combine(DataDirectory, "install.json");
|
||||||
|
|
||||||
public string PreparedAssetPath => Path.Combine(
|
public string PreparedAssetPath => Path.Combine(
|
||||||
_paths.DataDirectory,
|
DataDirectory,
|
||||||
"pak",
|
"pak",
|
||||||
"acdream.pak");
|
"acdream.pak");
|
||||||
|
|
||||||
|
|
@ -66,6 +68,18 @@ public sealed class LauncherInstallRecordStore
|
||||||
|
|
||||||
public async Task<InstallRecordVerification> LoadAndVerifyAsync(
|
public async Task<InstallRecordVerification> LoadAndVerifyAsync(
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using InstallerTransactionLease lease =
|
||||||
|
await InstallerTransactionLease.AcquireAsync(
|
||||||
|
DataDirectory,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return await LoadAndVerifyUnderLeaseAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<InstallRecordVerification> LoadAndVerifyUnderLeaseAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!File.Exists(RecordPath))
|
if (!File.Exists(RecordPath))
|
||||||
{
|
{
|
||||||
|
|
@ -85,11 +99,20 @@ public sealed class LauncherInstallRecordStore
|
||||||
FileShare.Read,
|
FileShare.Read,
|
||||||
bufferSize: 4096,
|
bufferSize: 4096,
|
||||||
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
|
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||||
record = await JsonSerializer.DeserializeAsync<LauncherInstallRecord>(
|
using JsonDocument document = await JsonDocument.ParseAsync(
|
||||||
stream,
|
stream,
|
||||||
SerializerOptions,
|
cancellationToken: cancellationToken)
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
JsonElement root = document.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object
|
||||||
|
|| !root.TryGetProperty("version", out JsonElement version)
|
||||||
|
|| !version.TryGetInt32(out _))
|
||||||
|
{
|
||||||
|
return Invalid(
|
||||||
|
"The install record must contain an explicit integer version.");
|
||||||
|
}
|
||||||
|
|
||||||
|
record = root.Deserialize<LauncherInstallRecord>(SerializerOptions);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
|
@ -108,7 +131,9 @@ public sealed class LauncherInstallRecordStore
|
||||||
return Invalid("The install record is empty.");
|
return Invalid("The install record is empty.");
|
||||||
}
|
}
|
||||||
|
|
||||||
string? contractError = ValidateRecordContract(record);
|
string? contractError = ValidateRecordContract(
|
||||||
|
record,
|
||||||
|
requireCanonicalSerializedPaths: true);
|
||||||
if (contractError is not null)
|
if (contractError is not null)
|
||||||
{
|
{
|
||||||
return Invalid(contractError);
|
return Invalid(contractError);
|
||||||
|
|
@ -158,9 +183,25 @@ public sealed class LauncherInstallRecordStore
|
||||||
public async Task SaveAtomicallyAsync(
|
public async Task SaveAtomicallyAsync(
|
||||||
LauncherInstallRecord record,
|
LauncherInstallRecord record,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using InstallerTransactionLease lease =
|
||||||
|
await InstallerTransactionLease.AcquireAsync(
|
||||||
|
DataDirectory,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await SaveAtomicallyUnderLeaseAsync(record, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task SaveAtomicallyUnderLeaseAsync(
|
||||||
|
LauncherInstallRecord record,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(record);
|
ArgumentNullException.ThrowIfNull(record);
|
||||||
string? contractError = ValidateRecordContract(record);
|
LauncherInstallRecord normalized = NormalizeForSave(record);
|
||||||
|
string? contractError = ValidateRecordContract(
|
||||||
|
normalized,
|
||||||
|
requireCanonicalSerializedPaths: true);
|
||||||
if (contractError is not null)
|
if (contractError is not null)
|
||||||
{
|
{
|
||||||
throw new InvalidDataException(contractError);
|
throw new InvalidDataException(contractError);
|
||||||
|
|
@ -186,7 +227,7 @@ public sealed class LauncherInstallRecordStore
|
||||||
{
|
{
|
||||||
await JsonSerializer.SerializeAsync(
|
await JsonSerializer.SerializeAsync(
|
||||||
stream,
|
stream,
|
||||||
record,
|
normalized,
|
||||||
SerializerOptions,
|
SerializerOptions,
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
@ -203,7 +244,9 @@ public sealed class LauncherInstallRecordStore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? ValidateRecordContract(LauncherInstallRecord record)
|
private string? ValidateRecordContract(
|
||||||
|
LauncherInstallRecord record,
|
||||||
|
bool requireCanonicalSerializedPaths)
|
||||||
{
|
{
|
||||||
if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
|
if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
|
||||||
{
|
{
|
||||||
|
|
@ -226,7 +269,23 @@ public sealed class LauncherInstallRecordStore
|
||||||
return "The install record contains an invalid SHA-256 digest.";
|
return "The install record contains an invalid SHA-256 digest.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(record.PreparedAssetPath))
|
||||||
|
{
|
||||||
|
return "The prepared asset path is missing.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(record.DatDirectory))
|
||||||
|
{
|
||||||
|
return "The DAT directory path is missing.";
|
||||||
|
}
|
||||||
|
|
||||||
string canonicalPreparedPath = Path.GetFullPath(PreparedAssetPath);
|
string canonicalPreparedPath = Path.GetFullPath(PreparedAssetPath);
|
||||||
|
if (requireCanonicalSerializedPaths
|
||||||
|
&& !Path.IsPathFullyQualified(record.PreparedAssetPath))
|
||||||
|
{
|
||||||
|
return "The prepared asset path must be absolute.";
|
||||||
|
}
|
||||||
|
|
||||||
string recordedPreparedPath;
|
string recordedPreparedPath;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -245,11 +304,87 @@ public sealed class LauncherInstallRecordStore
|
||||||
+ "DataDirectory/pak/acdream.pak path.";
|
+ "DataDirectory/pak/acdream.pak path.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requireCanonicalSerializedPaths
|
||||||
|
&& !CanonicalSpellingEquals(
|
||||||
|
record.PreparedAssetPath,
|
||||||
|
recordedPreparedPath,
|
||||||
|
trimEndingSeparator: false))
|
||||||
|
{
|
||||||
|
return "The prepared asset path is not canonical.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireCanonicalSerializedPaths
|
||||||
|
&& !Path.IsPathFullyQualified(record.DatDirectory))
|
||||||
|
{
|
||||||
|
return "The DAT directory path must be absolute.";
|
||||||
|
}
|
||||||
|
|
||||||
DatDirectoryValidation datValidation =
|
DatDirectoryValidation datValidation =
|
||||||
_datDirectories.Validate(record.DatDirectory);
|
_datDirectories.Validate(record.DatDirectory);
|
||||||
return datValidation.IsValid
|
if (!datValidation.IsValid)
|
||||||
? null
|
{
|
||||||
: datValidation.Message + FormatMissing(datValidation.MissingFileNames);
|
return datValidation.Message
|
||||||
|
+ FormatMissing(datValidation.MissingFileNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
return requireCanonicalSerializedPaths
|
||||||
|
&& !CanonicalSpellingEquals(
|
||||||
|
record.DatDirectory,
|
||||||
|
datValidation.Directory,
|
||||||
|
trimEndingSeparator: true)
|
||||||
|
? "The DAT directory path is not canonical."
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LauncherInstallRecord NormalizeForSave(LauncherInstallRecord record)
|
||||||
|
{
|
||||||
|
if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"Install record version {record.Version} is not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(record.PreparedAssetPath))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("The prepared asset path is missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
DatDirectoryValidation datValidation =
|
||||||
|
_datDirectories.Validate(record.DatDirectory);
|
||||||
|
if (!datValidation.IsValid)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
datValidation.Message
|
||||||
|
+ FormatMissing(datValidation.MissingFileNames));
|
||||||
|
}
|
||||||
|
|
||||||
|
string recordedPreparedPath;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
recordedPreparedPath = Path.GetFullPath(record.PreparedAssetPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException
|
||||||
|
or NotSupportedException
|
||||||
|
or PathTooLongException)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"The prepared asset path is invalid: {ex.Message}",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PathsEqual(recordedPreparedPath, PreparedAssetPath))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"The install record does not point to the launcher's canonical "
|
||||||
|
+ "DataDirectory/pak/acdream.pak path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return record with
|
||||||
|
{
|
||||||
|
Version = LauncherInstallRecord.CurrentRecordVersion,
|
||||||
|
DatDirectory = datValidation.Directory,
|
||||||
|
PreparedAssetPath = Path.GetFullPath(PreparedAssetPath),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FileVerification> VerifyFileAsync(
|
private async Task<FileVerification> VerifyFileAsync(
|
||||||
|
|
@ -317,6 +452,22 @@ public sealed class LauncherInstallRecordStore
|
||||||
? StringComparison.OrdinalIgnoreCase
|
? StringComparison.OrdinalIgnoreCase
|
||||||
: StringComparison.Ordinal);
|
: StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static bool CanonicalSpellingEquals(
|
||||||
|
string serialized,
|
||||||
|
string canonical,
|
||||||
|
bool trimEndingSeparator)
|
||||||
|
{
|
||||||
|
string candidate = trimEndingSeparator
|
||||||
|
? Path.TrimEndingDirectorySeparator(serialized)
|
||||||
|
: serialized;
|
||||||
|
return string.Equals(
|
||||||
|
candidate,
|
||||||
|
canonical,
|
||||||
|
OperatingSystem.IsWindows()
|
||||||
|
? StringComparison.OrdinalIgnoreCase
|
||||||
|
: StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
internal static void TryDelete(string path)
|
internal static void TryDelete(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -112,12 +112,27 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
public async Task<InstallRecordVerification> LoadExistingAsync(
|
public async Task<InstallRecordVerification> LoadExistingAsync(
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using InstallerTransactionLease lease =
|
||||||
|
await InstallerTransactionLease.AcquireAsync(
|
||||||
|
_recordStore.DataDirectory,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
BakeOutputStagingContract.DeleteOwnedStagingFiles(
|
||||||
|
_recordStore.PreparedAssetPath);
|
||||||
InstallRecordVerification verification = await _recordStore
|
InstallRecordVerification verification = await _recordStore
|
||||||
.LoadAndVerifyAsync(cancellationToken)
|
.LoadAndVerifyUnderLeaseAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
_verifiedRecord = verification.Record;
|
_verifiedRecord = verification.Record;
|
||||||
return verification;
|
return verification;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_installGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<LauncherInstallResult> InstallAsync(
|
public async Task<LauncherInstallResult> InstallAsync(
|
||||||
string datDirectory,
|
string datDirectory,
|
||||||
|
|
@ -135,6 +150,11 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await using InstallerTransactionLease lease =
|
||||||
|
await InstallerTransactionLease.AcquireAsync(
|
||||||
|
_recordStore.DataDirectory,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
return await InstallCoreAsync(
|
return await InstallCoreAsync(
|
||||||
datDirectory,
|
datDirectory,
|
||||||
threads,
|
threads,
|
||||||
|
|
@ -177,13 +197,11 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
|
|
||||||
string outputPath = _recordStore.PreparedAssetPath;
|
string outputPath = _recordStore.PreparedAssetPath;
|
||||||
string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath);
|
string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath);
|
||||||
if (_verifiedRecord is null)
|
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
||||||
{
|
|
||||||
InstallRecordVerification existing = await _recordStore
|
InstallRecordVerification existing = await _recordStore
|
||||||
.LoadAndVerifyAsync(cancellationToken)
|
.LoadAndVerifyUnderLeaseAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
_verifiedRecord = existing.Record;
|
_verifiedRecord = existing.Record;
|
||||||
}
|
|
||||||
|
|
||||||
Directory.CreateDirectory(
|
Directory.CreateDirectory(
|
||||||
Path.GetDirectoryName(outputPath)
|
Path.GetDirectoryName(outputPath)
|
||||||
|
|
@ -201,19 +219,14 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
}
|
}
|
||||||
|
|
||||||
var parser = new BakeProgressJsonlParser();
|
var parser = new BakeProgressJsonlParser();
|
||||||
BakeStartedEvent? started = null;
|
var protocol = new BakeProgressProtocol();
|
||||||
BakeCompletedEvent? completed = null;
|
|
||||||
string? protocolError = null;
|
|
||||||
string? childError = null;
|
|
||||||
|
|
||||||
void Observe(BakeProgressEvent progressEvent)
|
void Observe(BakeProgressEvent progressEvent)
|
||||||
{
|
{
|
||||||
|
bool accepted = protocol.Observe(progressEvent);
|
||||||
switch (progressEvent)
|
switch (progressEvent)
|
||||||
{
|
{
|
||||||
case BakeStartedEvent value:
|
case BakeWorkProgressEvent value when accepted:
|
||||||
started = value;
|
|
||||||
break;
|
|
||||||
case BakeWorkProgressEvent value:
|
|
||||||
LauncherInstallPhase phase = value.Phase switch
|
LauncherInstallPhase phase = value.Phase switch
|
||||||
{
|
{
|
||||||
"mesh" => LauncherInstallPhase.BakingMeshes,
|
"mesh" => LauncherInstallPhase.BakingMeshes,
|
||||||
|
|
@ -231,18 +244,13 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
value.Failures,
|
value.Failures,
|
||||||
value.EtaSeconds);
|
value.EtaSeconds);
|
||||||
break;
|
break;
|
||||||
case BakeCompletedEvent value:
|
case BakeErrorEvent value when accepted:
|
||||||
completed = value;
|
|
||||||
break;
|
|
||||||
case BakeErrorEvent value:
|
|
||||||
childError = value.Message;
|
|
||||||
Report(
|
Report(
|
||||||
progress,
|
progress,
|
||||||
LauncherInstallPhase.Failed,
|
LauncherInstallPhase.Failed,
|
||||||
$"Bake tool error: {value.Message}");
|
$"Bake tool error: {value.Message}");
|
||||||
break;
|
break;
|
||||||
case MalformedBakeProgressEvent value:
|
case MalformedBakeProgressEvent value:
|
||||||
protocolError ??= value.Reason;
|
|
||||||
Report(
|
Report(
|
||||||
progress,
|
progress,
|
||||||
LauncherInstallPhase.Failed,
|
LauncherInstallPhase.Failed,
|
||||||
|
|
@ -278,34 +286,35 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
{
|
{
|
||||||
Observe(progressEvent);
|
Observe(progressEvent);
|
||||||
}
|
}
|
||||||
|
protocol.CompleteInput();
|
||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (protocol.Violation is not null)
|
||||||
|
{
|
||||||
|
throw new LauncherInstallException(protocol.Violation);
|
||||||
|
}
|
||||||
|
|
||||||
if (processResult.ExitCode != 0)
|
if (processResult.ExitCode != 0)
|
||||||
{
|
{
|
||||||
throw new LauncherInstallException(
|
throw new LauncherInstallException(
|
||||||
BuildChildFailure(
|
BuildChildFailure(
|
||||||
processResult.ExitCode,
|
processResult.ExitCode,
|
||||||
childError,
|
protocol.Error?.Message,
|
||||||
processResult.StandardError));
|
processResult.StandardError));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(childError))
|
if (protocol.Error is not null)
|
||||||
{
|
{
|
||||||
throw new LauncherInstallException(
|
throw new LauncherInstallException(
|
||||||
$"The bake tool reported an error: {childError}");
|
$"The bake tool reported an error: {protocol.Error.Message}");
|
||||||
}
|
|
||||||
|
|
||||||
if (protocolError is not null)
|
|
||||||
{
|
|
||||||
throw new LauncherInstallException(
|
|
||||||
$"The bake tool emitted malformed JSON progress: {protocolError}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BakeStartedEvent? started = protocol.Started;
|
||||||
|
BakeCompletedEvent? completed = protocol.Completed;
|
||||||
if (started is null || completed is null)
|
if (started is null || completed is null)
|
||||||
{
|
{
|
||||||
throw new LauncherInstallException(
|
throw new LauncherInstallException(
|
||||||
"The bake tool exited without the required v1 started/completed "
|
"The bake protocol did not finish with a v1 completed event.");
|
||||||
+ "progress records.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (started.BakeToolVersion != completed.BakeToolVersion
|
if (started.BakeToolVersion != completed.BakeToolVersion
|
||||||
|
|
@ -355,7 +364,9 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
progress,
|
progress,
|
||||||
LauncherInstallPhase.SavingRecord,
|
LauncherInstallPhase.SavingRecord,
|
||||||
"Saving the verified install record...");
|
"Saving the verified install record...");
|
||||||
await _recordStore.SaveAtomicallyAsync(record, cancellationToken)
|
await _recordStore.SaveAtomicallyUnderLeaseAsync(
|
||||||
|
record,
|
||||||
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
_verifiedRecord = record;
|
_verifiedRecord = record;
|
||||||
|
|
@ -391,6 +402,10 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
|
|
||||||
throw new LauncherInstallException("Installation failed.", ex);
|
throw new LauncherInstallException("Installation failed.", ex);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool PreservePreviousPackage(string outputPath, string backupPath)
|
private bool PreservePreviousPackage(string outputPath, string backupPath)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
<SelfContained Condition="'$(RuntimeIdentifier)' == 'linux-x64'">true</SelfContained>
|
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||||
|
<PublishBakeTool Condition="'$(PublishBakeTool)' == ''">true</PublishBakeTool>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
@ -22,4 +23,20 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Distribution composition only: do not add a Launcher -> Bake project
|
||||||
|
reference. A per-RID launcher publish explicitly publishes the GL-free
|
||||||
|
CLI as its own self-contained single file into the same directory. -->
|
||||||
|
<Target Name="PublishCoDeployedBakeTool"
|
||||||
|
AfterTargets="Publish"
|
||||||
|
Condition="'$(RuntimeIdentifier)' != '' and '$(PublishBakeTool)' == 'true'">
|
||||||
|
<PropertyGroup>
|
||||||
|
<_BakePublishDirectory Condition="$([System.IO.Path]::IsPathRooted('$(PublishDir)'))">$(PublishDir)</_BakePublishDirectory>
|
||||||
|
<_BakePublishDirectory Condition="'$(_BakePublishDirectory)' == ''">$(MSBuildProjectDirectory)\$(PublishDir)</_BakePublishDirectory>
|
||||||
|
</PropertyGroup>
|
||||||
|
<MSBuild Projects="$(MSBuildProjectDirectory)\..\AcDream.Bake\AcDream.Bake.csproj"
|
||||||
|
Targets="Restore;Publish"
|
||||||
|
BuildInParallel="false"
|
||||||
|
Properties="Configuration=$(Configuration);RuntimeIdentifier=$(RuntimeIdentifier);SelfContained=true;PublishSingleFile=true;IncludeNativeLibrariesForSelfExtract=true;EnableSingleFileAnalyzer=false;PublishDir=$(_BakePublishDirectory);PublishBakeTool=false" />
|
||||||
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,24 @@ public sealed class BakeOutputTransactionTests : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StagingPathUsesTheDocumentedLauncherRecoveryContract()
|
||||||
|
{
|
||||||
|
string destination = Path.Combine(_directory, "pak", "acdream.pak");
|
||||||
|
Guid transaction = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef");
|
||||||
|
|
||||||
|
string staging = BakeOutputTransaction.CreateStagingPath(
|
||||||
|
destination,
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
Path.Combine(
|
||||||
|
_directory,
|
||||||
|
"pak",
|
||||||
|
".acdream.pak.acdream-bake.0123456789abcdef0123456789abcdef.tmp"),
|
||||||
|
staging);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Publish_ReplacesExistingDestinationOnlyAfterValidation()
|
public void Publish_ReplacesExistingDestinationOnlyAfterValidation()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,16 @@ namespace AcDream.Bake.Tests;
|
||||||
|
|
||||||
public sealed class BakeProgressCliTests
|
public sealed class BakeProgressCliTests
|
||||||
{
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("--help")]
|
||||||
|
[InlineData("-h")]
|
||||||
|
public void HelpIsAZeroDatArgumentProbe(string argument)
|
||||||
|
{
|
||||||
|
Assert.True(BakeCommandLine.IsHelpRequest([argument]));
|
||||||
|
Assert.False(BakeCommandLine.IsHelpRequest([argument, "extra"]));
|
||||||
|
Assert.Contains("--help", BakeCommandLine.Usage, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProgressJsonFlagIsOptInAndDefaultOutputRemainsInTheDatDirectory()
|
public void ProgressJsonFlagIsOptInAndDefaultOutputRemainsInTheDatDirectory()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -19,5 +19,11 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||||
<ProjectReference Include="..\..\src\AcDream.Platform\AcDream.Platform.csproj" />
|
<ProjectReference Include="..\..\src\AcDream.Platform\AcDream.Platform.csproj" />
|
||||||
|
<!-- Build ordering only. The crash-recovery test launches this fixture in
|
||||||
|
a separate process so the OS owns and releases the install lease. -->
|
||||||
|
<ProjectReference Include="..\AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder\AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj">
|
||||||
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
|
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||||
|
</ProjectReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||||
|
|
||||||
|
public sealed class BakeProgressProtocolTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void OneStartedProgressAndCompletedSequenceIsAccepted()
|
||||||
|
{
|
||||||
|
var protocol = new BakeProgressProtocol();
|
||||||
|
|
||||||
|
Assert.True(protocol.Observe(new BakeHumanOutputEvent("human")));
|
||||||
|
Assert.True(protocol.Observe(new UnknownBakeProgressEvent(
|
||||||
|
1,
|
||||||
|
"newMetric",
|
||||||
|
"{}")));
|
||||||
|
Assert.True(protocol.Observe(new FutureBakeProgressEvent(
|
||||||
|
2,
|
||||||
|
"started",
|
||||||
|
"{}")));
|
||||||
|
Assert.True(protocol.Observe(new BakeStartedEvent(1, 4, "pak")));
|
||||||
|
Assert.True(protocol.Observe(new BakeWorkProgressEvent(
|
||||||
|
1,
|
||||||
|
"mesh",
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1)));
|
||||||
|
Assert.True(protocol.Observe(new BakeCompletedEvent(1, 4, 100, 0)));
|
||||||
|
|
||||||
|
protocol.CompleteInput();
|
||||||
|
|
||||||
|
Assert.Null(protocol.Violation);
|
||||||
|
Assert.NotNull(protocol.Started);
|
||||||
|
Assert.NotNull(protocol.Completed);
|
||||||
|
Assert.Null(protocol.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(InvalidKnownSequences))]
|
||||||
|
public void OutOfOrderDuplicateAndPostTerminalKnownEventsAreRejected(
|
||||||
|
BakeProgressEvent[] events)
|
||||||
|
{
|
||||||
|
var protocol = new BakeProgressProtocol();
|
||||||
|
|
||||||
|
foreach (BakeProgressEvent progressEvent in events)
|
||||||
|
{
|
||||||
|
protocol.Observe(progressEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol.CompleteInput();
|
||||||
|
|
||||||
|
Assert.NotNull(protocol.Violation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ErrorTerminalCannotBeOverwrittenByContradictoryCompletion()
|
||||||
|
{
|
||||||
|
var protocol = new BakeProgressProtocol();
|
||||||
|
var failure = new BakeErrorEvent(1, "first failure");
|
||||||
|
|
||||||
|
Assert.True(protocol.Observe(new BakeStartedEvent(1, 4, null)));
|
||||||
|
Assert.True(protocol.Observe(failure));
|
||||||
|
Assert.False(protocol.Observe(new BakeCompletedEvent(1, 4, 10, 0)));
|
||||||
|
protocol.CompleteInput();
|
||||||
|
|
||||||
|
Assert.Same(failure, protocol.Error);
|
||||||
|
Assert.Null(protocol.Completed);
|
||||||
|
Assert.Contains("after", protocol.Violation, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<BakeProgressEvent[]> InvalidKnownSequences => new()
|
||||||
|
{
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeWorkProgressEvent(1, "mesh", 0, 1, 0, 0, 0),
|
||||||
|
},
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeCompletedEvent(1, 4, 10, 0),
|
||||||
|
},
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeErrorEvent(1, "before start"),
|
||||||
|
},
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeStartedEvent(1, 4, null),
|
||||||
|
new BakeStartedEvent(1, 4, null),
|
||||||
|
},
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeStartedEvent(1, 4, null),
|
||||||
|
new BakeCompletedEvent(1, 4, 10, 0),
|
||||||
|
new BakeCompletedEvent(1, 4, 10, 0),
|
||||||
|
},
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeStartedEvent(1, 4, null),
|
||||||
|
new BakeCompletedEvent(1, 4, 10, 0),
|
||||||
|
new BakeWorkProgressEvent(1, "mesh", 1, 1, 0, 1, 0),
|
||||||
|
},
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeStartedEvent(1, 4, null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,13 @@ using AcDream.Platform;
|
||||||
|
|
||||||
namespace AcDream.Launcher.Core.Tests.Installation;
|
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||||
|
|
||||||
|
[CollectionDefinition(WorkingDirectoryCollection.Name, DisableParallelization = true)]
|
||||||
|
public sealed class WorkingDirectoryCollection
|
||||||
|
{
|
||||||
|
public const string Name = "Launcher install-record working directory";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Collection(WorkingDirectoryCollection.Name)]
|
||||||
public sealed class LauncherInstallRecordStoreTests : IDisposable
|
public sealed class LauncherInstallRecordStoreTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _root = Path.Combine(
|
private readonly string _root = Path.Combine(
|
||||||
|
|
@ -134,6 +141,130 @@ public sealed class LauncherInstallRecordStoreTests : IDisposable
|
||||||
Assert.Contains("missing", verification.Status, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("missing", verification.Status, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MissingExplicitVersionIsRejectedBeforeAdmission()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
Directory.CreateDirectory(_paths.DataDirectory);
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
store.RecordPath,
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
datDirectory = Path.GetFullPath(_dats),
|
||||||
|
preparedAssetPath = Path.GetFullPath(store.PreparedAssetPath),
|
||||||
|
preparedAssetSha256 = new string('a', 64),
|
||||||
|
preparedAssetSize = 12,
|
||||||
|
bakeToolVersion =
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion,
|
||||||
|
}));
|
||||||
|
|
||||||
|
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||||
|
|
||||||
|
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
|
||||||
|
Assert.Contains("explicit", verification.Status, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveNormalizesCanonicalAbsoluteDatAndPreparedPaths()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||||
|
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
|
||||||
|
var info = new FileInfo(store.PreparedAssetPath);
|
||||||
|
var nonCanonical = new LauncherInstallRecord(
|
||||||
|
Path.Combine(_dats, "..", Path.GetFileName(_dats), "."),
|
||||||
|
Path.Combine(
|
||||||
|
Path.GetDirectoryName(store.PreparedAssetPath)!,
|
||||||
|
"..",
|
||||||
|
"pak",
|
||||||
|
Path.GetFileName(store.PreparedAssetPath)),
|
||||||
|
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||||
|
info.Length,
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||||
|
|
||||||
|
await store.SaveAtomicallyAsync(nonCanonical);
|
||||||
|
|
||||||
|
using JsonDocument document = JsonDocument.Parse(
|
||||||
|
await File.ReadAllTextAsync(store.RecordPath));
|
||||||
|
Assert.Equal(
|
||||||
|
Path.GetFullPath(_dats),
|
||||||
|
document.RootElement.GetProperty("datDirectory").GetString());
|
||||||
|
Assert.Equal(
|
||||||
|
Path.GetFullPath(store.PreparedAssetPath),
|
||||||
|
document.RootElement.GetProperty("preparedAssetPath").GetString());
|
||||||
|
Assert.Equal(
|
||||||
|
LauncherInstallRecord.CurrentRecordVersion,
|
||||||
|
document.RootElement.GetProperty("version").GetInt32());
|
||||||
|
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RelativeDatRecordCannotChangeMeaningWithWorkingDirectory()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||||
|
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
|
||||||
|
string alternateWorkingDirectory = Path.Combine(_root, "alternate-cwd");
|
||||||
|
string alternateDats = Path.Combine(alternateWorkingDirectory, "retail-dats");
|
||||||
|
CreateCompleteDatDirectory(alternateDats);
|
||||||
|
var info = new FileInfo(store.PreparedAssetPath);
|
||||||
|
var relative = new LauncherInstallRecord(
|
||||||
|
"retail-dats",
|
||||||
|
Path.GetFullPath(store.PreparedAssetPath),
|
||||||
|
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||||
|
info.Length,
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||||
|
Directory.CreateDirectory(_paths.DataDirectory);
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
store.RecordPath,
|
||||||
|
JsonSerializer.Serialize(relative, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
string originalWorkingDirectory = Environment.CurrentDirectory;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Environment.CurrentDirectory = alternateWorkingDirectory;
|
||||||
|
InstallRecordVerification verification =
|
||||||
|
await store.LoadAndVerifyAsync();
|
||||||
|
|
||||||
|
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
|
||||||
|
Assert.Contains("absolute", verification.Status, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.CurrentDirectory = originalWorkingDirectory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OlderAbsoluteButNonCanonicalDatDocumentIsRejected()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||||
|
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
|
||||||
|
var info = new FileInfo(store.PreparedAssetPath);
|
||||||
|
var nonCanonical = new LauncherInstallRecord(
|
||||||
|
Path.Combine(_dats, "..", Path.GetFileName(_dats)),
|
||||||
|
Path.GetFullPath(store.PreparedAssetPath),
|
||||||
|
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||||
|
info.Length,
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||||
|
Directory.CreateDirectory(_paths.DataDirectory);
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
store.RecordPath,
|
||||||
|
JsonSerializer.Serialize(nonCanonical, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||||
|
|
||||||
|
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
|
||||||
|
Assert.Contains("canonical", verification.Status, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartupRecoversPriorVerifiedPackageAfterInterruptedReplacement()
|
public async Task StartupRecoversPriorVerifiedPackageAfterInterruptedReplacement()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using AcDream.Launcher.Core.Integrity;
|
using AcDream.Launcher.Core.Integrity;
|
||||||
using AcDream.Launcher.Core.Installation;
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
|
@ -168,6 +169,33 @@ public sealed class LauncherInstallerTests : IDisposable
|
||||||
Assert.Equal(LauncherInstallPhase.Failed, progress[^1].Phase);
|
Assert.Equal(LauncherInstallPhase.Failed, progress[^1].Phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContradictoryTerminalCannotReplaceFirstFailureOrPriorInstall()
|
||||||
|
{
|
||||||
|
(LauncherInstaller installer, LauncherInstallRecordStore store, LauncherInstallRecord old) =
|
||||||
|
await CreateInstallerWithPriorRecordAsync(
|
||||||
|
async (request, output, _) =>
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(request.OutputPath, "contradictory output");
|
||||||
|
long bytes = new FileInfo(request.OutputPath).Length;
|
||||||
|
output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
|
||||||
|
output("{\"v\":1,\"e\":\"error\",\"message\":\"first failure\"}\n");
|
||||||
|
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4,"
|
||||||
|
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
|
||||||
|
return new BakeProcessResult(0, string.Empty);
|
||||||
|
});
|
||||||
|
|
||||||
|
LauncherInstallException exception =
|
||||||
|
await Assert.ThrowsAsync<LauncherInstallException>(
|
||||||
|
() => installer.InstallAsync(_dats, 2));
|
||||||
|
|
||||||
|
Assert.Contains("after", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal(
|
||||||
|
"previous verified package",
|
||||||
|
await File.ReadAllTextAsync(store.PreparedAssetPath));
|
||||||
|
Assert.Equal(old, (await store.LoadAndVerifyAsync()).Record);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CancellationRestoresPriorInstallAndNeverPublishesPartialOutput()
|
public async Task CancellationRestoresPriorInstallAndNeverPublishesPartialOutput()
|
||||||
{
|
{
|
||||||
|
|
@ -270,6 +298,187 @@ public sealed class LauncherInstallerTests : IDisposable
|
||||||
Assert.False(File.Exists(store.RecordPath));
|
Assert.False(File.Exists(store.RecordPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IndependentInstallersSerializeAndWaitingCancellationTouchesNothing()
|
||||||
|
{
|
||||||
|
var storeA = new LauncherInstallRecordStore(_paths);
|
||||||
|
LauncherInstallRecord old = await CreatePriorRecordAsync(storeA);
|
||||||
|
string backupPath = LauncherInstallRecordStore.GetBackupPath(
|
||||||
|
storeA.PreparedAssetPath);
|
||||||
|
var childEntered = new TaskCompletionSource(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var releaseChild = new TaskCompletionSource(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var runnerA = new FakeBakeProcessRunner(async (request, _, _) =>
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(request.OutputPath, "installer A in progress");
|
||||||
|
childEntered.SetResult();
|
||||||
|
await releaseChild.Task;
|
||||||
|
return new BakeProcessResult(1, "fixture A failed");
|
||||||
|
});
|
||||||
|
bool runnerBEntered = false;
|
||||||
|
var runnerB = new FakeBakeProcessRunner((_, _, _) =>
|
||||||
|
{
|
||||||
|
runnerBEntered = true;
|
||||||
|
return Task.FromResult(new BakeProcessResult(1, "must not run"));
|
||||||
|
});
|
||||||
|
var installerA = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
recordStore: storeA,
|
||||||
|
processRunner: runnerA);
|
||||||
|
var installerB = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
recordStore: new LauncherInstallRecordStore(_paths),
|
||||||
|
processRunner: runnerB);
|
||||||
|
|
||||||
|
Task<LauncherInstallResult> operationA =
|
||||||
|
installerA.InstallAsync(_dats, 1);
|
||||||
|
await childEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cancellationB = new CancellationTokenSource();
|
||||||
|
Task<LauncherInstallResult> operationB = installerB.InstallAsync(
|
||||||
|
_dats,
|
||||||
|
1,
|
||||||
|
cancellationToken: cancellationB.Token);
|
||||||
|
await Task.Delay(150);
|
||||||
|
cancellationB.Cancel();
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operationB);
|
||||||
|
Assert.False(runnerBEntered);
|
||||||
|
Assert.Equal(
|
||||||
|
"installer A in progress",
|
||||||
|
await File.ReadAllTextAsync(storeA.PreparedAssetPath));
|
||||||
|
Assert.Equal(
|
||||||
|
"previous verified package",
|
||||||
|
await File.ReadAllTextAsync(backupPath));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
releaseChild.TrySetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<LauncherInstallException>(() => operationA);
|
||||||
|
Assert.Equal(
|
||||||
|
"previous verified package",
|
||||||
|
await File.ReadAllTextAsync(storeA.PreparedAssetPath));
|
||||||
|
Assert.False(File.Exists(backupPath));
|
||||||
|
Assert.Equal(old, (await storeA.LoadAndVerifyAsync()).Record);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StagingCleanupDeletesOnlyExactBakeTransactionNames()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
string outputPath = store.PreparedAssetPath;
|
||||||
|
string directory = Path.GetDirectoryName(outputPath)!;
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
string owned = BakeOutputStagingContract.CreateStagingPath(
|
||||||
|
outputPath,
|
||||||
|
Guid.Parse("01234567-89ab-cdef-0123-456789abcdef"));
|
||||||
|
string canonical = outputPath;
|
||||||
|
string backup = LauncherInstallRecordStore.GetBackupPath(outputPath);
|
||||||
|
string oldPattern = Path.Combine(
|
||||||
|
directory,
|
||||||
|
$".{Path.GetFileName(outputPath)}.{Guid.NewGuid():N}.tmp");
|
||||||
|
string invalidTransaction = Path.Combine(
|
||||||
|
directory,
|
||||||
|
$".{Path.GetFileName(outputPath)}.acdream-bake.not-a-guid.tmp");
|
||||||
|
string unrelated = Path.Combine(directory, "unrelated.tmp");
|
||||||
|
Assert.Equal(
|
||||||
|
Path.Combine(
|
||||||
|
directory,
|
||||||
|
".acdream.pak.acdream-bake.0123456789abcdef0123456789abcdef.tmp"),
|
||||||
|
owned);
|
||||||
|
foreach (string path in new[]
|
||||||
|
{
|
||||||
|
owned,
|
||||||
|
canonical,
|
||||||
|
backup,
|
||||||
|
oldPattern,
|
||||||
|
invalidTransaction,
|
||||||
|
unrelated,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
File.WriteAllText(path, Path.GetFileName(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
||||||
|
|
||||||
|
Assert.False(File.Exists(owned));
|
||||||
|
Assert.True(File.Exists(canonical));
|
||||||
|
Assert.True(File.Exists(backup));
|
||||||
|
Assert.True(File.Exists(oldPattern));
|
||||||
|
Assert.True(File.Exists(invalidTransaction));
|
||||||
|
Assert.True(File.Exists(unrelated));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task KilledProcessReleasesLeaseAndRestartReclaimsOnlyBakeStaging()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
LauncherInstallRecord old = await CreatePriorRecordAsync(store);
|
||||||
|
string staging = BakeOutputStagingContract.CreateStagingPath(
|
||||||
|
store.PreparedAssetPath,
|
||||||
|
Guid.Parse("fedcba98-7654-3210-fedc-ba9876543210"));
|
||||||
|
string ready = Path.Combine(_root, "fixture-ready");
|
||||||
|
string fixtureDll = GetInstallLeaseFixturePath();
|
||||||
|
Assert.True(File.Exists(fixtureDll), $"Missing fixture: {fixtureDll}");
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo("dotnet")
|
||||||
|
{
|
||||||
|
RedirectStandardError = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add(fixtureDll);
|
||||||
|
startInfo.ArgumentList.Add(
|
||||||
|
InstallerTransactionLease.GetLockPath(store.DataDirectory));
|
||||||
|
startInfo.ArgumentList.Add(staging);
|
||||||
|
startInfo.ArgumentList.Add(ready);
|
||||||
|
using Process helper = Process.Start(startInfo)
|
||||||
|
?? throw new InvalidOperationException("Could not start lease fixture.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await WaitForFileAsync(ready, helper, TimeSpan.FromSeconds(10));
|
||||||
|
Assert.True(File.Exists(staging));
|
||||||
|
|
||||||
|
var blockedInstaller = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
recordStore: new LauncherInstallRecordStore(_paths));
|
||||||
|
using var blockedCancellation = new CancellationTokenSource(
|
||||||
|
TimeSpan.FromMilliseconds(200));
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
|
() => blockedInstaller.LoadExistingAsync(blockedCancellation.Token));
|
||||||
|
Assert.True(File.Exists(staging));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!helper.HasExited)
|
||||||
|
{
|
||||||
|
helper.Kill(entireProcessTree: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
var restarted = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
recordStore: new LauncherInstallRecordStore(_paths));
|
||||||
|
InstallRecordVerification recovered = await restarted.LoadExistingAsync();
|
||||||
|
|
||||||
|
Assert.True(recovered.IsVerified);
|
||||||
|
Assert.Equal(old, recovered.Record);
|
||||||
|
Assert.False(File.Exists(staging));
|
||||||
|
Assert.Equal(
|
||||||
|
"previous verified package",
|
||||||
|
await File.ReadAllTextAsync(store.PreparedAssetPath));
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(
|
private async Task<(
|
||||||
LauncherInstaller Installer,
|
LauncherInstaller Installer,
|
||||||
LauncherInstallRecordStore Store,
|
LauncherInstallRecordStore Store,
|
||||||
|
|
@ -303,6 +512,80 @@ public sealed class LauncherInstallerTests : IDisposable
|
||||||
return (installer, store, old);
|
return (installer, store, old);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<LauncherInstallRecord> CreatePriorRecordAsync(
|
||||||
|
LauncherInstallRecordStore store)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
store.PreparedAssetPath,
|
||||||
|
"previous verified package");
|
||||||
|
var old = new LauncherInstallRecord(
|
||||||
|
Path.GetFullPath(_dats),
|
||||||
|
Path.GetFullPath(store.PreparedAssetPath),
|
||||||
|
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||||
|
new FileInfo(store.PreparedAssetPath).Length,
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||||
|
await store.SaveAtomicallyAsync(old);
|
||||||
|
return old;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForFileAsync(
|
||||||
|
string path,
|
||||||
|
Process process,
|
||||||
|
TimeSpan timeout)
|
||||||
|
{
|
||||||
|
using var cancellation = new CancellationTokenSource(timeout);
|
||||||
|
while (!File.Exists(path))
|
||||||
|
{
|
||||||
|
if (process.HasExited)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Lease fixture exited with {process.ExitCode}: "
|
||||||
|
+ await process.StandardError.ReadToEndAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(25, cancellation.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetInstallLeaseFixturePath()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||||
|
.Parent?.Name
|
||||||
|
?? "Release";
|
||||||
|
return Path.Combine(
|
||||||
|
root,
|
||||||
|
"tests",
|
||||||
|
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder",
|
||||||
|
"bin",
|
||||||
|
configuration,
|
||||||
|
"net10.0",
|
||||||
|
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.dll");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepositoryRoot()
|
||||||
|
{
|
||||||
|
foreach (string start in new[]
|
||||||
|
{
|
||||||
|
AppContext.BaseDirectory,
|
||||||
|
Environment.CurrentDirectory,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
for (var directory = new DirectoryInfo(start);
|
||||||
|
directory is not null;
|
||||||
|
directory = directory.Parent)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||||
|
{
|
||||||
|
return directory.FullName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DirectoryNotFoundException("Could not locate repository root.");
|
||||||
|
}
|
||||||
|
|
||||||
private static void CreateCompleteDatDirectory(string directory)
|
private static void CreateCompleteDatDirectory(string directory)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(directory);
|
Directory.CreateDirectory(directory);
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,25 @@ public sealed class LauncherProjectBoundaryTests
|
||||||
Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile"));
|
Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RidPublishComposesBakeWithoutAProjectReference()
|
||||||
|
{
|
||||||
|
string project = File.ReadAllText(Path.Combine(
|
||||||
|
FindRepositoryRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.Launcher",
|
||||||
|
"AcDream.Launcher.csproj"));
|
||||||
|
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
"ProjectReference Include=\"..\\AcDream.Bake",
|
||||||
|
project,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains("PublishCoDeployedBakeTool", project, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("..\\AcDream.Bake\\AcDream.Bake.csproj", project, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("SelfContained=true", project, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("PublishSingleFile=true", project, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards()
|
public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards()
|
||||||
{
|
{
|
||||||
|
|
@ -119,6 +138,9 @@ public sealed class LauncherProjectBoundaryTests
|
||||||
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
|
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
|
||||||
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);
|
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);
|
||||||
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
|
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("acdream-bake.exe\" --help", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"$root/acdream-bake\" --help", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("test -x \"$root/acdream-bake\"", workflow, StringComparison.Ordinal);
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless",
|
"test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless",
|
||||||
workflow,
|
workflow,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue