From 021d3c73907908be9cfea8fe501006d514d0be6d Mon Sep 17 00:00:00 2001 From: Views2k <292889387+Views2k@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:07:37 +0000 Subject: [PATCH] Name runs that ended on a sleeping or locked PC instead of calling them unexplained A recovered run without a close request, a Windows fault or a Windows stop event was always reported as shutdown-incomplete. Most of those runs on 2.7.x ended on a PC that went to sleep or was locked and never came back, which hid the real unexplained stops among them. The session sentinel window now also receives the sleep, resume, lock and unlock notices (WM_POWERBROADCAST and WM_WTSSESSION_CHANGE) on its own thread, and the run marker keeps the last sleep and lock times. Resuming and unlocking clear them, as does a checkpoint a minute or more after the sleep notice, since the checkpoint timer does not run while Windows sleeps. The next start labels such a run system-sleep or session-locked; a Windows fault, stop or shutdown event still wins, and markers from earlier versions keep today's reading. Like a Windows shutdown, these runs get no dashboard card. Closes #193 --- src/Wisp.App/App.xaml.cs | 26 ++++++-- src/Wisp.App/CrashDiagnostics/CrashReport.cs | 2 + .../CrashDiagnostics/PreviousRunRecovery.cs | 4 ++ .../CrashDiagnostics/RunExitMarkerStore.cs | 45 ++++++++++++- .../CrashDiagnostics/SessionEndSentinel.cs | 62 ++++++++++++++---- .../WindowsFaultEventReader.cs | 8 ++- .../SupplementaryIncidentCapture.cs | 4 +- .../DiagnosticsViewModel.CrashReports.cs | 10 +-- .../Supplementary/SupplementaryIncident.cs | 2 +- .../DiagnosticsViewModelCrashNoticeTests.cs | 14 ++++ .../PreviousRunRecoveryTests.cs | 59 +++++++++++++++++ .../Wisp.App.Tests/RunExitMarkerStoreTests.cs | 65 +++++++++++++++++++ .../SupplementaryIncidentCaptureTests.cs | 22 +++++++ 13 files changed, 296 insertions(+), 27 deletions(-) diff --git a/src/Wisp.App/App.xaml.cs b/src/Wisp.App/App.xaml.cs index 152ef23b..38c89c61 100644 --- a/src/Wisp.App/App.xaml.cs +++ b/src/Wisp.App/App.xaml.cs @@ -104,12 +104,9 @@ protected override async void OnStartup(StartupEventArgs e) } _runExitMarker = RunExitMarkerStore.BeginCurrent(_runId); - // Windows delivers its shutdown notice to this window on its own thread, so the close request - // is recorded even while the dashboard thread is busy. - _sessionEndSentinel = SessionEndSentinel.TryStart(() => - { - if (Volatile.Read(ref _fatalExit) == 0) _runExitMarker?.MarkShutdownStarted(ShutdownIntent.OperatingSystem); - }, () => _runExitMarker?.ClearShutdownIntent(ShutdownIntent.OperatingSystem)); + // Windows delivers its shutdown, sleep and lock notices to this window on its own thread, so they + // are recorded even while the dashboard thread is busy. + _sessionEndSentinel = SessionEndSentinel.TryStart(RecordSessionNotice); // The native handler records the faulting address and call stack of a hardware fault // for this run; the next run reports it with the crash. NativeCrashCapture.TryInstall(_crashReports.StoreDirectory, _runId); @@ -350,6 +347,23 @@ private async Task RecoverPreviousRunAsync(RunExitMarker previous, DateTimeOffse catch (Exception) { } // Diagnostics and shutdown races must not interrupt startup. } + // The run marker keeps what Windows said last, so the next start can tell a run that ended on a + // sleeping or locked PC from one that stopped without explanation. + private void RecordSessionNotice(SessionNotice notice) + { + switch (notice) + { + case SessionNotice.Ending: + if (Volatile.Read(ref _fatalExit) == 0) _runExitMarker?.MarkShutdownStarted(ShutdownIntent.OperatingSystem); + break; + case SessionNotice.Continuing: _runExitMarker?.ClearShutdownIntent(ShutdownIntent.OperatingSystem); break; + case SessionNotice.Suspending: _runExitMarker?.MarkSuspended(DateTimeOffset.UtcNow); break; + case SessionNotice.Resumed: _runExitMarker?.MarkResumed(); break; + case SessionNotice.Locked: _runExitMarker?.MarkSessionLocked(DateTimeOffset.UtcNow); break; + case SessionNotice.Unlocked: _runExitMarker?.MarkSessionUnlocked(); break; + } + } + protected override void OnSessionEnding(SessionEndingCancelEventArgs e) { base.OnSessionEnding(e); diff --git a/src/Wisp.App/CrashDiagnostics/CrashReport.cs b/src/Wisp.App/CrashDiagnostics/CrashReport.cs index ce83aca2..2129ddff 100644 --- a/src/Wisp.App/CrashDiagnostics/CrashReport.cs +++ b/src/Wisp.App/CrashDiagnostics/CrashReport.cs @@ -126,6 +126,8 @@ internal string Format() } else if (exit.WindowsStopped) report.AppendLine("Windows stopped without shutting down (Kernel-Power 41 or EventLog 6008) and restarted within 10 minutes of the previous run's last checkpoint."); else if (exit.WindowsShutDown) report.AppendLine("Windows shut down or signed out (EventLog 6006 or User32 1074) within minutes of the run's last checkpoint while Wisp was still running."); + else if (exit.Suspended) report.AppendLine("Windows was about to sleep or hibernate when the run was last seen, and the run did not resume. No shutdown, stop or crash event was found."); + else if (exit.SessionLocked) report.AppendLine("The Windows session was locked when the run was last seen. No shutdown, stop or crash event was found."); else report.AppendLine("The previous run did not finish normal shutdown. The cause is not established; termination or power loss can also leave this marker."); if (exit.Native is { } native) { diff --git a/src/Wisp.App/CrashDiagnostics/PreviousRunRecovery.cs b/src/Wisp.App/CrashDiagnostics/PreviousRunRecovery.cs index da16ed46..cff304cd 100644 --- a/src/Wisp.App/CrashDiagnostics/PreviousRunRecovery.cs +++ b/src/Wisp.App/CrashDiagnostics/PreviousRunRecovery.cs @@ -50,6 +50,10 @@ internal sealed class PreviousRunRecovery(CrashReportStore reports, IWindowsFaul exit = exit with { WindowsShutDown = shutDown }; } } + // Nobody closed Wisp on a PC that was asleep, or whose session was locked, when the run was last + // seen, and Windows recorded no stop. Markers from earlier versions carry neither state. + if (exit.Fault is null && exit.Native is null && !exit.WindowsStopped && !exit.WindowsShutDown) + exit = exit with { Suspended = safe.SuspendedAtUtc is not null, SessionLocked = safe.SessionLockedAtUtc is not null }; var report = safe.ToReport(exit.Fault?.TimeUtc ?? nextRunStartedAtUtc) with { Origin = exit.Fault is null ? CrashOrigin.UnexpectedExit : CrashOrigin.WindowsApplicationFault, diff --git a/src/Wisp.App/CrashDiagnostics/RunExitMarkerStore.cs b/src/Wisp.App/CrashDiagnostics/RunExitMarkerStore.cs index c7051ddd..c948be93 100644 --- a/src/Wisp.App/CrashDiagnostics/RunExitMarkerStore.cs +++ b/src/Wisp.App/CrashDiagnostics/RunExitMarkerStore.cs @@ -16,6 +16,10 @@ internal sealed record RunExitMarker(Guid RunId, int ProcessId, long ProcessCrea { public HealthContextSnapshot? Context { get; init; } public ShutdownIntent ShutdownIntent { get; init; } + // Windows reported that it was about to sleep or hibernate, and has not reported resuming since. + public DateTimeOffset? SuspendedAtUtc { get; init; } + // The Windows session was locked (Win+L, the lock screen or a user switch) and has not been unlocked since. + public DateTimeOffset? SessionLockedAtUtc { get; init; } internal RunExitMarker? Sanitize() { @@ -32,10 +36,15 @@ internal sealed record RunExitMarker(Guid RunId, int ProcessId, long ProcessCrea { Build = new(report.WispVersion, report.PrivateBuildId, module, report.RuntimeVersion, report.WindowsVersion, architecture), - Context = ValidContext(Context) ? Context : null + Context = ValidContext(Context) ? Context : null, + SuspendedAtUtc = AfterStart(SuspendedAtUtc) ? SuspendedAtUtc : null, + SessionLockedAtUtc = AfterStart(SessionLockedAtUtc) ? SessionLockedAtUtc : null }; } + // A moment this run can have observed: UTC, at or after its start. + internal bool AfterStart(DateTimeOffset? value) => value is { } moment && moment.Offset == TimeSpan.Zero && moment >= StartedAtUtc; + internal CrashReport ToReport(DateTimeOffset detectedAtUtc) => new(RunId, detectedAtUtc, Build.WispVersion, Build.PrivateBuildId, Build.RuntimeVersion, CrashOrigin.UnexpectedExit, true, []) { @@ -60,6 +69,9 @@ private static bool SafeVersion(string? value) => value is { Length: > 0 and <= internal sealed class RunExitMarkerStore : IDisposable { private const int MaximumMarkerBytes = 8192; + // The checkpoint timer does not run while Windows sleeps, so a checkpoint this long after the sleep + // notice means Windows is running again, whether or not the resume notice arrived. + internal static readonly TimeSpan ResumeGrace = TimeSpan.FromMinutes(1); private readonly string _directory; private readonly SafeFileHandle _parent, _lease; private RunExitMarker _current; @@ -165,6 +177,34 @@ internal bool ClearShutdownIntent(ShutdownIntent intent) } } + // Windows is about to sleep or hibernate. Checkpoints stop with it, so a run that never resumes keeps + // this state for the next start to read; resuming withdraws it. + internal bool MarkSuspended(DateTimeOffset nowUtc) => + Mark(current => current.AfterStart(nowUtc) ? current with { SuspendedAtUtc = nowUtc } : null); + + internal bool MarkResumed() => Mark(current => current.SuspendedAtUtc is null ? null : current with { SuspendedAtUtc = null }); + + // The Windows session was locked: a run that ends while it stays locked was not closed by the user. + internal bool MarkSessionLocked(DateTimeOffset nowUtc) => + Mark(current => current.AfterStart(nowUtc) ? current with { SessionLockedAtUtc = nowUtc } : null); + + internal bool MarkSessionUnlocked() => Mark(current => current.SessionLockedAtUtc is null ? null : current with { SessionLockedAtUtc = null }); + + private bool Mark(Func change) + { + lock (_gate) + { + if (_disposed || _current.CleanExit) return false; + try + { + if (change(_current) is not { } next || Read(_parent)?.RunId != _current.RunId) return false; + Write(next); _current = next; + return true; + } + catch (Exception) { return false; } + } + } + internal bool Checkpoint(HealthContextSnapshot snapshot) { lock (_gate) @@ -174,7 +214,8 @@ internal bool Checkpoint(HealthContextSnapshot snapshot) sample.TimestampUtc <= snapshot.CapturedAtUtc && snapshot.CapturedAtUtc - sample.TimestampUtc <= TimeSpan.FromSeconds(30)) .OrderBy(sample => sample.TimestampUtc).TakeLast(3).ToImmutableArray(); if (samples.Length == 0 || _current.Context?.CapturedAtUtc >= snapshot.CapturedAtUtc) return false; - var next = _current with { Context = new(snapshot.CapturedAtUtc, samples, []) }; + var resumed = _current.SuspendedAtUtc is { } suspended && snapshot.CapturedAtUtc - suspended >= ResumeGrace; + var next = _current with { Context = new(snapshot.CapturedAtUtc, samples, []), SuspendedAtUtc = resumed ? null : _current.SuspendedAtUtc }; try { if (Read(_parent)?.RunId != _current.RunId) return false; diff --git a/src/Wisp.App/CrashDiagnostics/SessionEndSentinel.cs b/src/Wisp.App/CrashDiagnostics/SessionEndSentinel.cs index a1db3b36..ac78d7a7 100644 --- a/src/Wisp.App/CrashDiagnostics/SessionEndSentinel.cs +++ b/src/Wisp.App/CrashDiagnostics/SessionEndSentinel.cs @@ -2,36 +2,44 @@ namespace Wisp.App.CrashDiagnostics; +// What Windows told the sentinel window: the session is ending or going on after all, the PC is +// about to sleep or has resumed, and the session was locked or unlocked. +internal enum SessionNotice { Ending, Continuing, Suspending, Resumed, Locked, Unlocked } + // A hidden top-level window on its own thread receives WM_QUERYENDSESSION and WM_ENDSESSION even // while the dashboard thread is busy, so a Windows shutdown or sign-out is recorded as a close // request before Windows ends the process. Without it, a run that Windows ended was reported as an -// unexplained exit. +// unexplained exit. The same window receives the sleep, resume, lock and unlock notices, which the +// run marker keeps so the next start can tell a run that ended on a sleeping or locked PC apart. internal sealed class SessionEndSentinel : IDisposable { - private const uint WM_DESTROY = 0x0002, WM_CLOSE = 0x0010, WM_QUERYENDSESSION = 0x0011, WM_ENDSESSION = 0x0016; + private const uint WM_DESTROY = 0x0002, WM_CLOSE = 0x0010, WM_QUERYENDSESSION = 0x0011, WM_ENDSESSION = 0x0016, + WM_POWERBROADCAST = 0x0218, WM_WTSSESSION_CHANGE = 0x02B1; + private const long PBT_APMSUSPEND = 0x0004, PBT_APMRESUMESUSPEND = 0x0007, PBT_APMRESUMEAUTOMATIC = 0x0012; + private const long WTS_SESSION_LOCK = 0x7, WTS_SESSION_UNLOCK = 0x8; + private const uint NOTIFY_FOR_THIS_SESSION = 0; private const int WS_OVERLAPPED = 0x00000000; private const int ERROR_CLASS_ALREADY_EXISTS = 1410; private const string ClassName = "WispSessionEndSentinel"; - private readonly Action _sessionEnding; - private readonly Action? _sessionContinuing; + private readonly Action _notice; private readonly WindowProcedure _procedure; // Keeps the delegate alive for the window's lifetime. private readonly ManualResetEventSlim _started = new(false); private Thread? _thread; private IntPtr _window; private int _signalled; + private bool _sessionNotifications; private delegate IntPtr WindowProcedure(IntPtr window, uint message, IntPtr wParam, IntPtr lParam); - private SessionEndSentinel(Action sessionEnding, Action? sessionContinuing) + private SessionEndSentinel(Action notice) { - _sessionEnding = sessionEnding; - _sessionContinuing = sessionContinuing; + _notice = notice; _procedure = Procedure; } - internal static SessionEndSentinel? TryStart(Action sessionEnding, Action? sessionContinuing = null) + internal static SessionEndSentinel? TryStart(Action notice) { - var sentinel = new SessionEndSentinel(sessionEnding, sessionContinuing); + var sentinel = new SessionEndSentinel(notice); try { sentinel._thread = new Thread(sentinel.Run) { IsBackground = true, Name = "Wisp session sentinel" }; @@ -67,6 +75,10 @@ private void Run() catch (Exception) { } finally { _started.Set(); } if (_window == IntPtr.Zero) return; + // Lock and unlock notices need a registration, which can wait for the Terminal Services service; + // without one the sentinel still records shutdowns and sleep. + try { _sessionNotifications = WTSRegisterSessionNotification(_window, NOTIFY_FOR_THIS_SESSION); } + catch (Exception) { } while (GetMessageW(out var message, IntPtr.Zero, 0, 0) > 0) { TranslateMessage(ref message); @@ -85,7 +97,21 @@ private IntPtr Procedure(IntPtr window, uint message, IntPtr wParam, IntPtr lPar if (wParam != IntPtr.Zero) Signal(); else Continue(); return IntPtr.Zero; + case WM_POWERBROADCAST: + if ((long)wParam == PBT_APMSUSPEND) Notify(SessionNotice.Suspending); + else if ((long)wParam is PBT_APMRESUMEAUTOMATIC or PBT_APMRESUMESUSPEND) Notify(SessionNotice.Resumed); + return 1; + case WM_WTSSESSION_CHANGE: + if ((long)wParam == WTS_SESSION_LOCK) Notify(SessionNotice.Locked); + else if ((long)wParam == WTS_SESSION_UNLOCK) Notify(SessionNotice.Unlocked); + return IntPtr.Zero; case WM_CLOSE: + if (_sessionNotifications) + { + _sessionNotifications = false; + try { WTSUnRegisterSessionNotification(window); } + catch (Exception) { } + } DestroyWindow(window); return IntPtr.Zero; case WM_DESTROY: @@ -99,8 +125,7 @@ private IntPtr Procedure(IntPtr window, uint message, IntPtr wParam, IntPtr lPar private void Signal() { if (Interlocked.Exchange(ref _signalled, 1) != 0) return; - try { _sessionEnding(); } - catch (Exception) { } + Notify(SessionNotice.Ending); } // Windows asked every window whether the session may end and a program vetoed it: the close @@ -108,7 +133,12 @@ private void Signal() private void Continue() { if (Interlocked.Exchange(ref _signalled, 0) == 0) return; - try { _sessionContinuing?.Invoke(); } + Notify(SessionNotice.Continuing); + } + + private void Notify(SessionNotice notice) + { + try { _notice(notice); } catch (Exception) { } } @@ -182,4 +212,12 @@ private static extern IntPtr CreateWindowExW(int exStyle, string className, stri [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr GetModuleHandleW(string? moduleName); + + [DllImport("wtsapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool WTSRegisterSessionNotification(IntPtr window, uint flags); + + [DllImport("wtsapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool WTSUnRegisterSessionNotification(IntPtr window); } diff --git a/src/Wisp.App/CrashDiagnostics/WindowsFaultEventReader.cs b/src/Wisp.App/CrashDiagnostics/WindowsFaultEventReader.cs index b96b4c6e..e3760d9e 100644 --- a/src/Wisp.App/CrashDiagnostics/WindowsFaultEventReader.cs +++ b/src/Wisp.App/CrashDiagnostics/WindowsFaultEventReader.cs @@ -24,6 +24,10 @@ internal sealed record RecoveredExitInfo(DateTimeOffset RunStartedAtUtc, DateTim public bool WindowsStopped { get; init; } // Windows shut down or signed the user out within minutes of the run's last checkpoint while Wisp was still running. public bool WindowsShutDown { get; init; } + // Windows was about to sleep or hibernate when the run was last seen, and the run never resumed. + public bool Suspended { get; init; } + // The Windows session was locked when the run was last seen. + public bool SessionLocked { get; init; } internal RecoveredExitInfo? Sanitize() { @@ -38,7 +42,9 @@ internal sealed record RecoveredExitInfo(DateTimeOffset RunStartedAtUtc, DateTim Fault = fault with { Module = WindowsFaultEventReader.SafeModule(fault.Module), ModuleVersion = WindowsFaultEventReader.SafeVersion(fault.ModuleVersion) }, Native = Native?.Sanitize(), WindowsStopped = false, - WindowsShutDown = false + WindowsShutDown = false, + Suspended = false, + SessionLocked = false }; } return this with { Fault = null, Native = Native?.Sanitize() }; diff --git a/src/Wisp.App/DebugLogging/SupplementaryIncidentCapture.cs b/src/Wisp.App/DebugLogging/SupplementaryIncidentCapture.cs index d2402758..c4aaf5fd 100644 --- a/src/Wisp.App/DebugLogging/SupplementaryIncidentCapture.cs +++ b/src/Wisp.App/DebugLogging/SupplementaryIncidentCapture.cs @@ -36,7 +36,9 @@ internal static SupplementaryIncident Crash(CrashReport report) return SupplementaryIncidentSchema.Create(category, reason: category == "windows-fault" ? "windows-application-fault" : category != "unexpected-exit" ? "exception-raised" : safe?.RecoveredExit?.WindowsStopped == true ? "system-stopped" : - safe?.RecoveredExit?.WindowsShutDown == true ? "system-shutdown" : "shutdown-incomplete", + safe?.RecoveredExit?.WindowsShutDown == true ? "system-shutdown" : + safe?.RecoveredExit?.Suspended == true ? "system-sleep" : + safe?.RecoveredExit?.SessionLocked == true ? "session-locked" : "shutdown-incomplete", failureStage: "crash-" + origin, code: safe?.RecoveredExit?.Fault?.ExceptionCode ?? (safe?.Exceptions.FirstOrDefault() is { } first ? unchecked((uint)first.HResult) : null), exceptions: Exceptions(safe?.Exceptions), context: Context(safe?.Context, report.TimeUtc, diff --git a/src/Wisp.App/DiagnosticsViewModel.CrashReports.cs b/src/Wisp.App/DiagnosticsViewModel.CrashReports.cs index d61ced87..668308c2 100644 --- a/src/Wisp.App/DiagnosticsViewModel.CrashReports.cs +++ b/src/Wisp.App/DiagnosticsViewModel.CrashReports.cs @@ -28,7 +28,7 @@ internal void InitializeCrashReport(CrashReport? report, Func acknow { CrashReportNoticeGeneration++; var safe = report?.Sanitize(); - _crashReport = EndedByWindowsShutdown(safe) ? null : safe; + _crashReport = EndedWithoutTheUser(safe) ? null : safe; _acknowledgeCrashReport = acknowledge; _crashReportCopyStatus = ""; NotifyCrashReport(); @@ -36,14 +36,16 @@ internal void InitializeCrashReport(CrashReport? report, Func acknow internal bool TryInitializeRecoveredCrashReport(CrashReport report, long expectedGeneration, Func acknowledge) { - if (CrashReportNoticeGeneration != expectedGeneration || EndedByWindowsShutdown(report.Sanitize())) return false; + if (CrashReportNoticeGeneration != expectedGeneration || EndedWithoutTheUser(report.Sanitize())) return false; InitializeCrashReport(report, acknowledge); return true; } - // Windows shutting down or signing out while Wisp ran is not an unexpected end, so that run gets no card. + // Windows shutting down or signing out while Wisp ran is not an unexpected end, and neither is a run that + // ended while the PC was asleep or its session locked, so those runs get no card. // The report stays in the store, where the diagnostics bundle and Tools reporting read it. - private static bool EndedByWindowsShutdown(CrashReport? report) => report?.RecoveredExit?.WindowsShutDown == true; + private static bool EndedWithoutTheUser(CrashReport? report) => + report?.RecoveredExit is { } exit && (exit.WindowsShutDown || exit.Suspended || exit.SessionLocked); public void ReportCrashDetailsCopied(bool copied) { diff --git a/src/Wisp.App/Supplementary/SupplementaryIncident.cs b/src/Wisp.App/Supplementary/SupplementaryIncident.cs index 73eeb26d..dfb6b57f 100644 --- a/src/Wisp.App/Supplementary/SupplementaryIncident.cs +++ b/src/Wisp.App/Supplementary/SupplementaryIncident.cs @@ -41,7 +41,7 @@ internal static class SupplementaryIncidentSchema { "unknown", "application-still-running", "restarted", "deferred", "no-verified-installation", "restart-failed" }; internal static readonly HashSet Reasons = new(StringComparer.Ordinal) { - "exception-raised", "windows-application-fault", "shutdown-incomplete", "system-stopped", "system-shutdown", + "exception-raised", "windows-application-fault", "shutdown-incomplete", "system-stopped", "system-shutdown", "system-sleep", "session-locked", "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", diff --git a/tests/Wisp.App.Tests/DiagnosticsViewModelCrashNoticeTests.cs b/tests/Wisp.App.Tests/DiagnosticsViewModelCrashNoticeTests.cs index ee84495a..e0c0c900 100644 --- a/tests/Wisp.App.Tests/DiagnosticsViewModelCrashNoticeTests.cs +++ b/tests/Wisp.App.Tests/DiagnosticsViewModelCrashNoticeTests.cs @@ -36,6 +36,20 @@ public void ARecoveredNormalWindowsShutdownLeavesTheDashboardUntouched() Assert.Equal("Windows stopped while Wisp was running", model.CrashReportTitle); } + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void ARunThatEndedOnASleepingOrLockedPcGetsNoCard(bool suspended, bool locked) + { + var model = new DiagnosticsViewModel(new AppSettings()); + var report = RecoveredReport(); + report = report with { RecoveredExit = report.RecoveredExit! with { Suspended = suspended, SessionLocked = locked } }; + model.InitializeCrashReport(report, _ => throw new InvalidOperationException()); + Assert.False(model.HasCrashReport); + Assert.False(model.TryInitializeRecoveredCrashReport(report, model.CrashReportNoticeGeneration, _ => true)); + Assert.False(model.HasCrashReport); + } + private static CrashReport RecoveredReport(bool stopped = false, bool shutDown = false) { var marker = WindowsFaultEventReaderTests.Marker(); diff --git a/tests/Wisp.App.Tests/PreviousRunRecoveryTests.cs b/tests/Wisp.App.Tests/PreviousRunRecoveryTests.cs index 8cc1a6d3..009979a0 100644 --- a/tests/Wisp.App.Tests/PreviousRunRecoveryTests.cs +++ b/tests/Wisp.App.Tests/PreviousRunRecoveryTests.cs @@ -47,10 +47,13 @@ public void RecoveredFaultFieldsAreSanitizedAgainBeforePersistenceAndCopy() Origin = CrashOrigin.WindowsApplicationFault, RecoveredExit = new(marker.StartedAtUtc, detected, WindowsFaultLookupStatus.Matched, new(marker.StartedAtUtc.AddMinutes(1), 0xc0000005, 1234, "C:\\PRIVATE_PATH\\ntdll.dll", false)) + { Suspended = true, SessionLocked = true } }; Assert.True(fixture.Store.TrySave(report)); var loaded = fixture.Store.LatestPending()!; Assert.Null(loaded.RecoveredExit!.Fault!.Module); + // A matched Windows fault ended the run, whatever state the PC was in. + Assert.False(loaded.RecoveredExit.Suspended); Assert.False(loaded.RecoveredExit.SessionLocked); Assert.DoesNotContain("PRIVATE_", loaded.Format(), StringComparison.Ordinal); Assert.DoesNotContain("PRIVATE_", System.Text.Json.JsonSerializer.Serialize(loaded), StringComparison.Ordinal); Assert.Null((report with @@ -205,6 +208,62 @@ public async Task AWindowsFaultOrARecordedShutdownWinsOverAWindowsStop() Assert.Equal(0, stops.Calls); } + [Theory] + [InlineData(true, false, "system-sleep")] + [InlineData(false, true, "session-locked")] + [InlineData(true, true, "system-sleep")] + [InlineData(false, false, "shutdown-incomplete")] + public async Task ASleepingPcOrALockedSessionAtTheLastCheckpointNamesTheExit(bool suspended, bool locked, string reason) + { + using var fixture = new Fixture(); + var marker = WindowsFaultEventReaderTests.Marker() with + { + SuspendedAtUtc = suspended ? WindowsFaultEventReaderTests.Started.AddMinutes(2) : null, + SessionLockedAtUtc = locked ? WindowsFaultEventReaderTests.Started.AddMinutes(1) : null + }; + var report = await fixture.Recover(marker, new Source(_ => new(WindowsFaultLookupStatus.NoMatchingEvent)), stops: new Stops(false)); + Assert.NotNull(report); + Assert.Equal(CrashOrigin.UnexpectedExit, report.Origin); + Assert.Equal(suspended, report.RecoveredExit!.Suspended); + Assert.Equal(locked, report.RecoveredExit.SessionLocked); + var saved = fixture.Store.LatestPending()!.RecoveredExit!; + Assert.Equal(suspended, saved.Suspended); Assert.Equal(locked, saved.SessionLocked); + Assert.Equal(reason, Wisp.App.DebugLogging.SupplementaryIncidentCapture.Crash(report).Reason); + Assert.Contains(suspended ? "sleep or hibernate" : locked ? "session was locked" : "cause is not established", report.Format(), StringComparison.Ordinal); + } + + [Theory] + [InlineData(true, false, "system-stopped")] + [InlineData(false, true, "system-shutdown")] + public async Task AWindowsStopOrShutdownWinsOverASleepingPcOrALockedSession(bool stopped, bool shutDown, string reason) + { + using var fixture = new Fixture(); + var marker = WindowsFaultEventReaderTests.Marker() with + { + SuspendedAtUtc = WindowsFaultEventReaderTests.Started.AddMinutes(2), + SessionLockedAtUtc = WindowsFaultEventReaderTests.Started.AddMinutes(1) + }; + var stops = new Stops(stopped) { ShutDown = () => shutDown }; + var report = await fixture.Recover(marker, new Source(_ => new(WindowsFaultLookupStatus.NoMatchingEvent)), stops: stops); + Assert.NotNull(report); + Assert.False(report.RecoveredExit!.Suspended); Assert.False(report.RecoveredExit.SessionLocked); + Assert.Equal(reason, Wisp.App.DebugLogging.SupplementaryIncidentCapture.Crash(report).Reason); + } + + [Fact] + public async Task AWindowsFaultOrARecordedShutdownWinsOverASleepingPc() + { + using var fixture = new Fixture(); + var marker = WindowsFaultEventReaderTests.Marker() with { SuspendedAtUtc = WindowsFaultEventReaderTests.Started.AddMinutes(2) }; + var fault = await fixture.Recover(marker, new Source(_ => WindowsFaultEventReader.Correlate([WindowsFaultEventReaderTests.Fault(marker)], + marker, WindowsFaultEventReaderTests.Started.AddMinutes(5))), stops: new Stops(false)); + Assert.Equal(CrashOrigin.WindowsApplicationFault, fault!.Origin); + Assert.False(fault.RecoveredExit!.Suspended); + Assert.Equal("windows-application-fault", Wisp.App.DebugLogging.SupplementaryIncidentCapture.Crash(fault).Reason); + Assert.Null(await fixture.Recover(marker with { ShutdownIntent = ShutdownIntent.OperatingSystem }, + new Source(_ => new(WindowsFaultLookupStatus.NoMatchingEvent)), stops: new Stops(false))); + } + [Fact] public async Task AFailedWindowsStopLookupLeavesTheExitUnexplained() { diff --git a/tests/Wisp.App.Tests/RunExitMarkerStoreTests.cs b/tests/Wisp.App.Tests/RunExitMarkerStoreTests.cs index 98ed71ab..d8f29d7a 100644 --- a/tests/Wisp.App.Tests/RunExitMarkerStoreTests.cs +++ b/tests/Wisp.App.Tests/RunExitMarkerStoreTests.cs @@ -70,6 +70,71 @@ public void ShutdownIntentSurvivesCheckpointsAndRestartWithoutClaimingCompletedS Assert.False(next.MarkShutdownStarted(ShutdownIntent.Requested)); } + [Fact] + public void SleepAndLockNoticesSurviveCheckpointsAndRestartUntilWithdrawn() + { + using var directory = new TemporaryDirectory(); + var marker = Marker(0); + var locked = marker.StartedAtUtc.AddSeconds(5); + var suspended = marker.StartedAtUtc.AddSeconds(15); + using (var owner = RunExitMarkerStore.TryBegin(directory.Path, marker)) + { + Assert.False(owner!.MarkResumed()); Assert.False(owner.MarkSessionUnlocked()); + Assert.False(owner.MarkSuspended(marker.StartedAtUtc.AddSeconds(-1))); + Assert.False(owner.MarkSuspended(suspended.ToOffset(TimeSpan.FromHours(1)))); + Assert.True(owner.MarkSessionLocked(locked)); + Assert.True(owner.MarkSuspended(suspended)); + // A checkpoint right after the sleep notice keeps it: the PC is still on its way to sleep. + Assert.True(owner.Checkpoint(new(suspended.AddSeconds(2), [new() { TimestampUtc = suspended.AddSeconds(1) }], []))); + Assert.Equal(suspended, Read(directory).SuspendedAtUtc); + Assert.Equal(locked, Read(directory).SessionLockedAtUtc); + } + using (var next = RunExitMarkerStore.TryBegin(directory.Path, Marker(1))) + { + Assert.Equal(suspended, next!.Previous!.SuspendedAtUtc); + Assert.Equal(locked, next.Previous.SessionLockedAtUtc); + Assert.True(next.MarkSuspended(next.StartedAtUtc.AddSeconds(1))); + Assert.True(next.MarkResumed()); Assert.False(next.MarkResumed()); + Assert.True(next.MarkSessionLocked(next.StartedAtUtc.AddSeconds(2))); + Assert.True(next.MarkSessionUnlocked()); Assert.False(next.MarkSessionUnlocked()); + } + using var third = RunExitMarkerStore.TryBegin(directory.Path, Marker(2)); + Assert.Null(third!.Previous!.SuspendedAtUtc); Assert.Null(third.Previous.SessionLockedAtUtc); + } + + [Fact] + public void ACheckpointWellAfterTheSleepNoticeMeansWindowsResumedWithoutSayingSo() + { + using var directory = new TemporaryDirectory(); + var marker = Marker(0); + var suspended = marker.StartedAtUtc.AddSeconds(10); + using (var owner = RunExitMarkerStore.TryBegin(directory.Path, marker)) + { + Assert.True(owner!.MarkSuspended(suspended)); + var captured = suspended + RunExitMarkerStore.ResumeGrace; + Assert.True(owner.Checkpoint(new(captured, [new() { TimestampUtc = captured.AddSeconds(-1) }], []))); + } + using var next = RunExitMarkerStore.TryBegin(directory.Path, Marker(2)); + Assert.Null(next!.Previous!.SuspendedAtUtc); + Assert.Equal(suspended + RunExitMarkerStore.ResumeGrace, next.Previous.Context!.CapturedAtUtc); + } + + [Fact] + public void InvalidSleepOrLockTimesInAMarkerAreDroppedWithoutLosingTheRun() + { + using var directory = new TemporaryDirectory(); + var marker = Marker(0); + File.WriteAllBytes(Path.Combine(directory.Path, "running.json"), JsonSerializer.SerializeToUtf8Bytes(marker with + { + SuspendedAtUtc = marker.StartedAtUtc.AddMinutes(-1), + SessionLockedAtUtc = marker.StartedAtUtc.AddSeconds(30).ToOffset(TimeSpan.FromHours(2)) + })); + using var next = RunExitMarkerStore.TryBegin(directory.Path, Marker(1)); + var previous = Assert.IsType(next!.Previous); + Assert.Null(previous.SuspendedAtUtc); Assert.Null(previous.SessionLockedAtUtc); + Assert.Equal(marker.RunId, previous.RunId); + } + [Fact] public void CheckpointSurvivesRestartWithBoundedSamplesAndOriginalAges() { diff --git a/tests/Wisp.App.Tests/SupplementaryIncidentCaptureTests.cs b/tests/Wisp.App.Tests/SupplementaryIncidentCaptureTests.cs index bc122eea..2629e0da 100644 --- a/tests/Wisp.App.Tests/SupplementaryIncidentCaptureTests.cs +++ b/tests/Wisp.App.Tests/SupplementaryIncidentCaptureTests.cs @@ -40,6 +40,28 @@ public void CrashProjectionPreservesOriginAndSanitizedFaultEvidence(int origin, if (fault is not null) { Assert.Equal("ntdll.dll", incident.Crash.Module); Assert.Equal(0xc0000005u, incident.Code); } } + [Theory] + [InlineData(false, false, false, false, "shutdown-incomplete")] + [InlineData(false, false, true, false, "system-sleep")] + [InlineData(false, false, false, true, "session-locked")] + [InlineData(false, false, true, true, "system-sleep")] + [InlineData(true, false, true, true, "system-stopped")] + [InlineData(false, true, true, true, "system-shutdown")] + public void RecoveredExitReasonsNameASleepingPcOrALockedSessionBehindWindowsEvents(bool stopped, bool shutDown, bool suspended, bool locked, string reason) + { + var marker = WindowsFaultEventReaderTests.Marker(); + var now = marker.StartedAtUtc.AddMinutes(5); + var report = marker.ToReport(now) with + { + RecoveredExit = new(marker.StartedAtUtc, now, WindowsFaultLookupStatus.NoMatchingEvent, null) + { WindowsStopped = stopped, WindowsShutDown = shutDown, Suspended = suspended, SessionLocked = locked } + }; + var incident = SupplementaryIncidentCapture.Crash(report); + Assert.Equal("unexpected-exit", incident.Category); + Assert.Equal(reason, incident.Reason); + Assert.True(SupplementaryIncidentSchema.Valid(incident)); + } + [Theory] [InlineData((int)ClipRecorderState.WaitingForGame, "waiting")] [InlineData((int)ClipRecorderState.Stopping, "stopping")]