fix(launcher): harden installer transactions
This commit is contained in:
parent
ff6ebb6a6a
commit
3f68895120
21 changed files with 1164 additions and 61 deletions
|
|
@ -15,7 +15,15 @@ internal static class BakeCommandLine
|
|||
internal const string Usage =
|
||||
"usage: acdream-bake --dat-dir <path> [--out <file>] "
|
||||
+ "[--ids 0xId,0xId,...] [--landblocks 0xId,...] "
|
||||
+ "[--threads <n>] [--progress-json]";
|
||||
+ "[--threads <n>] [--progress-json]\n"
|
||||
+ " acdream-bake --help";
|
||||
|
||||
public static bool IsHelpRequest(IReadOnlyList<string> args)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(args);
|
||||
return args.Count == 1
|
||||
&& args[0] is "--help" or "-h";
|
||||
}
|
||||
|
||||
public static bool TryParse(
|
||||
IReadOnlyList<string> args,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ namespace AcDream.Bake;
|
|||
/// </summary>
|
||||
public static class BakeOutputTransaction
|
||||
{
|
||||
internal const string StagingMarker = ".acdream-bake.";
|
||||
|
||||
public static TResult WriteValidateAndPublish<TResult>(
|
||||
string destinationPath,
|
||||
Func<string, TResult> writeTemporary,
|
||||
|
|
@ -25,9 +27,7 @@ public static class BakeOutputTransaction
|
|||
throw new InvalidOperationException("destination has no parent directory");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
string temporaryPath = Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(fullDestination)}.{Guid.NewGuid():N}.tmp");
|
||||
string temporaryPath = CreateStagingPath(fullDestination, Guid.NewGuid());
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -60,4 +60,21 @@ public static class BakeOutputTransaction
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exact adjacent staging-name contract shared, by documentation and
|
||||
/// conformance tests, with Launcher.Core. Keeping this tiny contract in
|
||||
/// each BCL-facing assembly avoids an otherwise inverted project edge.
|
||||
/// </summary>
|
||||
internal static string CreateStagingPath(string destinationPath, Guid transactionId)
|
||||
{
|
||||
string fullDestination = Path.GetFullPath(destinationPath);
|
||||
string directory = Path.GetDirectoryName(fullDestination)
|
||||
?? throw new InvalidOperationException(
|
||||
"destination has no parent directory");
|
||||
return Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(fullDestination)}{StagingMarker}"
|
||||
+ $"{transactionId:N}.tmp");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
public string RecordPath => Path.Combine(_paths.DataDirectory, "install.json");
|
||||
public string DataDirectory => Path.GetFullPath(_paths.DataDirectory);
|
||||
|
||||
public string RecordPath => Path.Combine(DataDirectory, "install.json");
|
||||
|
||||
public string PreparedAssetPath => Path.Combine(
|
||||
_paths.DataDirectory,
|
||||
DataDirectory,
|
||||
"pak",
|
||||
"acdream.pak");
|
||||
|
||||
|
|
@ -66,6 +68,18 @@ public sealed class LauncherInstallRecordStore
|
|||
|
||||
public async Task<InstallRecordVerification> LoadAndVerifyAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using InstallerTransactionLease lease =
|
||||
await InstallerTransactionLease.AcquireAsync(
|
||||
DataDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return await LoadAndVerifyUnderLeaseAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async Task<InstallRecordVerification> LoadAndVerifyUnderLeaseAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(RecordPath))
|
||||
{
|
||||
|
|
@ -85,11 +99,20 @@ public sealed class LauncherInstallRecordStore
|
|||
FileShare.Read,
|
||||
bufferSize: 4096,
|
||||
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
record = await JsonSerializer.DeserializeAsync<LauncherInstallRecord>(
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(
|
||||
stream,
|
||||
SerializerOptions,
|
||||
cancellationToken)
|
||||
cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
JsonElement root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty("version", out JsonElement version)
|
||||
|| !version.TryGetInt32(out _))
|
||||
{
|
||||
return Invalid(
|
||||
"The install record must contain an explicit integer version.");
|
||||
}
|
||||
|
||||
record = root.Deserialize<LauncherInstallRecord>(SerializerOptions);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
|
@ -108,7 +131,9 @@ public sealed class LauncherInstallRecordStore
|
|||
return Invalid("The install record is empty.");
|
||||
}
|
||||
|
||||
string? contractError = ValidateRecordContract(record);
|
||||
string? contractError = ValidateRecordContract(
|
||||
record,
|
||||
requireCanonicalSerializedPaths: true);
|
||||
if (contractError is not null)
|
||||
{
|
||||
return Invalid(contractError);
|
||||
|
|
@ -158,9 +183,25 @@ public sealed class LauncherInstallRecordStore
|
|||
public async Task SaveAtomicallyAsync(
|
||||
LauncherInstallRecord record,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using InstallerTransactionLease lease =
|
||||
await InstallerTransactionLease.AcquireAsync(
|
||||
DataDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SaveAtomicallyUnderLeaseAsync(record, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async Task SaveAtomicallyUnderLeaseAsync(
|
||||
LauncherInstallRecord record,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
string? contractError = ValidateRecordContract(record);
|
||||
LauncherInstallRecord normalized = NormalizeForSave(record);
|
||||
string? contractError = ValidateRecordContract(
|
||||
normalized,
|
||||
requireCanonicalSerializedPaths: true);
|
||||
if (contractError is not null)
|
||||
{
|
||||
throw new InvalidDataException(contractError);
|
||||
|
|
@ -186,7 +227,7 @@ public sealed class LauncherInstallRecordStore
|
|||
{
|
||||
await JsonSerializer.SerializeAsync(
|
||||
stream,
|
||||
record,
|
||||
normalized,
|
||||
SerializerOptions,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
|
@ -203,7 +244,9 @@ public sealed class LauncherInstallRecordStore
|
|||
}
|
||||
}
|
||||
|
||||
private string? ValidateRecordContract(LauncherInstallRecord record)
|
||||
private string? ValidateRecordContract(
|
||||
LauncherInstallRecord record,
|
||||
bool requireCanonicalSerializedPaths)
|
||||
{
|
||||
if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
|
||||
{
|
||||
|
|
@ -226,7 +269,23 @@ public sealed class LauncherInstallRecordStore
|
|||
return "The install record contains an invalid SHA-256 digest.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(record.PreparedAssetPath))
|
||||
{
|
||||
return "The prepared asset path is missing.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(record.DatDirectory))
|
||||
{
|
||||
return "The DAT directory path is missing.";
|
||||
}
|
||||
|
||||
string canonicalPreparedPath = Path.GetFullPath(PreparedAssetPath);
|
||||
if (requireCanonicalSerializedPaths
|
||||
&& !Path.IsPathFullyQualified(record.PreparedAssetPath))
|
||||
{
|
||||
return "The prepared asset path must be absolute.";
|
||||
}
|
||||
|
||||
string recordedPreparedPath;
|
||||
try
|
||||
{
|
||||
|
|
@ -245,11 +304,87 @@ public sealed class LauncherInstallRecordStore
|
|||
+ "DataDirectory/pak/acdream.pak path.";
|
||||
}
|
||||
|
||||
if (requireCanonicalSerializedPaths
|
||||
&& !CanonicalSpellingEquals(
|
||||
record.PreparedAssetPath,
|
||||
recordedPreparedPath,
|
||||
trimEndingSeparator: false))
|
||||
{
|
||||
return "The prepared asset path is not canonical.";
|
||||
}
|
||||
|
||||
if (requireCanonicalSerializedPaths
|
||||
&& !Path.IsPathFullyQualified(record.DatDirectory))
|
||||
{
|
||||
return "The DAT directory path must be absolute.";
|
||||
}
|
||||
|
||||
DatDirectoryValidation datValidation =
|
||||
_datDirectories.Validate(record.DatDirectory);
|
||||
return datValidation.IsValid
|
||||
? null
|
||||
: datValidation.Message + FormatMissing(datValidation.MissingFileNames);
|
||||
if (!datValidation.IsValid)
|
||||
{
|
||||
return datValidation.Message
|
||||
+ FormatMissing(datValidation.MissingFileNames);
|
||||
}
|
||||
|
||||
return requireCanonicalSerializedPaths
|
||||
&& !CanonicalSpellingEquals(
|
||||
record.DatDirectory,
|
||||
datValidation.Directory,
|
||||
trimEndingSeparator: true)
|
||||
? "The DAT directory path is not canonical."
|
||||
: null;
|
||||
}
|
||||
|
||||
private LauncherInstallRecord NormalizeForSave(LauncherInstallRecord record)
|
||||
{
|
||||
if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Install record version {record.Version} is not supported.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(record.PreparedAssetPath))
|
||||
{
|
||||
throw new InvalidDataException("The prepared asset path is missing.");
|
||||
}
|
||||
|
||||
DatDirectoryValidation datValidation =
|
||||
_datDirectories.Validate(record.DatDirectory);
|
||||
if (!datValidation.IsValid)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
datValidation.Message
|
||||
+ FormatMissing(datValidation.MissingFileNames));
|
||||
}
|
||||
|
||||
string recordedPreparedPath;
|
||||
try
|
||||
{
|
||||
recordedPreparedPath = Path.GetFullPath(record.PreparedAssetPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException
|
||||
or NotSupportedException
|
||||
or PathTooLongException)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The prepared asset path is invalid: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
|
||||
if (!PathsEqual(recordedPreparedPath, PreparedAssetPath))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The install record does not point to the launcher's canonical "
|
||||
+ "DataDirectory/pak/acdream.pak path.");
|
||||
}
|
||||
|
||||
return record with
|
||||
{
|
||||
Version = LauncherInstallRecord.CurrentRecordVersion,
|
||||
DatDirectory = datValidation.Directory,
|
||||
PreparedAssetPath = Path.GetFullPath(PreparedAssetPath),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<FileVerification> VerifyFileAsync(
|
||||
|
|
@ -317,6 +452,22 @@ public sealed class LauncherInstallRecordStore
|
|||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal);
|
||||
|
||||
private static bool CanonicalSpellingEquals(
|
||||
string serialized,
|
||||
string canonical,
|
||||
bool trimEndingSeparator)
|
||||
{
|
||||
string candidate = trimEndingSeparator
|
||||
? Path.TrimEndingDirectorySeparator(serialized)
|
||||
: serialized;
|
||||
return string.Equals(
|
||||
candidate,
|
||||
canonical,
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
internal static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -112,11 +112,26 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
public async Task<InstallRecordVerification> LoadExistingAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
InstallRecordVerification verification = await _recordStore
|
||||
.LoadAndVerifyAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
_verifiedRecord = verification.Record;
|
||||
return verification;
|
||||
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using InstallerTransactionLease lease =
|
||||
await InstallerTransactionLease.AcquireAsync(
|
||||
_recordStore.DataDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
BakeOutputStagingContract.DeleteOwnedStagingFiles(
|
||||
_recordStore.PreparedAssetPath);
|
||||
InstallRecordVerification verification = await _recordStore
|
||||
.LoadAndVerifyUnderLeaseAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
_verifiedRecord = verification.Record;
|
||||
return verification;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_installGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LauncherInstallResult> InstallAsync(
|
||||
|
|
@ -135,6 +150,11 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using InstallerTransactionLease lease =
|
||||
await InstallerTransactionLease.AcquireAsync(
|
||||
_recordStore.DataDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return await InstallCoreAsync(
|
||||
datDirectory,
|
||||
threads,
|
||||
|
|
@ -177,13 +197,11 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
|
||||
string outputPath = _recordStore.PreparedAssetPath;
|
||||
string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath);
|
||||
if (_verifiedRecord is null)
|
||||
{
|
||||
InstallRecordVerification existing = await _recordStore
|
||||
.LoadAndVerifyAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
_verifiedRecord = existing.Record;
|
||||
}
|
||||
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
||||
InstallRecordVerification existing = await _recordStore
|
||||
.LoadAndVerifyUnderLeaseAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
_verifiedRecord = existing.Record;
|
||||
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(outputPath)
|
||||
|
|
@ -201,19 +219,14 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
}
|
||||
|
||||
var parser = new BakeProgressJsonlParser();
|
||||
BakeStartedEvent? started = null;
|
||||
BakeCompletedEvent? completed = null;
|
||||
string? protocolError = null;
|
||||
string? childError = null;
|
||||
var protocol = new BakeProgressProtocol();
|
||||
|
||||
void Observe(BakeProgressEvent progressEvent)
|
||||
{
|
||||
bool accepted = protocol.Observe(progressEvent);
|
||||
switch (progressEvent)
|
||||
{
|
||||
case BakeStartedEvent value:
|
||||
started = value;
|
||||
break;
|
||||
case BakeWorkProgressEvent value:
|
||||
case BakeWorkProgressEvent value when accepted:
|
||||
LauncherInstallPhase phase = value.Phase switch
|
||||
{
|
||||
"mesh" => LauncherInstallPhase.BakingMeshes,
|
||||
|
|
@ -231,18 +244,13 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
value.Failures,
|
||||
value.EtaSeconds);
|
||||
break;
|
||||
case BakeCompletedEvent value:
|
||||
completed = value;
|
||||
break;
|
||||
case BakeErrorEvent value:
|
||||
childError = value.Message;
|
||||
case BakeErrorEvent value when accepted:
|
||||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.Failed,
|
||||
$"Bake tool error: {value.Message}");
|
||||
break;
|
||||
case MalformedBakeProgressEvent value:
|
||||
protocolError ??= value.Reason;
|
||||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.Failed,
|
||||
|
|
@ -278,34 +286,35 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
{
|
||||
Observe(progressEvent);
|
||||
}
|
||||
protocol.CompleteInput();
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (protocol.Violation is not null)
|
||||
{
|
||||
throw new LauncherInstallException(protocol.Violation);
|
||||
}
|
||||
|
||||
if (processResult.ExitCode != 0)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
BuildChildFailure(
|
||||
processResult.ExitCode,
|
||||
childError,
|
||||
protocol.Error?.Message,
|
||||
processResult.StandardError));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(childError))
|
||||
if (protocol.Error is not null)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
$"The bake tool reported an error: {childError}");
|
||||
}
|
||||
|
||||
if (protocolError is not null)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
$"The bake tool emitted malformed JSON progress: {protocolError}");
|
||||
$"The bake tool reported an error: {protocol.Error.Message}");
|
||||
}
|
||||
|
||||
BakeStartedEvent? started = protocol.Started;
|
||||
BakeCompletedEvent? completed = protocol.Completed;
|
||||
if (started is null || completed is null)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
"The bake tool exited without the required v1 started/completed "
|
||||
+ "progress records.");
|
||||
"The bake protocol did not finish with a v1 completed event.");
|
||||
}
|
||||
|
||||
if (started.BakeToolVersion != completed.BakeToolVersion
|
||||
|
|
@ -355,7 +364,9 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
progress,
|
||||
LauncherInstallPhase.SavingRecord,
|
||||
"Saving the verified install record...");
|
||||
await _recordStore.SaveAtomicallyAsync(record, cancellationToken)
|
||||
await _recordStore.SaveAtomicallyUnderLeaseAsync(
|
||||
record,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_verifiedRecord = record;
|
||||
|
|
@ -391,6 +402,10 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
|
||||
throw new LauncherInstallException("Installation failed.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
private bool PreservePreviousPackage(string outputPath, string backupPath)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<SelfContained Condition="'$(RuntimeIdentifier)' == 'linux-x64'">true</SelfContained>
|
||||
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||
<PublishBakeTool Condition="'$(PublishBakeTool)' == ''">true</PublishBakeTool>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
@ -22,4 +23,20 @@
|
|||
<ItemGroup>
|
||||
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Distribution composition only: do not add a Launcher -> Bake project
|
||||
reference. A per-RID launcher publish explicitly publishes the GL-free
|
||||
CLI as its own self-contained single file into the same directory. -->
|
||||
<Target Name="PublishCoDeployedBakeTool"
|
||||
AfterTargets="Publish"
|
||||
Condition="'$(RuntimeIdentifier)' != '' and '$(PublishBakeTool)' == 'true'">
|
||||
<PropertyGroup>
|
||||
<_BakePublishDirectory Condition="$([System.IO.Path]::IsPathRooted('$(PublishDir)'))">$(PublishDir)</_BakePublishDirectory>
|
||||
<_BakePublishDirectory Condition="'$(_BakePublishDirectory)' == ''">$(MSBuildProjectDirectory)\$(PublishDir)</_BakePublishDirectory>
|
||||
</PropertyGroup>
|
||||
<MSBuild Projects="$(MSBuildProjectDirectory)\..\AcDream.Bake\AcDream.Bake.csproj"
|
||||
Targets="Restore;Publish"
|
||||
BuildInParallel="false"
|
||||
Properties="Configuration=$(Configuration);RuntimeIdentifier=$(RuntimeIdentifier);SelfContained=true;PublishSingleFile=true;IncludeNativeLibrariesForSelfExtract=true;EnableSingleFileAnalyzer=false;PublishDir=$(_BakePublishDirectory);PublishBakeTool=false" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue