feat(launcher): LU1 — stop hashing 28 GB before the launcher window appears
Measured on the user's machine: %LOCALAPPDATA%\acdream\pak\acdream.pak is 29,908,271,024 bytes and SHA-256 over it takes 24.1 s at 1.16 GB/s. App .OnFrameworkInitializationCompleted ran exactly that hash synchronously, before constructing the window, and the digest came back identical to the one install.json already recorded. So the launcher took roughly half a minute to appear in order to re-confirm a fact that had not changed. A friend does not see it only because they have no package installed yet — verification short-circuits at "nothing installed" — so it would hit them the moment first-run setup finished. Startup now checks the cheap facts (size, last-write time) and skips only the hash, and only when a previous FULL hash of that same file agreed with the install record. Everything that should hash still does: install, update, the crash-recovery backup path, and a new explicit "Verify files" button. The remembered fact lives in a SIDECAR (install.verification.json), not as a new field on the install record: LauncherInstallRecordStore reads install.json with JsonUnmappedMemberHandling.Disallow, so a new property there would make an older launcher build reject the record outright and demand a fresh ~28 GB bake after a rollback. An unknown sidecar is simply ignored by builds that predate it. The cache type never throws — it sits in front of a guarantee, so every failure mode (missing, corrupt, unknown schema, unwritable) degrades to "hash it again" rather than to a failed launch. Two subtleties worth keeping: - The write time is re-read after the hash and the entry is only written when it is unchanged. A writer racing a multi-second hash would otherwise be remembered under the OLD timestamp, and the next startup would trust a digest that never covered those bytes. - A hash that disagrees with the record invalidates the entry, so a stale "verified" fact cannot outlive the evidence that produced it. Tests: PreparedAssetVerificationCacheTests (10) counts hash invocations through the store's injectable hasher and covers second-startup skip, forced full verification, touched package, same-size silent corruption, resize, a cache digest that disagrees with the record, three unreadable-cache shapes, and backup recovery still hashing. Plus two LauncherWindowViewModel tests for the Verify files command. Launcher.Core 335 passed, Launcher 57 passed. Note for the first run after this ships: the very first startup still pays one full hash to learn the digest for the installed file, and every startup after that is instant. Campaign LU slice LU1. Plan: docs/plans/2026-08-19-launcher-usability-campaign.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a34e8f2a17
commit
00d1278228
8 changed files with 665 additions and 22 deletions
|
|
@ -338,6 +338,10 @@ public sealed class LauncherWindowViewModelTests
|
|||
var installer = new FakeLauncherInstaller();
|
||||
using var viewModel = CreateInitialized(orchestrator, installer);
|
||||
|
||||
// LU1: nothing has asked for a verification yet. Startup's own
|
||||
// verification happens in the composition root, not here.
|
||||
Assert.Empty(installer.LoadExistingCalls);
|
||||
|
||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
|
||||
Assert.Equal(installer.DetectedDirectory, viewModel.FirstRunWizardShell.DatDirectory);
|
||||
|
|
@ -433,6 +437,77 @@ public sealed class LauncherWindowViewModelTests
|
|||
Assert.True(orchestrator.ClearCalled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LU1. Ordinary startup trusts a remembered digest so the window is not
|
||||
/// held behind a multi-second hash of a ~28 GiB file; "Verify files" is
|
||||
/// the deliberate way to make it read the whole package again, so it must
|
||||
/// force the full hash rather than hit the same fast path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task VerifyFilesForcesAFullHashAndPublishesTheResult()
|
||||
{
|
||||
// Verification is gated on no session running, same as install and
|
||||
// update: a failed verification clears the install record, and doing
|
||||
// that under a live client would be incoherent.
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
Session = FakeLauncherOrchestrator.CreateSession(
|
||||
LauncherActivityState.Exited,
|
||||
"Exited cleanly."),
|
||||
};
|
||||
var record = new LauncherInstallRecord(
|
||||
"C:/dats",
|
||||
"C:/data/pak/acdream.pak",
|
||||
new string('a', 64),
|
||||
4096,
|
||||
4);
|
||||
var installer = new FakeLauncherInstaller
|
||||
{
|
||||
NextVerification = new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Verified,
|
||||
record,
|
||||
"Client content verified."),
|
||||
};
|
||||
using var viewModel = CreateInitialized(orchestrator, installer);
|
||||
|
||||
await viewModel.VerifyContentCommand.ExecuteAsync();
|
||||
|
||||
Assert.Equal([true], installer.LoadExistingCalls);
|
||||
Assert.Same(record, orchestrator.InstalledRecord);
|
||||
Assert.Equal("Client content verified.", viewModel.OperationStatus);
|
||||
Assert.Null(viewModel.LastError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LU1. A package that fails its full verification must clear the install
|
||||
/// record — the launcher may not start a client against content it just
|
||||
/// proved is wrong — and must say why.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task VerifyFilesClearsTheInstallRecordWhenVerificationFails()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
Session = FakeLauncherOrchestrator.CreateSession(
|
||||
LauncherActivityState.Exited,
|
||||
"Exited cleanly."),
|
||||
};
|
||||
var installer = new FakeLauncherInstaller
|
||||
{
|
||||
NextVerification = new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Invalid,
|
||||
null,
|
||||
"The prepared package SHA-256 does not match the install record."),
|
||||
};
|
||||
using var viewModel = CreateInitialized(orchestrator, installer);
|
||||
|
||||
await viewModel.VerifyContentCommand.ExecuteAsync();
|
||||
|
||||
Assert.Null(orchestrator.InstalledRecord);
|
||||
Assert.Contains("SHA-256", viewModel.OperationStatus, StringComparison.Ordinal);
|
||||
Assert.Contains("SHA-256", viewModel.LastError!, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static LauncherWindowViewModel CreateInitialized(
|
||||
FakeLauncherOrchestrator orchestrator,
|
||||
ILauncherInstaller? installer = null)
|
||||
|
|
@ -730,12 +805,26 @@ public sealed class LauncherWindowViewModelTests
|
|||
"The DAT directory is incomplete.",
|
||||
DatDirectoryLocator.RequiredFileNames);
|
||||
|
||||
public Task<InstallRecordVerification> LoadExistingAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new InstallRecordVerification(
|
||||
/// <summary>LU1: what the next <see cref="LoadExistingAsync"/> returns.
|
||||
/// Defaults to "not installed", which is what every pre-existing test
|
||||
/// in this file expects.</summary>
|
||||
public InstallRecordVerification NextVerification { get; set; } =
|
||||
new(
|
||||
InstallRecordVerificationState.Missing,
|
||||
null,
|
||||
"Client content is not installed."));
|
||||
"Client content is not installed.");
|
||||
|
||||
/// <summary>LU1: the <c>forceFullVerification</c> values this fake was
|
||||
/// called with, oldest first.</summary>
|
||||
public List<bool> LoadExistingCalls { get; } = [];
|
||||
|
||||
public Task<InstallRecordVerification> LoadExistingAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false)
|
||||
{
|
||||
LoadExistingCalls.Add(forceFullVerification);
|
||||
return Task.FromResult(NextVerification);
|
||||
}
|
||||
|
||||
public Task<LauncherInstallResult> InstallAsync(
|
||||
string datDirectory,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue