fix(launcher): guard orphan bake publication

This commit is contained in:
Erik 2026-08-14 21:02:27 +02:00
parent 3f68895120
commit 208a70ac83
14 changed files with 829 additions and 55 deletions

View file

@ -12,6 +12,7 @@
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Bake.Tests" />
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder" />
</ItemGroup>
<ItemGroup>
@ -24,6 +25,7 @@
<ItemGroup>
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
</ItemGroup>
</Project>

View file

@ -16,6 +16,21 @@ public static class BakeOutputTransaction
Func<string, TResult> writeTemporary,
Action<string, TResult> validateTemporary,
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);
ArgumentNullException.ThrowIfNull(writeTemporary);
@ -36,6 +51,14 @@ public static class BakeOutputTransaction
cancellationToken.ThrowIfCancellationRequested();
validateTemporary(temporaryPath, result);
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.
// File.Replace additionally performs destination metadata/backup

View 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;
}
}
}