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")]