fix(launcher): harden installer transactions
This commit is contained in:
parent
ff6ebb6a6a
commit
3f68895120
21 changed files with 1164 additions and 61 deletions
|
|
@ -23,6 +23,24 @@ public sealed class BakeOutputTransactionTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StagingPathUsesTheDocumentedLauncherRecoveryContract()
|
||||
{
|
||||
string destination = Path.Combine(_directory, "pak", "acdream.pak");
|
||||
Guid transaction = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef");
|
||||
|
||||
string staging = BakeOutputTransaction.CreateStagingPath(
|
||||
destination,
|
||||
transaction);
|
||||
|
||||
Assert.Equal(
|
||||
Path.Combine(
|
||||
_directory,
|
||||
"pak",
|
||||
".acdream.pak.acdream-bake.0123456789abcdef0123456789abcdef.tmp"),
|
||||
staging);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_ReplacesExistingDestinationOnlyAfterValidation()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,16 @@ namespace AcDream.Bake.Tests;
|
|||
|
||||
public sealed class BakeProgressCliTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("--help")]
|
||||
[InlineData("-h")]
|
||||
public void HelpIsAZeroDatArgumentProbe(string argument)
|
||||
{
|
||||
Assert.True(BakeCommandLine.IsHelpRequest([argument]));
|
||||
Assert.False(BakeCommandLine.IsHelpRequest([argument, "extra"]));
|
||||
Assert.Contains("--help", BakeCommandLine.Usage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressJsonFlagIsOptInAndDefaultOutputRemainsInTheDatDirectory()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.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>
|
||||
</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;
|
||||
|
||||
[CollectionDefinition(WorkingDirectoryCollection.Name, DisableParallelization = true)]
|
||||
public sealed class WorkingDirectoryCollection
|
||||
{
|
||||
public const string Name = "Launcher install-record working directory";
|
||||
}
|
||||
|
||||
[Collection(WorkingDirectoryCollection.Name)]
|
||||
public sealed class LauncherInstallRecordStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
|
|
@ -134,6 +141,130 @@ public sealed class LauncherInstallRecordStoreTests : IDisposable
|
|||
Assert.Contains("missing", verification.Status, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingExplicitVersionIsRejectedBeforeAdmission()
|
||||
{
|
||||
var store = new LauncherInstallRecordStore(_paths);
|
||||
Directory.CreateDirectory(_paths.DataDirectory);
|
||||
await File.WriteAllTextAsync(
|
||||
store.RecordPath,
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
datDirectory = Path.GetFullPath(_dats),
|
||||
preparedAssetPath = Path.GetFullPath(store.PreparedAssetPath),
|
||||
preparedAssetSha256 = new string('a', 64),
|
||||
preparedAssetSize = 12,
|
||||
bakeToolVersion =
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion,
|
||||
}));
|
||||
|
||||
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||
|
||||
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
|
||||
Assert.Contains("explicit", verification.Status, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveNormalizesCanonicalAbsoluteDatAndPreparedPaths()
|
||||
{
|
||||
var store = new LauncherInstallRecordStore(_paths);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
|
||||
var info = new FileInfo(store.PreparedAssetPath);
|
||||
var nonCanonical = new LauncherInstallRecord(
|
||||
Path.Combine(_dats, "..", Path.GetFileName(_dats), "."),
|
||||
Path.Combine(
|
||||
Path.GetDirectoryName(store.PreparedAssetPath)!,
|
||||
"..",
|
||||
"pak",
|
||||
Path.GetFileName(store.PreparedAssetPath)),
|
||||
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||
info.Length,
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||
|
||||
await store.SaveAtomicallyAsync(nonCanonical);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(
|
||||
await File.ReadAllTextAsync(store.RecordPath));
|
||||
Assert.Equal(
|
||||
Path.GetFullPath(_dats),
|
||||
document.RootElement.GetProperty("datDirectory").GetString());
|
||||
Assert.Equal(
|
||||
Path.GetFullPath(store.PreparedAssetPath),
|
||||
document.RootElement.GetProperty("preparedAssetPath").GetString());
|
||||
Assert.Equal(
|
||||
LauncherInstallRecord.CurrentRecordVersion,
|
||||
document.RootElement.GetProperty("version").GetInt32());
|
||||
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RelativeDatRecordCannotChangeMeaningWithWorkingDirectory()
|
||||
{
|
||||
var store = new LauncherInstallRecordStore(_paths);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
|
||||
string alternateWorkingDirectory = Path.Combine(_root, "alternate-cwd");
|
||||
string alternateDats = Path.Combine(alternateWorkingDirectory, "retail-dats");
|
||||
CreateCompleteDatDirectory(alternateDats);
|
||||
var info = new FileInfo(store.PreparedAssetPath);
|
||||
var relative = new LauncherInstallRecord(
|
||||
"retail-dats",
|
||||
Path.GetFullPath(store.PreparedAssetPath),
|
||||
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||
info.Length,
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||
Directory.CreateDirectory(_paths.DataDirectory);
|
||||
await File.WriteAllTextAsync(
|
||||
store.RecordPath,
|
||||
JsonSerializer.Serialize(relative, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
}));
|
||||
|
||||
string originalWorkingDirectory = Environment.CurrentDirectory;
|
||||
try
|
||||
{
|
||||
Environment.CurrentDirectory = alternateWorkingDirectory;
|
||||
InstallRecordVerification verification =
|
||||
await store.LoadAndVerifyAsync();
|
||||
|
||||
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
|
||||
Assert.Contains("absolute", verification.Status, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.CurrentDirectory = originalWorkingDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OlderAbsoluteButNonCanonicalDatDocumentIsRejected()
|
||||
{
|
||||
var store = new LauncherInstallRecordStore(_paths);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
|
||||
var info = new FileInfo(store.PreparedAssetPath);
|
||||
var nonCanonical = new LauncherInstallRecord(
|
||||
Path.Combine(_dats, "..", Path.GetFileName(_dats)),
|
||||
Path.GetFullPath(store.PreparedAssetPath),
|
||||
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||
info.Length,
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||
Directory.CreateDirectory(_paths.DataDirectory);
|
||||
await File.WriteAllTextAsync(
|
||||
store.RecordPath,
|
||||
JsonSerializer.Serialize(nonCanonical, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
}));
|
||||
|
||||
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||
|
||||
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
|
||||
Assert.Contains("canonical", verification.Status, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartupRecoversPriorVerifiedPackageAfterInterruptedReplacement()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Diagnostics;
|
||||
using System.Text.Json.Nodes;
|
||||
using AcDream.Launcher.Core.Integrity;
|
||||
using AcDream.Launcher.Core.Installation;
|
||||
|
|
@ -168,6 +169,33 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
Assert.Equal(LauncherInstallPhase.Failed, progress[^1].Phase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ContradictoryTerminalCannotReplaceFirstFailureOrPriorInstall()
|
||||
{
|
||||
(LauncherInstaller installer, LauncherInstallRecordStore store, LauncherInstallRecord old) =
|
||||
await CreateInstallerWithPriorRecordAsync(
|
||||
async (request, output, _) =>
|
||||
{
|
||||
await File.WriteAllTextAsync(request.OutputPath, "contradictory output");
|
||||
long bytes = new FileInfo(request.OutputPath).Length;
|
||||
output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
|
||||
output("{\"v\":1,\"e\":\"error\",\"message\":\"first failure\"}\n");
|
||||
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4,"
|
||||
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
|
||||
return new BakeProcessResult(0, string.Empty);
|
||||
});
|
||||
|
||||
LauncherInstallException exception =
|
||||
await Assert.ThrowsAsync<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]
|
||||
public async Task CancellationRestoresPriorInstallAndNeverPublishesPartialOutput()
|
||||
{
|
||||
|
|
@ -270,6 +298,187 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
Assert.False(File.Exists(store.RecordPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IndependentInstallersSerializeAndWaitingCancellationTouchesNothing()
|
||||
{
|
||||
var storeA = new LauncherInstallRecordStore(_paths);
|
||||
LauncherInstallRecord old = await CreatePriorRecordAsync(storeA);
|
||||
string backupPath = LauncherInstallRecordStore.GetBackupPath(
|
||||
storeA.PreparedAssetPath);
|
||||
var childEntered = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseChild = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var runnerA = new FakeBakeProcessRunner(async (request, _, _) =>
|
||||
{
|
||||
await File.WriteAllTextAsync(request.OutputPath, "installer A in progress");
|
||||
childEntered.SetResult();
|
||||
await releaseChild.Task;
|
||||
return new BakeProcessResult(1, "fixture A failed");
|
||||
});
|
||||
bool runnerBEntered = false;
|
||||
var runnerB = new FakeBakeProcessRunner((_, _, _) =>
|
||||
{
|
||||
runnerBEntered = true;
|
||||
return Task.FromResult(new BakeProcessResult(1, "must not run"));
|
||||
});
|
||||
var installerA = new LauncherInstaller(
|
||||
_paths,
|
||||
_bakeExecutable,
|
||||
recordStore: storeA,
|
||||
processRunner: runnerA);
|
||||
var installerB = new LauncherInstaller(
|
||||
_paths,
|
||||
_bakeExecutable,
|
||||
recordStore: new LauncherInstallRecordStore(_paths),
|
||||
processRunner: runnerB);
|
||||
|
||||
Task<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<(
|
||||
LauncherInstaller Installer,
|
||||
LauncherInstallRecordStore Store,
|
||||
|
|
@ -303,6 +512,80 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
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)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
|
|
|
|||
|
|
@ -71,6 +71,25 @@ public sealed class LauncherProjectBoundaryTests
|
|||
Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RidPublishComposesBakeWithoutAProjectReference()
|
||||
{
|
||||
string project = File.ReadAllText(Path.Combine(
|
||||
FindRepositoryRoot(),
|
||||
"src",
|
||||
"AcDream.Launcher",
|
||||
"AcDream.Launcher.csproj"));
|
||||
|
||||
Assert.DoesNotContain(
|
||||
"ProjectReference Include=\"..\\AcDream.Bake",
|
||||
project,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("PublishCoDeployedBakeTool", project, StringComparison.Ordinal);
|
||||
Assert.Contains("..\\AcDream.Bake\\AcDream.Bake.csproj", project, StringComparison.Ordinal);
|
||||
Assert.Contains("SelfContained=true", project, StringComparison.Ordinal);
|
||||
Assert.Contains("PublishSingleFile=true", project, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards()
|
||||
{
|
||||
|
|
@ -119,6 +138,9 @@ public sealed class LauncherProjectBoundaryTests
|
|||
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("acdream-bake.exe\" --help", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("\"$root/acdream-bake\" --help", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains("test -x \"$root/acdream-bake\"", workflow, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless",
|
||||
workflow,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue