fix(launcher): guard orphan bake publication
This commit is contained in:
parent
3f68895120
commit
208a70ac83
14 changed files with 829 additions and 55 deletions
|
|
@ -278,6 +278,9 @@ src/
|
||||||
AcDream.Platform/ BCL-only portable path contract (Campaign LA LA0)
|
AcDream.Platform/ BCL-only portable path contract (Campaign LA LA0)
|
||||||
ApplicationPathSet.cs -> shared XDG/Windows config, data, cache,
|
ApplicationPathSet.cs -> shared XDG/Windows config, data, cache,
|
||||||
plugin, screenshot, and diagnostic paths
|
plugin, screenshot, and diagnostic paths
|
||||||
|
BakePublicationGuardPaths.cs
|
||||||
|
-> shared launcher/Bake environment nonce and
|
||||||
|
adjacent publication lock/token naming contract
|
||||||
-> zero project/package references (guarded by
|
-> zero project/package references (guarded by
|
||||||
tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs);
|
tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs);
|
||||||
Runtime and App reference it directly; Headless reaches it
|
Runtime and App reference it directly; Headless reaches it
|
||||||
|
|
@ -295,7 +298,12 @@ src/
|
||||||
orchestration, and atomic SHA/size/tool-version
|
orchestration, and atomic SHA/size/tool-version
|
||||||
install-record verification and recovery; one
|
install-record verification and recovery; one
|
||||||
OS-handle lease serializes recovery/install per
|
OS-handle lease serializes recovery/install per
|
||||||
DataDirectory, and only exact adjacent
|
DataDirectory; a second OS-held publication
|
||||||
|
lock plus durable per-transaction nonce makes
|
||||||
|
late orphan Bake children irrevocably stale
|
||||||
|
before recovery, while already-authorized
|
||||||
|
promotion completes before recovery; only exact
|
||||||
|
adjacent
|
||||||
`.<pak>.acdream-bake.<guid:N>.tmp` files are
|
`.<pak>.acdream-bake.<guid:N>.tmp` files are
|
||||||
transaction-owned crash residue
|
transaction-owned crash residue
|
||||||
-> references Platform only; no Avalonia or game-host dependency
|
-> references Platform only; no Avalonia or game-host dependency
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="AcDream.Bake.Tests" />
|
<InternalsVisibleTo Include="AcDream.Bake.Tests" />
|
||||||
|
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
|
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
|
||||||
|
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,21 @@ public static class BakeOutputTransaction
|
||||||
Func<string, TResult> writeTemporary,
|
Func<string, TResult> writeTemporary,
|
||||||
Action<string, TResult> validateTemporary,
|
Action<string, TResult> validateTemporary,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
=> WriteValidateAndPublish(
|
||||||
|
destinationPath,
|
||||||
|
writeTemporary,
|
||||||
|
validateTemporary,
|
||||||
|
beforePublicationLock: null,
|
||||||
|
beforePromotion: null,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
internal static TResult WriteValidateAndPublish<TResult>(
|
||||||
|
string destinationPath,
|
||||||
|
Func<string, TResult> writeTemporary,
|
||||||
|
Action<string, TResult> validateTemporary,
|
||||||
|
Action? beforePublicationLock,
|
||||||
|
Action? beforePromotion,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
|
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
|
||||||
ArgumentNullException.ThrowIfNull(writeTemporary);
|
ArgumentNullException.ThrowIfNull(writeTemporary);
|
||||||
|
|
@ -36,6 +51,14 @@ public static class BakeOutputTransaction
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
validateTemporary(temporaryPath, result);
|
validateTemporary(temporaryPath, result);
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
beforePublicationLock?.Invoke();
|
||||||
|
using IDisposable? publication =
|
||||||
|
BakePublicationGuard.AcquireIfRequested(
|
||||||
|
fullDestination,
|
||||||
|
cancellationToken);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
beforePromotion?.Invoke();
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
// Same-volume MoveFileEx/rename is the publication primitive.
|
// Same-volume MoveFileEx/rename is the publication primitive.
|
||||||
// File.Replace additionally performs destination metadata/backup
|
// File.Replace additionally performs destination metadata/backup
|
||||||
|
|
|
||||||
80
src/AcDream.Bake/BakePublicationGuard.cs
Normal file
80
src/AcDream.Bake/BakePublicationGuard.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
namespace AcDream.Bake;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional launcher authorization checked immediately before atomic
|
||||||
|
/// publication. Standalone Bake runs have no nonce environment variable and
|
||||||
|
/// retain the original unguarded behavior.
|
||||||
|
/// </summary>
|
||||||
|
internal static class BakePublicationGuard
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
|
||||||
|
|
||||||
|
internal static IDisposable? AcquireIfRequested(
|
||||||
|
string outputPath,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? nonce = Environment.GetEnvironmentVariable(
|
||||||
|
BakePublicationGuardPaths.NonceEnvironmentVariable);
|
||||||
|
if (nonce is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BakePublicationGuardPaths.IsValidNonce(nonce))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The launcher bake publication nonce is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string lockPath = BakePublicationGuardPaths.GetPublishLockPath(
|
||||||
|
outputPath);
|
||||||
|
Directory.CreateDirectory(
|
||||||
|
Path.GetDirectoryName(lockPath)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"The bake publication lock has no parent directory."));
|
||||||
|
|
||||||
|
FileStream? lease = null;
|
||||||
|
while (lease is null)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lease = new FileStream(
|
||||||
|
lockPath,
|
||||||
|
FileMode.OpenOrCreate,
|
||||||
|
FileAccess.ReadWrite,
|
||||||
|
FileShare.None,
|
||||||
|
bufferSize: 1,
|
||||||
|
options: FileOptions.None);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
cancellationToken.WaitHandle.WaitOne(RetryDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string authorizationPath =
|
||||||
|
BakePublicationGuardPaths.GetAuthorizationPath(outputPath);
|
||||||
|
string authorized = File.Exists(authorizationPath)
|
||||||
|
? File.ReadAllText(authorizationPath)
|
||||||
|
: string.Empty;
|
||||||
|
if (!string.Equals(authorized, nonce, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"This bake process is no longer authorized to publish its output.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lease;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
lease.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests" />
|
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests" />
|
||||||
|
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
|
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -51,14 +51,22 @@ internal static class BakeOutputStagingContract
|
||||||
}
|
}
|
||||||
|
|
||||||
string destinationFileName = Path.GetFileName(fullDestination);
|
string destinationFileName = Path.GetFileName(fullDestination);
|
||||||
foreach (string candidate in Directory.EnumerateFiles(directory))
|
try
|
||||||
{
|
{
|
||||||
if (IsOwnedStagingFileName(
|
foreach (string candidate in Directory.EnumerateFiles(directory))
|
||||||
Path.GetFileName(candidate),
|
|
||||||
destinationFileName))
|
|
||||||
{
|
{
|
||||||
LauncherInstallRecordStore.TryDelete(candidate);
|
if (IsOwnedStagingFileName(
|
||||||
|
Path.GetFileName(candidate),
|
||||||
|
destinationFileName))
|
||||||
|
{
|
||||||
|
LauncherInstallRecordStore.TryDelete(candidate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Best effort: these files are never launchable. A later startup
|
||||||
|
// retries exact-name cleanup under the publication lock.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
namespace AcDream.Launcher.Core.Installation;
|
namespace AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
|
@ -9,7 +10,8 @@ public sealed record BakeProcessRequest(
|
||||||
string ExecutablePath,
|
string ExecutablePath,
|
||||||
string DatDirectory,
|
string DatDirectory,
|
||||||
string OutputPath,
|
string OutputPath,
|
||||||
int Threads)
|
int Threads,
|
||||||
|
string? PublicationNonce = null)
|
||||||
{
|
{
|
||||||
public IReadOnlyList<string> Arguments =>
|
public IReadOnlyList<string> Arguments =>
|
||||||
[
|
[
|
||||||
|
|
@ -59,19 +61,7 @@ public sealed class SystemBakeProcessRunner : IBakeProcessRunner
|
||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
ProcessStartInfo startInfo = CreateStartInfo(request);
|
||||||
{
|
|
||||||
FileName = request.ExecutablePath,
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardInput = true,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
};
|
|
||||||
foreach (string argument in request.Arguments)
|
|
||||||
{
|
|
||||||
startInfo.ArgumentList.Add(argument);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var process = new Process { StartInfo = startInfo };
|
using var process = new Process { StartInfo = startInfo };
|
||||||
if (!process.Start())
|
if (!process.Start())
|
||||||
|
|
@ -140,6 +130,42 @@ public sealed class SystemBakeProcessRunner : IBakeProcessRunner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static ProcessStartInfo CreateStartInfo(BakeProcessRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = request.ExecutablePath,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardInput = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
foreach (string argument in request.Arguments)
|
||||||
|
{
|
||||||
|
startInfo.ArgumentList.Add(argument);
|
||||||
|
}
|
||||||
|
startInfo.Environment.Remove(
|
||||||
|
BakePublicationGuardPaths.NonceEnvironmentVariable);
|
||||||
|
if (request.PublicationNonce is not null)
|
||||||
|
{
|
||||||
|
if (!BakePublicationGuardPaths.IsValidNonce(
|
||||||
|
request.PublicationNonce))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"The bake publication nonce is invalid.",
|
||||||
|
nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
startInfo.Environment[
|
||||||
|
BakePublicationGuardPaths.NonceEnvironmentVariable] =
|
||||||
|
request.PublicationNonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
return startInfo;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task PumpAsync(
|
private static async Task PumpAsync(
|
||||||
TextReader reader,
|
TextReader reader,
|
||||||
Action<string> sink,
|
Action<string> sink,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Launcher half of the environment-only Bake publication guard. Paths are
|
||||||
|
/// derived from the canonical output path, while a durable GUID nonce grants
|
||||||
|
/// one child permission to promote its already-validated adjacent staging
|
||||||
|
/// file. Every token mutation happens while the stable publication lock is
|
||||||
|
/// held.
|
||||||
|
/// </summary>
|
||||||
|
internal static class BakePublicationGuardContract
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
|
||||||
|
|
||||||
|
internal static async ValueTask<PublicationLease> AcquireAsync(
|
||||||
|
string outputPath,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string lockPath = BakePublicationGuardPaths.GetPublishLockPath(
|
||||||
|
outputPath);
|
||||||
|
Directory.CreateDirectory(
|
||||||
|
Path.GetDirectoryName(lockPath)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"The bake publication lock has no parent directory."));
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new PublicationLease(new FileStream(
|
||||||
|
lockPath,
|
||||||
|
FileMode.OpenOrCreate,
|
||||||
|
FileAccess.ReadWrite,
|
||||||
|
FileShare.None,
|
||||||
|
bufferSize: 1,
|
||||||
|
options: FileOptions.None));
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
await Task.Delay(RetryDelay, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void Authorize(
|
||||||
|
string outputPath,
|
||||||
|
string nonce,
|
||||||
|
PublicationLease lease)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(lease);
|
||||||
|
if (!BakePublicationGuardPaths.IsValidNonce(nonce))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"The bake publication nonce must be a lowercase GUID in N format.",
|
||||||
|
nameof(nonce));
|
||||||
|
}
|
||||||
|
|
||||||
|
string authorizationPath =
|
||||||
|
BakePublicationGuardPaths.GetAuthorizationPath(outputPath);
|
||||||
|
using var stream = new FileStream(
|
||||||
|
authorizationPath,
|
||||||
|
FileMode.Create,
|
||||||
|
FileAccess.Write,
|
||||||
|
FileShare.None,
|
||||||
|
bufferSize: 4096,
|
||||||
|
options: FileOptions.WriteThrough);
|
||||||
|
using var writer = new StreamWriter(stream, leaveOpen: true);
|
||||||
|
writer.Write(nonce);
|
||||||
|
writer.Flush();
|
||||||
|
stream.Flush(flushToDisk: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void Invalidate(
|
||||||
|
string outputPath,
|
||||||
|
PublicationLease lease,
|
||||||
|
string? onlyIfNonceMatches = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(lease);
|
||||||
|
string authorizationPath =
|
||||||
|
BakePublicationGuardPaths.GetAuthorizationPath(outputPath);
|
||||||
|
if (!File.Exists(authorizationPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlyIfNonceMatches is not null)
|
||||||
|
{
|
||||||
|
string current = File.ReadAllText(authorizationPath);
|
||||||
|
|
||||||
|
if (!string.Equals(
|
||||||
|
current,
|
||||||
|
onlyIfNonceMatches,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Delete(authorizationPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class PublicationLease : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly FileStream _stream;
|
||||||
|
|
||||||
|
internal PublicationLease(FileStream stream)
|
||||||
|
{
|
||||||
|
_stream = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_stream.Dispose();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -120,10 +120,9 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
_recordStore.DataDirectory,
|
_recordStore.DataDirectory,
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
BakeOutputStagingContract.DeleteOwnedStagingFiles(
|
InstallRecordVerification verification =
|
||||||
_recordStore.PreparedAssetPath);
|
await RecoverExistingUnderPublicationGuardAsync(
|
||||||
InstallRecordVerification verification = await _recordStore
|
cancellationToken)
|
||||||
.LoadAndVerifyUnderLeaseAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
_verifiedRecord = verification.Record;
|
_verifiedRecord = verification.Record;
|
||||||
return verification;
|
return verification;
|
||||||
|
|
@ -197,9 +196,8 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
|
|
||||||
string outputPath = _recordStore.PreparedAssetPath;
|
string outputPath = _recordStore.PreparedAssetPath;
|
||||||
string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath);
|
string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath);
|
||||||
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
InstallRecordVerification existing =
|
||||||
InstallRecordVerification existing = await _recordStore
|
await RecoverExistingUnderPublicationGuardAsync(cancellationToken)
|
||||||
.LoadAndVerifyUnderLeaseAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
_verifiedRecord = existing.Record;
|
_verifiedRecord = existing.Record;
|
||||||
|
|
||||||
|
|
@ -220,6 +218,7 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
|
|
||||||
var parser = new BakeProgressJsonlParser();
|
var parser = new BakeProgressJsonlParser();
|
||||||
var protocol = new BakeProgressProtocol();
|
var protocol = new BakeProgressProtocol();
|
||||||
|
string? publicationNonce = null;
|
||||||
|
|
||||||
void Observe(BakeProgressEvent progressEvent)
|
void Observe(BakeProgressEvent progressEvent)
|
||||||
{
|
{
|
||||||
|
|
@ -265,11 +264,26 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
publicationNonce = BakePublicationGuardPaths.CreateNonce();
|
||||||
|
await using (
|
||||||
|
BakePublicationGuardContract.PublicationLease publication =
|
||||||
|
await BakePublicationGuardContract.AcquireAsync(
|
||||||
|
outputPath,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
BakePublicationGuardContract.Authorize(
|
||||||
|
outputPath,
|
||||||
|
publicationNonce,
|
||||||
|
publication);
|
||||||
|
}
|
||||||
|
|
||||||
var request = new BakeProcessRequest(
|
var request = new BakeProcessRequest(
|
||||||
_bakeExecutablePath,
|
_bakeExecutablePath,
|
||||||
validation.Directory,
|
validation.Directory,
|
||||||
outputPath,
|
outputPath,
|
||||||
threads);
|
threads,
|
||||||
|
publicationNonce);
|
||||||
BakeProcessResult processResult = await _processRunner.RunAsync(
|
BakeProcessResult processResult = await _processRunner.RunAsync(
|
||||||
request,
|
request,
|
||||||
chunk =>
|
chunk =>
|
||||||
|
|
@ -370,7 +384,11 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
_verifiedRecord = record;
|
_verifiedRecord = record;
|
||||||
LauncherInstallRecordStore.TryDelete(backupPath);
|
await FinalizeSuccessfulPublicationAsync(
|
||||||
|
outputPath,
|
||||||
|
backupPath,
|
||||||
|
publicationNonce)
|
||||||
|
.ConfigureAwait(false);
|
||||||
Report(
|
Report(
|
||||||
progress,
|
progress,
|
||||||
LauncherInstallPhase.Completed,
|
LauncherInstallPhase.Completed,
|
||||||
|
|
@ -381,7 +399,12 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
RestorePreviousPackage(outputPath, backupPath, previousPreserved);
|
await FinalizeFailedPublicationAsync(
|
||||||
|
outputPath,
|
||||||
|
backupPath,
|
||||||
|
previousPreserved,
|
||||||
|
publicationNonce)
|
||||||
|
.ConfigureAwait(false);
|
||||||
Report(
|
Report(
|
||||||
progress,
|
progress,
|
||||||
LauncherInstallPhase.Cancelled,
|
LauncherInstallPhase.Cancelled,
|
||||||
|
|
@ -390,7 +413,12 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
RestorePreviousPackage(outputPath, backupPath, previousPreserved);
|
await FinalizeFailedPublicationAsync(
|
||||||
|
outputPath,
|
||||||
|
backupPath,
|
||||||
|
previousPreserved,
|
||||||
|
publicationNonce)
|
||||||
|
.ConfigureAwait(false);
|
||||||
Report(
|
Report(
|
||||||
progress,
|
progress,
|
||||||
LauncherInstallPhase.Failed,
|
LauncherInstallPhase.Failed,
|
||||||
|
|
@ -402,10 +430,62 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
|
|
||||||
throw new LauncherInstallException("Installation failed.", ex);
|
throw new LauncherInstallException("Installation failed.", ex);
|
||||||
}
|
}
|
||||||
finally
|
}
|
||||||
{
|
|
||||||
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
private async Task<InstallRecordVerification>
|
||||||
}
|
RecoverExistingUnderPublicationGuardAsync(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string outputPath = _recordStore.PreparedAssetPath;
|
||||||
|
await using BakePublicationGuardContract.PublicationLease publication =
|
||||||
|
await BakePublicationGuardContract.AcquireAsync(
|
||||||
|
outputPath,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
// Any child whose parent died before it acquired this lock is now
|
||||||
|
// irrevocably stale. A child already holding the lock must finish its
|
||||||
|
// promotion before recovery reaches this invalidation point.
|
||||||
|
BakePublicationGuardContract.Invalidate(outputPath, publication);
|
||||||
|
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
||||||
|
return await _recordStore.LoadAndVerifyUnderLeaseAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task FinalizeSuccessfulPublicationAsync(
|
||||||
|
string outputPath,
|
||||||
|
string backupPath,
|
||||||
|
string publicationNonce)
|
||||||
|
{
|
||||||
|
await using BakePublicationGuardContract.PublicationLease publication =
|
||||||
|
await BakePublicationGuardContract.AcquireAsync(
|
||||||
|
outputPath,
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
BakePublicationGuardContract.Invalidate(
|
||||||
|
outputPath,
|
||||||
|
publication,
|
||||||
|
publicationNonce);
|
||||||
|
LauncherInstallRecordStore.TryDelete(backupPath);
|
||||||
|
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task FinalizeFailedPublicationAsync(
|
||||||
|
string outputPath,
|
||||||
|
string backupPath,
|
||||||
|
bool previousPreserved,
|
||||||
|
string? publicationNonce)
|
||||||
|
{
|
||||||
|
await using BakePublicationGuardContract.PublicationLease publication =
|
||||||
|
await BakePublicationGuardContract.AcquireAsync(
|
||||||
|
outputPath,
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
BakePublicationGuardContract.Invalidate(
|
||||||
|
outputPath,
|
||||||
|
publication,
|
||||||
|
publicationNonce);
|
||||||
|
RestorePreviousPackage(outputPath, backupPath, previousPreserved);
|
||||||
|
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool PreservePreviousPackage(string outputPath, string backupPath)
|
private bool PreservePreviousPackage(string outputPath, string backupPath)
|
||||||
|
|
|
||||||
35
src/AcDream.Platform/BakePublicationGuardPaths.cs
Normal file
35
src/AcDream.Platform/BakePublicationGuardPaths.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
namespace AcDream.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Portable, versioned naming contract shared by the launcher parent and the
|
||||||
|
/// independently published bake child. The durable token grants one child
|
||||||
|
/// permission to publish while the adjacent OS-held lock serializes its final
|
||||||
|
/// promotion with launcher recovery.
|
||||||
|
/// </summary>
|
||||||
|
public static class BakePublicationGuardPaths
|
||||||
|
{
|
||||||
|
public const string NonceEnvironmentVariable =
|
||||||
|
"ACDREAM_BAKE_PUBLISH_NONCE_V1";
|
||||||
|
public const string PublishLockSuffix = ".publish.lock";
|
||||||
|
public const string AuthorizationSuffix = ".publish-token";
|
||||||
|
|
||||||
|
public static string CreateNonce() => Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
public static bool IsValidNonce(string? nonce) =>
|
||||||
|
nonce is not null
|
||||||
|
&& nonce.Length == 32
|
||||||
|
&& Guid.TryParseExact(nonce, "N", out Guid parsed)
|
||||||
|
&& string.Equals(parsed.ToString("N"), nonce, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
public static string GetPublishLockPath(string outputPath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||||
|
return Path.GetFullPath(outputPath) + PublishLockSuffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetAuthorizationPath(string outputPath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||||
|
return Path.GetFullPath(outputPath) + AuthorizationSuffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,4 +8,9 @@
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\AcDream.Bake\AcDream.Bake.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\AcDream.Platform\AcDream.Platform.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,190 @@
|
||||||
if (args.Length != 3)
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
using AcDream.Bake;
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
return args.FirstOrDefault() switch
|
||||||
{
|
{
|
||||||
return 2;
|
"hold-install-lease" => await HoldInstallLeaseAsync(args[1..]),
|
||||||
|
"orphan-parent" => await RunOrphanParentAsync(args[1..]),
|
||||||
|
"orphan-child" => RunOrphanChild(args[1..]),
|
||||||
|
_ => 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
static async Task<int> HoldInstallLeaseAsync(string[] arguments)
|
||||||
|
{
|
||||||
|
if (arguments.Length != 3)
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
string lockPath = Path.GetFullPath(arguments[0]);
|
||||||
|
string stagingPath = Path.GetFullPath(arguments[1]);
|
||||||
|
string readyPath = Path.GetFullPath(arguments[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;
|
||||||
}
|
}
|
||||||
|
|
||||||
string lockPath = Path.GetFullPath(args[0]);
|
static async Task<int> RunOrphanParentAsync(string[] arguments)
|
||||||
string stagingPath = Path.GetFullPath(args[1]);
|
{
|
||||||
string readyPath = Path.GetFullPath(args[2]);
|
if (arguments.Length != 8)
|
||||||
Directory.CreateDirectory(
|
{
|
||||||
Path.GetDirectoryName(lockPath)
|
return 2;
|
||||||
?? 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(
|
string dataDirectory = Path.GetFullPath(arguments[0]);
|
||||||
lockPath,
|
string datDirectory = Path.GetFullPath(arguments[1]);
|
||||||
FileMode.OpenOrCreate,
|
string bakeMarker = Path.GetFullPath(arguments[2]);
|
||||||
FileAccess.ReadWrite,
|
string schedule = arguments[3];
|
||||||
FileShare.None);
|
string childReadyPath = Path.GetFullPath(arguments[4]);
|
||||||
File.WriteAllText(stagingPath, "abandoned bake staging");
|
string childReleasePath = Path.GetFullPath(arguments[5]);
|
||||||
File.WriteAllText(readyPath, "ready");
|
string childPidPath = Path.GetFullPath(arguments[6]);
|
||||||
await Task.Delay(Timeout.InfiniteTimeSpan);
|
string childExitPath = Path.GetFullPath(arguments[7]);
|
||||||
return 0;
|
var paths = new ApplicationPathSet(
|
||||||
|
Path.Combine(dataDirectory, "fixture-config"),
|
||||||
|
dataDirectory,
|
||||||
|
Path.Combine(dataDirectory, "fixture-cache"),
|
||||||
|
null);
|
||||||
|
var runner = new OrphanBakeProcessRunner(
|
||||||
|
schedule,
|
||||||
|
childReadyPath,
|
||||||
|
childReleasePath,
|
||||||
|
childPidPath,
|
||||||
|
childExitPath);
|
||||||
|
var installer = new LauncherInstaller(
|
||||||
|
paths,
|
||||||
|
bakeMarker,
|
||||||
|
processRunner: runner);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await installer.InstallAsync(datDirectory, 1);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int RunOrphanChild(string[] arguments)
|
||||||
|
{
|
||||||
|
if (arguments.Length != 5)
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
string outputPath = Path.GetFullPath(arguments[0]);
|
||||||
|
string schedule = arguments[1];
|
||||||
|
string readyPath = Path.GetFullPath(arguments[2]);
|
||||||
|
string releasePath = Path.GetFullPath(arguments[3]);
|
||||||
|
string exitPath = Path.GetFullPath(arguments[4]);
|
||||||
|
Action barrier = () =>
|
||||||
|
{
|
||||||
|
File.WriteAllText(readyPath, schedule);
|
||||||
|
while (!File.Exists(releasePath))
|
||||||
|
{
|
||||||
|
Thread.Sleep(10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
int exitCode;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BakeOutputTransaction.WriteValidateAndPublish(
|
||||||
|
outputPath,
|
||||||
|
temporaryPath =>
|
||||||
|
{
|
||||||
|
File.WriteAllText(temporaryPath, "orphan replacement");
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
(temporaryPath, _) =>
|
||||||
|
{
|
||||||
|
if (File.ReadAllText(temporaryPath) != "orphan replacement")
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("staging content changed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforePublicationLock: schedule == "late" ? barrier : null,
|
||||||
|
beforePromotion: schedule == "holds" ? barrier : null,
|
||||||
|
CancellationToken.None);
|
||||||
|
exitCode = 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
File.WriteAllText(exitPath + ".error", ex.Message);
|
||||||
|
exitCode = 17;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(exitPath, exitCode.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class OrphanBakeProcessRunner(
|
||||||
|
string schedule,
|
||||||
|
string childReadyPath,
|
||||||
|
string childReleasePath,
|
||||||
|
string childPidPath,
|
||||||
|
string childExitPath) : IBakeProcessRunner
|
||||||
|
{
|
||||||
|
public async Task<BakeProcessResult> RunAsync(
|
||||||
|
BakeProcessRequest request,
|
||||||
|
Action<string> onStandardOutput,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string dotnetHost = Environment.ProcessPath
|
||||||
|
?? throw new InvalidOperationException("dotnet host path is unavailable");
|
||||||
|
string fixtureDll = Assembly.GetExecutingAssembly().Location;
|
||||||
|
var startInfo = new ProcessStartInfo(dotnetHost)
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add(fixtureDll);
|
||||||
|
startInfo.ArgumentList.Add("orphan-child");
|
||||||
|
startInfo.ArgumentList.Add(request.OutputPath);
|
||||||
|
startInfo.ArgumentList.Add(schedule);
|
||||||
|
startInfo.ArgumentList.Add(childReadyPath);
|
||||||
|
startInfo.ArgumentList.Add(childReleasePath);
|
||||||
|
startInfo.ArgumentList.Add(childExitPath);
|
||||||
|
startInfo.Environment.Remove(
|
||||||
|
BakePublicationGuardPaths.NonceEnvironmentVariable);
|
||||||
|
startInfo.Environment[
|
||||||
|
BakePublicationGuardPaths.NonceEnvironmentVariable] =
|
||||||
|
request.PublicationNonce
|
||||||
|
?? throw new InvalidOperationException("publication nonce is missing");
|
||||||
|
|
||||||
|
using Process child = Process.Start(startInfo)
|
||||||
|
?? throw new InvalidOperationException("orphan child did not start");
|
||||||
|
File.WriteAllText(
|
||||||
|
childPidPath,
|
||||||
|
child.Id.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
await child.WaitForExitAsync(cancellationToken);
|
||||||
|
if (child.ExitCode == 0)
|
||||||
|
{
|
||||||
|
long bytes = new FileInfo(request.OutputPath).Length;
|
||||||
|
onStandardOutput("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
|
||||||
|
onStandardOutput($"{{\"v\":1,\"e\":\"completed\","
|
||||||
|
+ $"\"bakeToolVersion\":4,\"outputBytes\":{bytes},"
|
||||||
|
+ "\"failures\":0}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BakeProcessResult(child.ExitCode, "orphan fixture child");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||||
|
|
||||||
|
public sealed class BakeProcessRunnerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void PublicationNonceIsEnvironmentOnlyAndVisibleArgumentsStayPinned()
|
||||||
|
{
|
||||||
|
string nonce = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef")
|
||||||
|
.ToString("N");
|
||||||
|
var request = new BakeProcessRequest(
|
||||||
|
"acdream-bake",
|
||||||
|
"retail-dats",
|
||||||
|
"data/pak/acdream.pak",
|
||||||
|
7,
|
||||||
|
nonce);
|
||||||
|
|
||||||
|
System.Diagnostics.ProcessStartInfo startInfo =
|
||||||
|
SystemBakeProcessRunner.CreateStartInfo(request);
|
||||||
|
|
||||||
|
Assert.Equal(request.Arguments, startInfo.ArgumentList);
|
||||||
|
Assert.DoesNotContain(nonce, startInfo.ArgumentList);
|
||||||
|
Assert.Equal(
|
||||||
|
nonce,
|
||||||
|
startInfo.Environment[
|
||||||
|
BakePublicationGuardPaths.NonceEnvironmentVariable]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnguardedRequestExplicitlyRemovesInheritedAuthorization()
|
||||||
|
{
|
||||||
|
var request = new BakeProcessRequest(
|
||||||
|
"acdream-bake",
|
||||||
|
"retail-dats",
|
||||||
|
"data/pak/acdream.pak",
|
||||||
|
1);
|
||||||
|
|
||||||
|
System.Diagnostics.ProcessStartInfo startInfo =
|
||||||
|
SystemBakeProcessRunner.CreateStartInfo(request);
|
||||||
|
|
||||||
|
Assert.False(startInfo.Environment.ContainsKey(
|
||||||
|
BakePublicationGuardPaths.NonceEnvironmentVariable));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -85,6 +85,11 @@ public sealed class LauncherInstallerTests : IDisposable
|
||||||
"--progress-json",
|
"--progress-json",
|
||||||
],
|
],
|
||||||
observedRequest.Arguments);
|
observedRequest.Arguments);
|
||||||
|
Assert.True(BakePublicationGuardPaths.IsValidNonce(
|
||||||
|
observedRequest.PublicationNonce));
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
observedRequest.PublicationNonce!,
|
||||||
|
observedRequest.Arguments);
|
||||||
Assert.Equal(LauncherInstallRecordStore.CurrentBakeToolVersion,
|
Assert.Equal(LauncherInstallRecordStore.CurrentBakeToolVersion,
|
||||||
result.Record.BakeToolVersion);
|
result.Record.BakeToolVersion);
|
||||||
Assert.Equal(new FileInfo(result.Record.PreparedAssetPath).Length,
|
Assert.Equal(new FileInfo(result.Record.PreparedAssetPath).Length,
|
||||||
|
|
@ -94,6 +99,9 @@ public sealed class LauncherInstallerTests : IDisposable
|
||||||
result.Record.PreparedAssetSha256);
|
result.Record.PreparedAssetSha256);
|
||||||
Assert.Contains(progress, value => value.Phase == LauncherInstallPhase.BakingMeshes);
|
Assert.Contains(progress, value => value.Phase == LauncherInstallPhase.BakingMeshes);
|
||||||
Assert.Equal(LauncherInstallPhase.Completed, progress[^1].Phase);
|
Assert.Equal(LauncherInstallPhase.Completed, progress[^1].Phase);
|
||||||
|
Assert.False(File.Exists(
|
||||||
|
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||||
|
result.Record.PreparedAssetPath)));
|
||||||
|
|
||||||
var store = new LauncherInstallRecordStore(_paths);
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||||
|
|
@ -434,6 +442,7 @@ public sealed class LauncherInstallerTests : IDisposable
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
};
|
};
|
||||||
startInfo.ArgumentList.Add(fixtureDll);
|
startInfo.ArgumentList.Add(fixtureDll);
|
||||||
|
startInfo.ArgumentList.Add("hold-install-lease");
|
||||||
startInfo.ArgumentList.Add(
|
startInfo.ArgumentList.Add(
|
||||||
InstallerTransactionLease.GetLockPath(store.DataDirectory));
|
InstallerTransactionLease.GetLockPath(store.DataDirectory));
|
||||||
startInfo.ArgumentList.Add(staging);
|
startInfo.ArgumentList.Add(staging);
|
||||||
|
|
@ -479,6 +488,144 @@ public sealed class LauncherInstallerTests : IDisposable
|
||||||
await File.ReadAllTextAsync(store.PreparedAssetPath));
|
await File.ReadAllTextAsync(store.PreparedAssetPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("holds", 0)]
|
||||||
|
[InlineData("late", 17)]
|
||||||
|
public async Task OrphanBakeCanNeverPublishAfterRestartRecovery(
|
||||||
|
string schedule,
|
||||||
|
int expectedChildExitCode)
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
LauncherInstallRecord old = await CreatePriorRecordAsync(store);
|
||||||
|
string recordBefore = await File.ReadAllTextAsync(store.RecordPath);
|
||||||
|
string control = Path.Combine(_root, "orphan-" + schedule);
|
||||||
|
Directory.CreateDirectory(control);
|
||||||
|
string ready = Path.Combine(control, "child-ready");
|
||||||
|
string release = Path.Combine(control, "child-release");
|
||||||
|
string childPid = Path.Combine(control, "child-pid");
|
||||||
|
string childExit = Path.Combine(control, "child-exit");
|
||||||
|
string fixtureDll = GetInstallLeaseFixturePath();
|
||||||
|
var startInfo = new ProcessStartInfo("dotnet")
|
||||||
|
{
|
||||||
|
RedirectStandardError = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
foreach (string argument in new[]
|
||||||
|
{
|
||||||
|
fixtureDll,
|
||||||
|
"orphan-parent",
|
||||||
|
store.DataDirectory,
|
||||||
|
_dats,
|
||||||
|
_bakeExecutable,
|
||||||
|
schedule,
|
||||||
|
ready,
|
||||||
|
release,
|
||||||
|
childPid,
|
||||||
|
childExit,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
startInfo.ArgumentList.Add(argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
using Process parent = Process.Start(startInfo)
|
||||||
|
?? throw new InvalidOperationException("Could not start orphan parent.");
|
||||||
|
int orphanPid = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await WaitForFileAsync(ready, parent, TimeSpan.FromSeconds(15));
|
||||||
|
orphanPid = int.Parse(
|
||||||
|
await File.ReadAllTextAsync(childPid),
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
Assert.True(File.Exists(
|
||||||
|
LauncherInstallRecordStore.GetBackupPath(
|
||||||
|
store.PreparedAssetPath)));
|
||||||
|
Assert.True(File.Exists(
|
||||||
|
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||||
|
store.PreparedAssetPath)));
|
||||||
|
|
||||||
|
parent.Kill(entireProcessTree: false);
|
||||||
|
await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
var restarted = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
recordStore: new LauncherInstallRecordStore(_paths));
|
||||||
|
Task<InstallRecordVerification> recovery =
|
||||||
|
restarted.LoadExistingAsync();
|
||||||
|
InstallRecordVerification recovered;
|
||||||
|
if (schedule == "holds")
|
||||||
|
{
|
||||||
|
await Task.Delay(200);
|
||||||
|
Assert.False(recovery.IsCompleted);
|
||||||
|
File.WriteAllText(release, "release");
|
||||||
|
recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15));
|
||||||
|
Assert.False(File.Exists(
|
||||||
|
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||||
|
store.PreparedAssetPath)));
|
||||||
|
File.WriteAllText(release, "release");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(recovered.IsVerified);
|
||||||
|
Assert.Equal(old, recovered.Record);
|
||||||
|
string canonicalAfterRecovery =
|
||||||
|
await File.ReadAllTextAsync(store.PreparedAssetPath);
|
||||||
|
string recordAfterRecovery =
|
||||||
|
await File.ReadAllTextAsync(store.RecordPath);
|
||||||
|
bool backupAfterRecovery = File.Exists(
|
||||||
|
LauncherInstallRecordStore.GetBackupPath(
|
||||||
|
store.PreparedAssetPath));
|
||||||
|
|
||||||
|
await WaitForFileAsync(childExit, TimeSpan.FromSeconds(15));
|
||||||
|
Assert.Equal(
|
||||||
|
expectedChildExitCode,
|
||||||
|
int.Parse(
|
||||||
|
await File.ReadAllTextAsync(childExit),
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
if (schedule == "late")
|
||||||
|
{
|
||||||
|
Assert.Contains(
|
||||||
|
"no longer authorized",
|
||||||
|
await File.ReadAllTextAsync(childExit + ".error"),
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
canonicalAfterRecovery,
|
||||||
|
await File.ReadAllTextAsync(store.PreparedAssetPath));
|
||||||
|
Assert.Equal("previous verified package", canonicalAfterRecovery);
|
||||||
|
Assert.Equal(recordBefore, recordAfterRecovery);
|
||||||
|
Assert.Equal(recordAfterRecovery, await File.ReadAllTextAsync(store.RecordPath));
|
||||||
|
Assert.Equal(
|
||||||
|
backupAfterRecovery,
|
||||||
|
File.Exists(LauncherInstallRecordStore.GetBackupPath(
|
||||||
|
store.PreparedAssetPath)));
|
||||||
|
Assert.False(backupAfterRecovery);
|
||||||
|
Assert.False(File.Exists(
|
||||||
|
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||||
|
store.PreparedAssetPath)));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.WriteAllText(release, "release");
|
||||||
|
if (!parent.HasExited)
|
||||||
|
{
|
||||||
|
parent.Kill(entireProcessTree: false);
|
||||||
|
await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orphanPid != 0 && !File.Exists(childExit))
|
||||||
|
{
|
||||||
|
TryKill(orphanPid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(
|
private async Task<(
|
||||||
LauncherInstaller Installer,
|
LauncherInstaller Installer,
|
||||||
LauncherInstallRecordStore Store,
|
LauncherInstallRecordStore Store,
|
||||||
|
|
@ -548,6 +695,33 @@ public sealed class LauncherInstallerTests : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForFileAsync(string path, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
using var cancellation = new CancellationTokenSource(timeout);
|
||||||
|
while (!File.Exists(path))
|
||||||
|
{
|
||||||
|
await Task.Delay(25, cancellation.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryKill(int processId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using Process process = Process.GetProcessById(processId);
|
||||||
|
if (!process.HasExited)
|
||||||
|
{
|
||||||
|
process.Kill(entireProcessTree: true);
|
||||||
|
process.WaitForExit(5_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// The orphan normally exits by itself; cleanup tolerates the
|
||||||
|
// expected race with Process.GetProcessById.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetInstallLeaseFixturePath()
|
private static string GetInstallLeaseFixturePath()
|
||||||
{
|
{
|
||||||
string root = FindRepositoryRoot();
|
string root = FindRepositoryRoot();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue