From c9a5ec67905e1fa5baee80ae3bf8e7e92f0f0d8e Mon Sep 17 00:00:00 2001 From: Views2k <292889387+Views2k@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:14:14 +0000 Subject: [PATCH] Stop automatic update retries when the update helper rejects the installation and report its code When the helper exits before it is ready, read the result file it wrote, report its error code to Tools under the install-handoff stage, and treat InvalidRequest exit codes and installation-identity failures as permanent for the session, the way a missing helper already is. Show reinstall instructions when the registered install location does not match. The dashboard button still tries once per click. --- CHANGELOG.md | 4 + src/Wisp.App/App.xaml.cs | 29 ++- src/Wisp.App/AppController.cs | 16 +- src/Wisp.App/ApplicationUpdateLauncher.cs | 216 ++++++++++++++---- src/Wisp.App/AutomaticUpdateCoordinator.cs | 10 +- src/Wisp.Update/UpdateModels.cs | 5 + src/Wisp.Updater/UpdateFailure.cs | 2 +- .../ApplicationUpdateLauncherTests.cs | 126 ++++++++++ .../AutomaticUpdateCoordinatorTests.cs | 27 ++- .../CommandAndErrorTests.cs | 13 ++ .../InstalledApplicationValidatorTests.cs | 28 +++ 11 files changed, 411 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a0f6d91..a5364cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Notable changes to Wisp are recorded here. +## Unreleased + +- Report which check the update helper failed when an update cannot start, and show how to reinstall when this copy of Wisp does not match the registered installation. Automatic update attempts stop for the session after such a rejection; Install update remains available from the dashboard. + ## 2.6.13 - 2026-10-17 - Keep the HUD renderer running when a native graphics allocation fails; report the failure and restart the renderer instead of closing Wisp. diff --git a/src/Wisp.App/App.xaml.cs b/src/Wisp.App/App.xaml.cs index 969e6c3b..4687f2c9 100644 --- a/src/Wisp.App/App.xaml.cs +++ b/src/Wisp.App/App.xaml.cs @@ -645,6 +645,7 @@ internal async Task TryBeginApplicationUpdateAsync return (false, "Application updates were paused. Review the update again when available."); } ApplicationUpdateHandoff handoff; + var launchedAt = DateTimeOffset.UtcNow; try { handoff = ApplicationUpdateLauncher.Launch(installer); @@ -730,17 +731,31 @@ InvalidOperationException or ArgumentException or Win32Exception or } if (handoffState != ApplicationUpdateHandoffState.Ready) { - RecordSupplementarySignal("update", "update", handoffState == ApplicationUpdateHandoffState.TimedOut ? "timeout" : "failure", "install-handoff", - incident: Supplementary.SupplementaryIncidentSchema.Create("update", - handoffState == ApplicationUpdateHandoffState.TimedOut ? "timeout" : "update-verification-failed", "update-install-handoff")); + string error; + var helperRejected = false; + if (handoffState == ApplicationUpdateHandoffState.HelperExited) + { + // The helper records which check failed before it exits. Report that code now, + // and stop automatic attempts when the same check fails again in this session. + var exit = ApplicationUpdateLauncher.DescribeHelperExit(handoff.ExitCode, launchedAt); + RecordSupplementarySignal("update", "update", "failure", "install-handoff", incident: exit.Incident); + error = exit.Status; + helperRejected = exit.Permanent; + } + else + { + RecordSupplementarySignal("update", "update", handoffState == ApplicationUpdateHandoffState.TimedOut ? "timeout" : "failure", "install-handoff", + incident: Supplementary.SupplementaryIncidentSchema.Create("update", + handoffState == ApplicationUpdateHandoffState.TimedOut ? "timeout" : "update-verification-failed", "update-install-handoff")); + error = handoffState == ApplicationUpdateHandoffState.TimedOut + ? "The update helper did not become ready in time. Wisp stayed open and no files were installed." + : "The update helper could not verify this installation. Wisp stayed open and no files were installed."; + } handoff.StopIfRunning(); _applicationUpdateHandoffActive = false; _controller.Runs.CancelMetadataClosePreparation(); - var error = handoffState == ApplicationUpdateHandoffState.TimedOut - ? "The update helper did not become ready in time. Wisp stayed open and no files were installed." - : "The update helper could not verify this installation. Wisp stayed open and no files were installed."; _controller.ViewModel.UpdateApplicationUpdateStatus(error, "Try again", canCheck: true); - return (false, error); + return new ApplicationUpdateStartResult(false, error, HelperRejected: helperRejected); } } diff --git a/src/Wisp.App/AppController.cs b/src/Wisp.App/AppController.cs index 3adba74c..ff69928d 100644 --- a/src/Wisp.App/AppController.cs +++ b/src/Wisp.App/AppController.cs @@ -723,9 +723,19 @@ private static ApplicationUpdateDetails ApplicationUpdateDetailsFrom(UpdateRelea } } + // Once automatic installation has stopped for the session (the update helper is missing or + // rejected this installation), the periodic check points at the full installer instead of + // promising an installation that cannot happen. The buttons still try once per click. + private bool AutomaticInstallationStopped => _automaticInstaller is { HandoffUnavailable: true }; + + private static string ManualInstallStatus(SemanticVersion version) => + $"Wisp {ApplicationVersionInfo.Format(version)} could not be installed automatically. Download and run the full installer from wispoverlay.com."; + private void ShowAvailableApplicationUpdate(UpdateRelease release) => ViewModel.UpdateApplicationUpdateStatus( - $"Wisp {ApplicationVersionInfo.Format(release.Version)} is available. Wisp will update and restart after Forza closes.", + AutomaticInstallationStopped + ? ManualInstallStatus(release.Version) + : $"Wisp {ApplicationVersionInfo.Format(release.Version)} is available. Wisp will update and restart after Forza closes.", "Update", canCheck: true, isUpdateAvailable: true); @@ -737,7 +747,9 @@ private bool TryShowPendingApplicationUpdate(bool canCheck = true) return false; } ViewModel.UpdateApplicationUpdateStatus( - $"Wisp {ApplicationVersionInfo.Format(pending.Version)} is verified and ready to install.", + AutomaticInstallationStopped + ? ManualInstallStatus(pending.Version) + : $"Wisp {ApplicationVersionInfo.Format(pending.Version)} is verified and ready to install.", "Install update", canCheck, isUpdateAvailable: true); diff --git a/src/Wisp.App/ApplicationUpdateLauncher.cs b/src/Wisp.App/ApplicationUpdateLauncher.cs index c1050cd6..230064ed 100644 --- a/src/Wisp.App/ApplicationUpdateLauncher.cs +++ b/src/Wisp.App/ApplicationUpdateLauncher.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Security.Cryptography; using System.Text.Json; @@ -16,10 +17,14 @@ internal enum ApplicationUpdateHandoffState // Outcome of App.TryBeginApplicationUpdateAsync. HelperUnavailable marks a failure that no later // attempt in this session can fix: Wisp.Updater.exe is missing from the application directory. -internal readonly record struct ApplicationUpdateStartResult(bool Started, string Error, bool HelperUnavailable = false) +// HelperRejected marks a helper that started and rejected the request or this installation before +// it was ready; the same checks fail the same way for the rest of the session, so automatic +// attempts stop as well. A manual install from the dashboard still tries once per click. +internal readonly record struct ApplicationUpdateStartResult(bool Started, string Error, bool HelperUnavailable = false, + bool HelperRejected = false) { internal AutomaticUpdateApplyOutcome Outcome => Started ? AutomaticUpdateApplyOutcome.Started - : HelperUnavailable ? AutomaticUpdateApplyOutcome.Unavailable : AutomaticUpdateApplyOutcome.Retry; + : HelperUnavailable || HelperRejected ? AutomaticUpdateApplyOutcome.Unavailable : AutomaticUpdateApplyOutcome.Retry; public static implicit operator ApplicationUpdateStartResult((bool Started, string Error) result) => new(result.Started, result.Error); @@ -28,6 +33,11 @@ public static implicit operator ApplicationUpdateStartResult((bool Started, stri internal readonly record struct ApplicationUpdateResultStatus(string Status, string Action, string Outcome = "unknown", Supplementary.SupplementaryIncident? Incident = null, string? SourceVersion = null, string? TargetVersion = null); +// What the update helper reported when it exited before signalling readiness. Status is fixed local +// copy chosen from the helper's error code; text from the helper's record is never rendered. +// Permanent means the same handoff fails the same way for the rest of this session. +internal readonly record struct ApplicationUpdateHelperExit(Supplementary.SupplementaryIncident Incident, string Status, bool Permanent); + internal sealed class ApplicationUpdateHandoff : IDisposable { private readonly Process _helper; @@ -48,6 +58,24 @@ internal ApplicationUpdateHandoff(Process helper, EventWaitHandle readyEvent) } } + // The helper's exit code once it has exited; null while it runs or when the process is gone. + internal int? ExitCode + { + get + { + try + { + return _helper.HasExited ? _helper.ExitCode : null; + } + catch (Exception exception) when (exception is InvalidOperationException or + System.ComponentModel.Win32Exception or + NotSupportedException) + { + return null; + } + } + } + internal async Task WaitUntilReadyAsync(TimeSpan timeout, CancellationToken cancellation = default) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -112,6 +140,15 @@ internal static class ApplicationUpdateLauncher private const int MaximumResultBytes = 16 * 1024; private const string ResultFileName = "update-result.json"; + // A record written this long before the helper was launched, or after the report, belongs to + // another attempt and is left for the next launch to consume. + private static readonly TimeSpan HelperResultSkew = TimeSpan.FromMinutes(1); + + // Helper checks about this installation's own identity. Their result cannot change while this + // Wisp process runs, whichever exit code class the helper reported them under. + private static readonly HashSet SessionPermanentHelperReasons = new(StringComparer.Ordinal) + { "update-install-registration", "update-application-path", "update-installed-app-invalid" }; + private static readonly JsonSerializerOptions RequestJson = new() { PropertyNamingPolicy = null, @@ -253,7 +290,7 @@ internal static bool TryConsumeResult( out ApplicationUpdateResultStatus result) { result = default; - var resultPath = Path.Combine(localApplicationDataPath, "Wisp", ResultFileName); + var resultPath = ResultPath(localApplicationDataPath); if (!File.Exists(resultPath)) { return false; @@ -264,65 +301,34 @@ internal static bool TryConsumeResult( $".update-result.{Guid.NewGuid():N}.consumed"); try { - var attributes = File.GetAttributes(resultPath); - if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + if (!IsRegularFile(resultPath)) { return false; } File.Move(resultPath, consumedPath, overwrite: false); - var info = new FileInfo(consumedPath); - if (info.Length <= 0 || info.Length > MaximumResultBytes) + if (!TryReadRecord(consumedPath, out var record)) { return false; } - using var stream = new FileStream( - consumedPath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize: 4 * 1024, - FileOptions.SequentialScan); - using var document = JsonDocument.Parse(stream, new JsonDocumentOptions { MaxDepth = 4 }); - var root = document.RootElement; - if (root.ValueKind != JsonValueKind.Object - || !root.TryGetProperty("schemaVersion", out var schema) - || !schema.TryGetInt32(out var schemaVersion) - || schemaVersion != 1 - || !root.TryGetProperty("state", out var stateElement) - || stateElement.ValueKind != JsonValueKind.String) + if (record.State == "installed" && record.TargetVersion is { } targetVersion) { - return false; - } - - var state = stateElement.GetString(); - string? VersionField(string name) => root.TryGetProperty(name, out var field) && field.ValueKind == JsonValueKind.String && - SemanticVersion.TryParse(field.GetString(), out var parsed) ? parsed.ToString() : null; - if (string.Equals(state, "installed", StringComparison.Ordinal) - && root.TryGetProperty("targetVersion", out var targetElement) - && targetElement.ValueKind == JsonValueKind.String - && SemanticVersion.TryParse(targetElement.GetString(), out var targetVersion)) - { - var startedTarget = targetVersion.ToString() == ApplicationVersionInfo.MachineVersion; + var startedTarget = targetVersion == ApplicationVersionInfo.MachineVersion; result = new ApplicationUpdateResultStatus( startedTarget ? $"Wisp {targetVersion} was installed successfully." : "The update needs another attempt. Check for updates to try again.", "Check again", startedTarget ? "success" : "unknown", - SourceVersion: VersionField("sourceVersion"), TargetVersion: targetVersion.ToString()); + SourceVersion: record.SourceVersion, TargetVersion: targetVersion); return true; } - if (string.Equals(state, "failed", StringComparison.Ordinal)) + if (record.State == "failed") { // Do not render text from the helper record. A fixed local message // keeps the UI safe even if a same-user process edits the file. - var recoveryState = root.TryGetProperty("recoveryState", out var recoveryElement) - && recoveryElement.ValueKind == JsonValueKind.String - ? recoveryElement.GetString() - : null; result = new ApplicationUpdateResultStatus( - FailureStatus(recoveryState), - "Try again", "failure", UpdateResultIncident(root, recoveryState), VersionField("sourceVersion"), VersionField("targetVersion")); + FailureStatus(record.RecoveryState), + "Try again", "failure", UpdateResultIncident(record, "update-installed"), record.SourceVersion, record.TargetVersion); return true; } @@ -339,16 +345,126 @@ internal static bool TryConsumeResult( } } - private static Supplementary.SupplementaryIncident UpdateResultIncident(JsonElement root, string? recovery) + internal static ApplicationUpdateHelperExit DescribeHelperExit(int? exitCode, DateTimeOffset launchedAt) => + DescribeHelperExit(exitCode, launchedAt, + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), DateTimeOffset.UtcNow); + + // The helper writes update-result.json before it exits, so a record from this handoff names the + // check that failed. It is consumed here so the next launch does not report the same failure a + // second time under the "installed" stage. An older or unreadable record is left for that launch + // and only the exit code is reported. Automatic attempts stop for the session when the helper + // rejected the request itself (UpdateApplyContract.InvalidRequestExitCode: the same request + // fails the same way again) or a check about this installation's identity failed. + internal static ApplicationUpdateHelperExit DescribeHelperExit(int? exitCode, DateTimeOffset launchedAt, + string localApplicationDataPath, DateTimeOffset now) + { + var resultPath = ResultPath(localApplicationDataPath); + UpdateResultRecord? record = null; + try + { + if (File.Exists(resultPath) && IsRegularFile(resultPath) + && TryReadRecord(resultPath, out var fresh) + && fresh.State == "failed" + && (fresh.RecordedAt is not { } recordedAt + || recordedAt >= launchedAt - HelperResultSkew && recordedAt <= now + HelperResultSkew)) + { + TryDelete(resultPath); + record = fresh; + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or + JsonException or ArgumentException) + { + // Without a readable record the exit code is the only evidence. + } + + var reason = record is null ? "update-unexpected-failure" : HelperFailureReason(record.ErrorCode); + var permanent = exitCode == UpdateApplyContract.InvalidRequestExitCode || SessionPermanentHelperReasons.Contains(reason); + var incident = record is null + ? Supplementary.SupplementaryIncidentSchema.Create("update", reason, "update-install-handoff", + exitCode is { } code ? unchecked((uint)code) : null) + : UpdateResultIncident(record, "update-install-handoff"); + return new(incident, HelperExitStatus(reason, permanent), permanent); + } + + private static string ResultPath(string localApplicationDataPath) => + Path.Combine(localApplicationDataPath, "Wisp", ResultFileName); + + private static bool IsRegularFile(string path) => + (File.GetAttributes(path) & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == 0; + + // The bounded fields Wisp reads from the helper's record. Its free text stays in the file. + private sealed record UpdateResultRecord(string State, string? ErrorCode, string? RecoveryState, uint? NativeErrorCode, + uint? InstallerExitCode, string? SourceVersion, string? TargetVersion, DateTimeOffset? RecordedAt); + + private static bool TryReadRecord(string path, [NotNullWhen(true)] out UpdateResultRecord? record) { - var reason = root.TryGetProperty("errorCode", out var error) && error.ValueKind == JsonValueKind.String - ? error.GetString()?.ToLowerInvariant().Replace('_', '-') : null; - if (reason is null || !Supplementary.SupplementaryIncidentSchema.UpdateFailureReasons.Contains(reason)) - reason = "update-unexpected-failure"; - if (recovery is null || !Supplementary.SupplementaryIncidentSchema.UpdateRecoveries.Contains(recovery)) recovery = "unknown"; + record = null; + var info = new FileInfo(path); + if (info.Length <= 0 || info.Length > MaximumResultBytes) + { + return false; + } + + using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4 * 1024, + FileOptions.SequentialScan); + using var document = JsonDocument.Parse(stream, new JsonDocumentOptions { MaxDepth = 4 }); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("schemaVersion", out var schema) + || schema.ValueKind != JsonValueKind.Number + || !schema.TryGetInt32(out var schemaVersion) + || schemaVersion != 1 + || !root.TryGetProperty("state", out var stateElement) + || stateElement.ValueKind != JsonValueKind.String) + { + return false; + } + + string? Text(string name) => root.TryGetProperty(name, out var field) && field.ValueKind == JsonValueKind.String ? field.GetString() : null; + string? VersionField(string name) => Text(name) is { } text && SemanticVersion.TryParse(text, out var parsed) ? parsed.ToString() : null; uint? Number(string name) => root.TryGetProperty(name, out var field) && field.ValueKind == JsonValueKind.Number && field.TryGetUInt32(out var number) ? number : null; - return Supplementary.SupplementaryIncidentSchema.Create("update", reason, "update-installed", Number("nativeErrorCode"), - update: new(recovery, Number("installerExitCode"))); + DateTimeOffset? recordedAt = root.TryGetProperty("recordedAtUtc", out var recorded) && recorded.ValueKind == JsonValueKind.String && + recorded.TryGetDateTimeOffset(out var at) ? at : null; + record = new(stateElement.GetString()!, Text("errorCode"), Text("recoveryState"), Number("nativeErrorCode"), Number("installerExitCode"), + VersionField("sourceVersion"), VersionField("targetVersion"), recordedAt); + return true; + } + + private static Supplementary.SupplementaryIncident UpdateResultIncident(UpdateResultRecord record, string failureStage) + { + var recovery = record.RecoveryState is { } state && Supplementary.SupplementaryIncidentSchema.UpdateRecoveries.Contains(state) ? state : "unknown"; + return Supplementary.SupplementaryIncidentSchema.Create("update", HelperFailureReason(record.ErrorCode), failureStage, record.NativeErrorCode, + update: new(recovery, record.InstallerExitCode)); + } + + // The helper's UPDATE_* error code in the catalog's kebab-case form; codes outside the catalog stay unexpected. + private static string HelperFailureReason(string? errorCode) + { + var reason = errorCode?.ToLowerInvariant().Replace('_', '-'); + return reason is not null && Supplementary.SupplementaryIncidentSchema.UpdateFailureReasons.Contains(reason) + ? reason : "update-unexpected-failure"; + } + + private static string HelperExitStatus(string reason, bool permanent) + { + const string suffix = " Wisp stayed open and no files were installed."; + return reason switch + { + "update-install-registration" => + "This copy of Wisp does not match the Wisp installation registered in Windows. Download and run the full installer from wispoverlay.com to reinstall it." + suffix, + "update-application-path" or "update-installed-app-invalid" => + "The update helper could not verify this Wisp installation. Download and run the full installer from wispoverlay.com to reinstall it." + suffix, + _ when permanent => + "The update helper rejected the prepared update. Download and run the full installer from wispoverlay.com to update." + suffix, + _ => + "The update helper stopped before the update could start. Try again, or download and run the full installer from wispoverlay.com." + suffix + }; } private static string FailureStatus(string? recoveryState) => recoveryState switch diff --git a/src/Wisp.App/AutomaticUpdateCoordinator.cs b/src/Wisp.App/AutomaticUpdateCoordinator.cs index 3ba68283..0e2bf8f9 100644 --- a/src/Wisp.App/AutomaticUpdateCoordinator.cs +++ b/src/Wisp.App/AutomaticUpdateCoordinator.cs @@ -9,8 +9,9 @@ internal enum AutomaticUpdateApplyOutcome Started, // The handoff did not start this time. Try again after ApplicationUpdateCheckPolicy.RetryInterval. Retry, - // This installation has no usable update helper, so no attempt can succeed until the user - // runs the full installer. Automatic attempts stop for the rest of the session. + // No attempt can succeed until the user runs the full installer: this installation has no + // usable update helper, or the helper rejected the request or this installation before it was + // ready. Automatic attempts stop for the rest of the session. Unavailable } @@ -30,8 +31,9 @@ internal sealed class AutomaticUpdateCoordinator( private DateTimeOffset _retryAfter; private readonly Func _clock = clock ?? (() => DateTimeOffset.UtcNow); - // Set once a handoff reports that the update helper is missing. The coordinator then stays - // idle for the rest of the session; a manual install from the dashboard can still try again. + // Set once a handoff reports that the update helper is missing or rejected this installation. + // The coordinator then stays idle for the rest of the session; a manual install from the + // dashboard can still try again. internal bool HandoffUnavailable => _handoffUnavailable; internal async Task RunAsync() diff --git a/src/Wisp.Update/UpdateModels.cs b/src/Wisp.Update/UpdateModels.cs index b1cf7a3d..b0b5006b 100644 --- a/src/Wisp.Update/UpdateModels.cs +++ b/src/Wisp.Update/UpdateModels.cs @@ -71,6 +71,11 @@ public static class UpdateApplyContract public const string ReadyEventPrefix = @"Local\Wisp.Update.Ready."; public const int ReadyTokenHexLength = 64; + // Exit code of the update helper when it rejects the request itself: invalid arguments or + // metadata, an unsafe path, or an installed location that does not match the running Wisp. + // The same request fails the same way again, so the app stops automatic attempts for the session. + public const int InvalidRequestExitCode = 2; + public static string CreateReadyEventName(string token) { if (!IsValidReadyToken(token)) diff --git a/src/Wisp.Updater/UpdateFailure.cs b/src/Wisp.Updater/UpdateFailure.cs index 90d0ef25..075d5746 100644 --- a/src/Wisp.Updater/UpdateFailure.cs +++ b/src/Wisp.Updater/UpdateFailure.cs @@ -4,7 +4,7 @@ internal enum UpdaterExitCode { Success = 0, UnexpectedFailure = 1, - InvalidRequest = 2, + InvalidRequest = Wisp.Update.UpdateApplyContract.InvalidRequestExitCode, ParentProcessFailure = 3, InstallerValidationFailure = 4, InstallerExecutionFailure = 5, diff --git a/tests/Wisp.App.Tests/ApplicationUpdateLauncherTests.cs b/tests/Wisp.App.Tests/ApplicationUpdateLauncherTests.cs index 2bf0466d..dd45e324 100644 --- a/tests/Wisp.App.Tests/ApplicationUpdateLauncherTests.cs +++ b/tests/Wisp.App.Tests/ApplicationUpdateLauncherTests.cs @@ -1,5 +1,8 @@ using System.ComponentModel; using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Wisp.App.Supplementary; using Wisp.Update; using Xunit; @@ -11,6 +14,9 @@ public sealed class ApplicationUpdateLauncherTests : IDisposable Path.GetTempPath(), "Wisp.App.Tests", Guid.NewGuid().ToString("N")); + private static readonly DateTimeOffset LaunchedAt = new(2026, 10, 18, 12, 0, 0, TimeSpan.Zero); + + private string ResultPath => Path.Combine(_root, "Wisp", "update-result.json"); [Fact] public void InvalidRequestEntryReleasesHandoffWithoutDeletingTheEntry() @@ -65,12 +71,126 @@ public void MissingHelperFailureStopsAutomaticHandoffWhileOtherFailuresRetry() Assert.Equal(AutomaticUpdateApplyOutcome.Unavailable, new ApplicationUpdateStartResult(false, "error", HelperUnavailable: true).Outcome); + Assert.Equal(AutomaticUpdateApplyOutcome.Unavailable, + new ApplicationUpdateStartResult(false, "error", HelperRejected: true).Outcome); Assert.Equal(AutomaticUpdateApplyOutcome.Retry, new ApplicationUpdateStartResult(false, "error").Outcome); Assert.Equal(AutomaticUpdateApplyOutcome.Started, new ApplicationUpdateStartResult(true, string.Empty).Outcome); ApplicationUpdateStartResult deferred = (false, "Update ready. Wisp will update and restart after you close Forza."); Assert.Equal(AutomaticUpdateApplyOutcome.Retry, deferred.Outcome); } + [Theory] + [InlineData(2, "UPDATE_INSTALL_REGISTRATION", "update-install-registration", true, 5u)] + [InlineData(2, "UPDATE_APPLICATION_PATH", "update-application-path", true, null)] + [InlineData(2, "UPDATE_INSTALLER_PATH", "update-installer-path", true, null)] + [InlineData(6, "UPDATE_INSTALLED_APP_INVALID", "update-installed-app-invalid", true, null)] + [InlineData(4, "UPDATE_INSTALLER_HASH_MISMATCH", "update-installer-hash-mismatch", false, null)] + [InlineData(3, "UPDATE_PARENT_IDENTITY", "update-parent-identity", false, 5u)] + [InlineData(1, "not-a-catalog-code", "update-unexpected-failure", false, null)] + public void HelperExitRecordNamesTheFailedCheckAndStopsTheSessionWhenItCannotChange( + int exitCode, string errorCode, string reason, bool permanent, uint? native) + { + // Wisp 2.6.12 saw the helper exit with UPDATE_INSTALL_REGISTRATION before it was ready, every + // two minutes for a day, each reported as "update-verification-failed" and retried although + // the registered location cannot change within the session. + WriteResult(new + { + schemaVersion = 1, + state = "failed", + sourceVersion = "2.6.12", + targetVersion = "2.6.13", + errorCode, + message = "PRIVATE_SENTINEL", + recoveryState = "application-still-running", + nativeErrorCode = native, + recordedAtUtc = LaunchedAt.AddSeconds(3) + }); + + var exit = ApplicationUpdateLauncher.DescribeHelperExit(exitCode, LaunchedAt, _root, LaunchedAt.AddSeconds(20)); + + Assert.Equal(reason, exit.Incident.Reason); + Assert.Equal("update-install-handoff", exit.Incident.FailureStage); + Assert.Equal(native, exit.Incident.Code); + Assert.Equal("application-still-running", exit.Incident.Update?.Recovery); + Assert.True(SupplementaryIncidentSchema.Valid(exit.Incident)); + Assert.DoesNotContain("PRIVATE_SENTINEL", JsonSerializer.Serialize(exit.Incident)); + Assert.DoesNotContain("PRIVATE_SENTINEL", exit.Status); + Assert.Equal(permanent, exit.Permanent); + Assert.Contains("wispoverlay.com", exit.Status); + Assert.Contains("Wisp stayed open and no files were installed", exit.Status); + if (reason == "update-install-registration") + { + Assert.Contains("registered in Windows", exit.Status); + Assert.Contains("reinstall", exit.Status); + } + // The record was consumed: the next launch does not report the same failure again. + Assert.False(File.Exists(ResultPath)); + Assert.False(ApplicationUpdateLauncher.TryConsumeResult(_root, out _)); + } + + [Fact] + public void HelperExitWithoutARecordReportsTheExitCodeAndStopsOnlyForARejectedRequest() + { + var rejected = ApplicationUpdateLauncher.DescribeHelperExit(2, LaunchedAt, _root, LaunchedAt.AddSeconds(2)); + Assert.Equal("update-unexpected-failure", rejected.Incident.Reason); + Assert.Equal("update-install-handoff", rejected.Incident.FailureStage); + Assert.Equal(2u, rejected.Incident.Code); + Assert.Null(rejected.Incident.Update); + Assert.True(SupplementaryIncidentSchema.Valid(rejected.Incident)); + Assert.True(rejected.Permanent); + Assert.Contains("wispoverlay.com", rejected.Status); + + var crashed = ApplicationUpdateLauncher.DescribeHelperExit(unchecked((int)0xC0000005), LaunchedAt, _root, LaunchedAt.AddSeconds(2)); + Assert.Equal(0xC0000005u, crashed.Incident.Code); + Assert.False(crashed.Permanent); + Assert.True(SupplementaryIncidentSchema.Valid(crashed.Incident)); + + var unknown = ApplicationUpdateLauncher.DescribeHelperExit(null, LaunchedAt, _root, LaunchedAt); + Assert.Null(unknown.Incident.Code); + Assert.False(unknown.Permanent); + } + + [Fact] + public void StaleHelperRecordIsLeftForTheNextLaunch() + { + WriteResult(new + { + schemaVersion = 1, + state = "failed", + errorCode = "UPDATE_INSTALL_REGISTRATION", + recoveryState = "restarted", + recordedAtUtc = LaunchedAt.AddHours(-2) + }); + + var exit = ApplicationUpdateLauncher.DescribeHelperExit(1, LaunchedAt, _root, LaunchedAt.AddSeconds(2)); + + Assert.Equal("update-unexpected-failure", exit.Incident.Reason); + Assert.Equal(1u, exit.Incident.Code); + Assert.False(exit.Permanent); + Assert.True(File.Exists(ResultPath)); + Assert.True(ApplicationUpdateLauncher.TryConsumeResult(_root, out var next)); + Assert.Equal("update-install-registration", next.Incident?.Reason); + Assert.Equal("update-installed", next.Incident?.FailureStage); + Assert.Equal("restarted", next.Incident?.Update?.Recovery); + } + + [Theory] + [InlineData("{not json")] + [InlineData("{\"schemaVersion\":\"1\",\"state\":\"failed\",\"errorCode\":\"UPDATE_INSTALL_REGISTRATION\"}")] + public void UnreadableHelperRecordDoesNotBreakTheReport(string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(ResultPath)!); + File.WriteAllText(ResultPath, content); + + var exit = ApplicationUpdateLauncher.DescribeHelperExit(3, LaunchedAt, _root, LaunchedAt.AddSeconds(2)); + + Assert.Equal("update-unexpected-failure", exit.Incident.Reason); + Assert.False(exit.Permanent); + Assert.True(SupplementaryIncidentSchema.Valid(exit.Incident)); + Assert.True(File.Exists(ResultPath)); + Assert.False(ApplicationUpdateLauncher.TryConsumeResult(_root, out _)); + } + public void Dispose() { if (Directory.Exists(_root)) @@ -79,6 +199,12 @@ public void Dispose() } } + private void WriteResult(object record) + { + Directory.CreateDirectory(Path.GetDirectoryName(ResultPath)!); + File.WriteAllText(ResultPath, JsonSerializer.Serialize(record), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + private UpdateApplyRequest CreateRequest() => new( Path.Combine(_root, "Wisp-Setup-9.9.9.exe"), "9.9.9", diff --git a/tests/Wisp.App.Tests/AutomaticUpdateCoordinatorTests.cs b/tests/Wisp.App.Tests/AutomaticUpdateCoordinatorTests.cs index d51b940a..72fcce86 100644 --- a/tests/Wisp.App.Tests/AutomaticUpdateCoordinatorTests.cs +++ b/tests/Wisp.App.Tests/AutomaticUpdateCoordinatorTests.cs @@ -10,6 +10,7 @@ private sealed class Scenario internal bool Enabled = true, HasUpdate = true, GameOpen, Busy; internal int Downloads, Installs; internal AutomaticUpdateApplyOutcome InstallOutcome = AutomaticUpdateApplyOutcome.Started; + internal Func? OutcomeForAttempt; internal DateTimeOffset Now = DateTimeOffset.UtcNow; internal TaskCompletionSource? Download; internal readonly VerifiedInstaller Installer = (VerifiedInstaller)Activator.CreateInstance(typeof(VerifiedInstaller), @@ -18,7 +19,7 @@ private sealed class Scenario internal AutomaticUpdateCoordinator Create() => new( () => Enabled, () => HasUpdate, () => Task.FromResult(GameOpen), () => Busy, () => { Downloads++; return Download?.Task ?? Task.FromResult(Installer); }, - installer => { Assert.Same(Installer, installer); Installs++; return Task.FromResult(InstallOutcome); }, + installer => { Assert.Same(Installer, installer); Installs++; return Task.FromResult(OutcomeForAttempt?.Invoke(Installs) ?? InstallOutcome); }, _ => { }, () => Now); // The dashboard install timer ticks every five seconds for as long as Wisp runs. @@ -129,6 +130,30 @@ public async Task MissingUpdateHelperIsNotRetriedForALaterRelease() Assert.True(coordinator.HandoffUnavailable); } + [Fact] + public async Task RejectedHandoffAfterTransientRetriesStopsForTheSession() + { + // Wisp 2.6.12 retried a handoff every RetryInterval for a day while the helper kept rejecting + // the installed location. Two transient failures, then the helper's rejection: no further + // attempts for the session, and a later 15-minute check does not restart them. + var s = new Scenario + { + OutcomeForAttempt = attempt => attempt < 3 ? AutomaticUpdateApplyOutcome.Retry : AutomaticUpdateApplyOutcome.Unavailable + }; + var coordinator = s.Create(); + await s.TickAsync(coordinator, TimeSpan.FromHours(8)); + Assert.Equal(3, s.Downloads); + Assert.Equal(3, s.Installs); + Assert.True(coordinator.HandoffUnavailable); + + s.HasUpdate = false; + await s.TickAsync(coordinator, ApplicationUpdateCheckPolicy.MinimumInterval); + s.HasUpdate = true; + await s.TickAsync(coordinator, ApplicationUpdateCheckPolicy.MinimumInterval); + Assert.Equal(3, s.Downloads); + Assert.Equal(3, s.Installs); + } + [Theory] [InlineData(false, true)] [InlineData(true, false)] diff --git a/tests/Wisp.Updater.Tests/CommandAndErrorTests.cs b/tests/Wisp.Updater.Tests/CommandAndErrorTests.cs index f0e953f1..f56b1adf 100644 --- a/tests/Wisp.Updater.Tests/CommandAndErrorTests.cs +++ b/tests/Wisp.Updater.Tests/CommandAndErrorTests.cs @@ -36,6 +36,16 @@ public void CommandLineRequiresApplySwitchAndSingleRequestPath() Assert.Equal("UPDATE_ARGUMENTS", missingSwitch.ErrorCode); Assert.Equal("UPDATE_ARGUMENTS", extraArgument.ErrorCode); + Assert.Equal(UpdaterExitCode.InvalidRequest, missingSwitch.ExitCode); + } + + [Fact] + public void InvalidRequestExitCodeIsTheSharedContractValue() + { + // Wisp reads this exit code when the helper exits before it is ready and stops automatic + // update attempts for the session, because the same request is rejected the same way again. + Assert.Equal(Wisp.Update.UpdateApplyContract.InvalidRequestExitCode, (int)UpdaterExitCode.InvalidRequest); + Assert.Equal(2, (int)UpdaterExitCode.InvalidRequest); } [Fact] @@ -64,6 +74,9 @@ public void ResultFileIsAtomicLocalConciseAndHasNoUtf8Bom() { Assert.Equal(32u, document.RootElement.GetProperty("nativeErrorCode").GetUInt32()); Assert.Equal(5u, document.RootElement.GetProperty("installerExitCode").GetUInt32()); + // Wisp matches the record to the handoff that produced it by this timestamp. + var recordedAt = document.RootElement.GetProperty("recordedAtUtc").GetDateTimeOffset(); + Assert.InRange(DateTimeOffset.UtcNow - recordedAt, TimeSpan.Zero, TimeSpan.FromMinutes(1)); } resultFile.TryWriteSuccess(new StableVersion(1, 0, 0), new StableVersion(1, 2, 3)); diff --git a/tests/Wisp.Updater.Tests/InstalledApplicationValidatorTests.cs b/tests/Wisp.Updater.Tests/InstalledApplicationValidatorTests.cs index 85a4cb00..0eca8bc7 100644 --- a/tests/Wisp.Updater.Tests/InstalledApplicationValidatorTests.cs +++ b/tests/Wisp.Updater.Tests/InstalledApplicationValidatorTests.cs @@ -81,6 +81,34 @@ public void RegistrationMustResolveToTheExactRequestedWispExecutable() var otherPath = Path.Combine(Path.GetDirectoryName(layout.InstallDirectory)!, "Other", "Wisp.exe"); var exception = Assert.Throws(() => validator.Validate(otherPath)); Assert.Equal("UPDATE_INSTALL_REGISTRATION", exception.ErrorCode); + Assert.Equal(UpdaterExitCode.InvalidRequest, exception.ExitCode); + } + + [Fact] + public void RegistrationComparesTheFullPathIgnoringCaseAndTrailingSeparator() + { + // The installer records InstallLocation with a trailing separator; the running path comes + // from the process and may differ in case. Both still name the same installed Wisp.exe. + using var layout = new TestLayout(); + var registered = layout.InstallDirectory.ToUpperInvariant() + Path.DirectorySeparatorChar; + + new InstalledApplicationRegistrationValidator(new FixedRegistrationReader(registered)) + .Validate(layout.ApplicationPath); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Programs\\Wisp")] + public void MissingOrRelativeRegistrationIsRejectedAsAnInvalidRequest(string? installLocation) + { + using var layout = new TestLayout(); + var validator = new InstalledApplicationRegistrationValidator(new FixedRegistrationReader(installLocation)); + + var exception = Assert.Throws(() => validator.Validate(layout.ApplicationPath)); + + Assert.Equal("UPDATE_INSTALL_REGISTRATION", exception.ErrorCode); + Assert.Equal(UpdaterExitCode.InvalidRequest, exception.ExitCode); } private sealed class FixedInspector(PortableExecutableIdentity identity) : IPortableExecutableInspector