diff --git a/CHANGELOG.md b/CHANGELOG.md index e87817d0..eb6a5ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Notable changes to Wisp are recorded here. +## Unreleased + +- Report a feature-control policy Wisp rejected or could not keep as its own diagnostic, instead of an unclassified failure report. +- Report a current tune Wisp could not read as a tune read failure in diagnostics, instead of an export validation failure. + ## 2.7.0 - 2026-10-21 - Keep clip recording going when the graphics card is busy instead of restarting every few seconds; clips stay in step with the game and its audio. diff --git a/src/Wisp.App/Supplementary/FeatureControlClient.cs b/src/Wisp.App/Supplementary/FeatureControlClient.cs index bee1e205..e1ef9b17 100644 --- a/src/Wisp.App/Supplementary/FeatureControlClient.cs +++ b/src/Wisp.App/Supplementary/FeatureControlClient.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using Wisp.App.DebugLogging; namespace Wisp.App.Supplementary; @@ -95,16 +96,22 @@ internal async Task RefreshAsync(CancellationToken cancellation) private SupplementaryContentAcceptance? _reportedAcceptance; // Reported once per change, so Tools can show why a published revision did not take effect here. + // A download this PC rejected or could not keep is the policy service's or this PC's cache's + // failure, never a player's feature: it carries its own category, so Tools never has to fall + // back to the collector's placeholder for it. private void ReportAcceptance(SupplementaryContentAcceptance acceptance) { if (_reportedAcceptance == acceptance) return; _reportedAcceptance = acceptance; if (acceptance == SupplementaryContentAcceptance.Rejected) - SupplementaryObservations.Record("feature", "app", "failure", "validation"); + SupplementaryObservations.Record("feature", "app", "failure", "validation", incident: Incident("policy-rejected")); else if (acceptance == SupplementaryContentAcceptance.CacheUnavailable) - SupplementaryObservations.Record("feature", "app", "failure", "recovery"); + SupplementaryObservations.Record("feature", "app", "failure", "recovery", incident: Incident("policy-cache-unavailable")); } + private SupplementaryIncident Incident(string reason) => SupplementaryIncidentSchema.Create("feature-control", reason, "feature-control-refresh", + context: SupplementaryIncidentCapture.Context(HealthContextRecorder.Current.Snapshot(), _clock.GetUtcNow())); + private void PublishState() { var next = State; diff --git a/src/Wisp.App/Supplementary/SupplementaryIncident.cs b/src/Wisp.App/Supplementary/SupplementaryIncident.cs index 9dc72b08..27cf83c1 100644 --- a/src/Wisp.App/Supplementary/SupplementaryIncident.cs +++ b/src/Wisp.App/Supplementary/SupplementaryIncident.cs @@ -33,7 +33,7 @@ internal static class SupplementaryIncidentSchema internal const int MaximumBytes = 8192; internal static readonly HashSet Categories = new(StringComparer.Ordinal) { "managed-exception", "windows-fault", "unexpected-exit", "recorder", "export", "native-access", "native-unsupported", "renderer", "collector", "saved-data", "tune", - "settings", "update", "telemetry", "playback" + "settings", "update", "telemetry", "playback", "feature-control" }; internal static readonly HashSet UpdateFailureReasons = new(StringComparer.Ordinal) { "update-application-path", "update-arguments", "update-helper-path", "update-installed-app-invalid", "update-installer-exit-failed", "update-installer-hash", "update-installer-hash-mismatch", "update-installer-not-pe", "update-installer-path", "update-installer-product-mismatch", "update-installer-read-failed", "update-installer-size", "update-installer-size-mismatch", "update-installer-start-failed", "update-installer-stop-failed", "update-installer-timeout", "update-installer-version-mismatch", "update-install-registration", "update-parent-exited", "update-parent-identity", "update-parent-inspection", "update-parent-process", "update-parent-timeout", "update-ready-event", "update-ready-signal", "update-request-json", "update-request-path", "update-request-root", "update-request-size", "update-restart-failed", "update-source-version", "update-target-version", "update-unexpected-failure" }; @@ -42,7 +42,7 @@ internal static class SupplementaryIncidentSchema internal static readonly HashSet Reasons = new(StringComparer.Ordinal) { "exception-raised", "windows-application-fault", "shutdown-incomplete", "system-stopped", - "classification-missing", "unknown", "access-denied", "unsupported-build", "unsupported_os", "unsupported_gpu", "unsupported_format", + "classification-missing", "policy-rejected", "policy-cache-unavailable", "unknown", "access-denied", "unsupported-build", "unsupported_os", "unsupported_gpu", "unsupported_format", "capture_failed", "encoder_failed", "audio_failed", "audio_capture_failed", "audio_unavailable", "buffer_full", "no_keyframe", "not_ready", "save_in_progress", "storage_failed", "mux_failed", "protocol_error", "capture_stale", "capture_reconnecting", "encoder_reconnecting", "audio_reconnecting", "scheduler_late", @@ -58,7 +58,7 @@ internal static class SupplementaryIncidentSchema }; internal static readonly HashSet FailureStages = new(StringComparer.Ordinal) { - "report-classification", "buffer-open", + "report-classification", "feature-control-refresh", "buffer-open", "crash-ui", "crash-background", "crash-task", "crash-recovered", "crash-windows", "helper-launch", "helper-config", "helper-start", "helper-pause", "helper-resume", "helper-save", "helper-stop", "helper-protocol", "helper-exit", "helper-state", "not_started", "unknown", "storage_preflight", "runtime_initialize", "capture_initialize", "conversion_initialize", diff --git a/src/Wisp.App/Tunes/TuneCaptureService.cs b/src/Wisp.App/Tunes/TuneCaptureService.cs index 4ab2faae..cae2bec3 100644 --- a/src/Wisp.App/Tunes/TuneCaptureService.cs +++ b/src/Wisp.App/Tunes/TuneCaptureService.cs @@ -275,7 +275,7 @@ System.ComponentModel.Win32Exception or DllNotFoundException or EntryPointNotFou return Fail(TuneCaptureStatus.Unavailable, "The current tune could not be read. Refresh and try again.", generation, TuneCaptureDetails.Create(stage, build, exception), TuneIncident(stage, exception is UnauthorizedAccessException || exception is System.ComponentModel.Win32Exception { NativeErrorCode: 5 } - ? "access-denied" : exception is InvalidDataException ? "validation" : "tune-read-failed", exception)); + ? "access-denied" : "tune-read-failed", exception)); } finally { if (entered) _captureGate.Release(); } } diff --git a/tests/Wisp.App.Tests/FeatureControlTests.cs b/tests/Wisp.App.Tests/FeatureControlTests.cs index e49da5e6..78208130 100644 --- a/tests/Wisp.App.Tests/FeatureControlTests.cs +++ b/tests/Wisp.App.Tests/FeatureControlTests.cs @@ -10,6 +10,8 @@ namespace Wisp.App.Tests; +// RejectedDownloadIsReportedOnceWithItsOwnIncident swaps the process-wide observation sink. +[Collection("Supplementary instrumentation")] public sealed class FeatureControlTests(ITestOutputHelper output) { [Fact] @@ -554,6 +556,34 @@ public void CacheLeaseFailurePreservesCurrentPolicyAndCanRecover() Assert.Equal(SupplementaryContentAcceptance.Accepted, store.Accept(f.Sign(f.Payload(2)), f.Now)); } + [Fact] + public async Task RejectedDownloadIsReportedOnceWithItsOwnIncident() + { + // 2026-10-22: five PCs rejected an envelope the service let expire and reported it without a + // category, so Tools filed "classification missing" and an app-wide bug. + using var f = new Fixture(); + var observed = new List(); + var previous = SupplementaryObservations.Observer; + SupplementaryObservations.Observer = observed.Add; + try + { + var expired = f.Payload(); + expired["expiresAt"] = Stamp(f.Now); + var transport = new Transport { Response = new(SupplementaryRequestStatus.Success, f.Sign(expired)) }; + using var client = f.Client(transport, _ => { }); + await client.RefreshAsync(TestContext.Current.CancellationToken); + await client.RefreshAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, transport.Calls); + var rejected = Assert.Single(observed, o => o.Outcome == "failure"); + Assert.Equal(("feature", "app", "validation"), (rejected.Kind, rejected.Feature, rejected.Stage)); + Assert.Equal("feature-control", rejected.Incident!.Category); + Assert.Equal("policy-rejected", rejected.Incident.Reason); + Assert.Equal("feature-control-refresh", rejected.Incident.FailureStage); + Assert.True(SupplementaryIncidentSchema.Valid(rejected.Incident)); + } + finally { SupplementaryObservations.Observer = previous; } + } + [Fact] public async Task EmptyInvalidAndUnavailableResponsesPreservePolicyWhileExpiryRestoresAvailability() { diff --git a/tests/Wisp.App.Tests/NativeTuneCaptureTests.cs b/tests/Wisp.App.Tests/NativeTuneCaptureTests.cs index 31d2f8d6..bd88bc19 100644 --- a/tests/Wisp.App.Tests/NativeTuneCaptureTests.cs +++ b/tests/Wisp.App.Tests/NativeTuneCaptureTests.cs @@ -54,6 +54,21 @@ public async Task TuneResultsUseTheirOwnAdmissionEvidenceEvenWhenReadFails() var unavailable = await service.RequestSnapshotAsync(TestContext.Current.CancellationToken); Assert.Equal("identity-unverified", unavailable.GameVerification!.Reason); } + [Fact] + public async Task TuneDataThatCannotBeReadIsAReadFailureNotAnExportCheck() + { + // Tools groups "validation" with clip export checks; a current car whose data cannot be + // read is a tune read failure. + var factory = new Factory { Verification = NativeGameVerification.Steam }; + await using var service = new TuneCaptureService(factory, + (_, _) => throw new InvalidDataException("The current car's tune is not available yet.")); + var failed = await service.RequestSnapshotAsync(TestContext.Current.CancellationToken); + Assert.Equal(TuneCaptureStatus.Unavailable, failed.Status); + Assert.Equal("tune-read-failed", failed.Incident!.Reason); + Assert.Equal("tune-read", failed.Incident.FailureStage); + Assert.Equal("InvalidDataException", failed.Incident.Exceptions!.Value[0].Type); + } + [Fact] public void TuneFailureCarriesStructuredStageAndCodeWithoutPrivateText() {