Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions src/Wisp.App/Supplementary/FeatureControlClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using Wisp.App.DebugLogging;

namespace Wisp.App.Supplementary;

Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/Wisp.App/Supplementary/SupplementaryIncident.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ internal static class SupplementaryIncidentSchema
internal const int MaximumBytes = 8192;
internal static readonly HashSet<string> 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<string> 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" };
Expand All @@ -42,7 +42,7 @@ internal static class SupplementaryIncidentSchema
internal static readonly HashSet<string> 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",
Expand All @@ -58,7 +58,7 @@ internal static class SupplementaryIncidentSchema
};
internal static readonly HashSet<string> 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",
Expand Down
2 changes: 1 addition & 1 deletion src/Wisp.App/Tunes/TuneCaptureService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
}
Expand Down
30 changes: 30 additions & 0 deletions tests/Wisp.App.Tests/FeatureControlTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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<SupplementaryObservation>();
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()
{
Expand Down
15 changes: 15 additions & 0 deletions tests/Wisp.App.Tests/NativeTuneCaptureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading