From 4609cdd43703c0a5985b217a1610491e4406bbf0 Mon Sep 17 00:00:00 2001 From: Views2k <292889387+Views2k@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:17:41 +0000 Subject: [PATCH] Rebuild the HUD host after failed window calls instead of retiring it A host-window Win32 call (CreateWindowEx, GetClientRect, ClientToScreen, MonitorFromWindow, GetMonitorInfo, SetWindowLongPtr or SetWindowPos) can fail while the game attaches or the player returns from another window. After four failures 500 ms apart the HUD window's native renderer was retired for the rest of the run: the failed flag was never cleared and HUD windows are created once, so that window stayed blank until Wisp was restarted. The failure report carried E_FAIL, which every Win32Exception reports, so neither the Win32 error nor the failing call was known. Host-window failures are now named at each call site and reported once per episode with HRESULT_FROM_WIN32 of the Win32 error code, in the breadcrumb and in the renderer status text. When an episode's retries run out, the host window and its renderer are released and built again on the next attach above the game (game start or foreground return), at most once per such event. Three consecutive exhausted episodes without a presented frame fall back to the previous permanent failure. The WPF artwork stays suppressed throughout, as before. Tests cover the retry, rebuild and retirement bounds, the failure details, and a live host that is released after four failed synchronizations and presents again through a new HWND after the next attach. --- src/Wisp.App/NativeRendering/HudNativeHost.cs | 72 ++++-- .../NativeRendering/HudNativeHostRecovery.cs | 57 +++++ .../NativeCompositionWindow.cs | 41 +++- src/Wisp.App/WindowZOrder.cs | 3 + .../HudNativeHostRecoveryTests.cs | 227 ++++++++++++++++++ tests/Wisp.App.Tests/HudNativeHostTests.cs | 5 + 6 files changed, 374 insertions(+), 31 deletions(-) create mode 100644 src/Wisp.App/NativeRendering/HudNativeHostRecovery.cs create mode 100644 tests/Wisp.App.Tests/HudNativeHostRecoveryTests.cs diff --git a/src/Wisp.App/NativeRendering/HudNativeHost.cs b/src/Wisp.App/NativeRendering/HudNativeHost.cs index 3f66fa90..7fc29c60 100644 --- a/src/Wisp.App/NativeRendering/HudNativeHost.cs +++ b/src/Wisp.App/NativeRendering/HudNativeHost.cs @@ -28,6 +28,7 @@ internal sealed class HudNativeHost : IDisposable private bool _interactive; private HudWindowSnapshot? _snapshot; private readonly NativeRendererRecovery _recovery = new(); + private readonly HudNativeHostRecovery _hostRecovery = new(); private static readonly TimeSpan RendererReleaseTimeout = TimeSpan.FromSeconds(5); private bool _scheduled, _capturing, _failed, _retryPending, _disposed; private int _nextId, _workerRevision; @@ -60,6 +61,14 @@ internal static void SynchronizeWindow(Window window) catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException) { host.OnHostFailure(error.HResult); } } + // The window was attached above the game, at the game's start or on a return to the foreground. + // A host released because its window calls kept failing at an earlier transition is built again + // now: once per such event, so a window that keeps failing cannot spin. + internal static void RebuildOnGameAttach(Window window) + { + if (!Hosts.TryGetValue(window, out var host) || host._disposed || host._failed) return; + if (host._hostRecovery.TryBeginRebuild()) host.RequestCapture(); + } internal static void SetInteractive(Window window, bool interactive) { if (!Hosts.TryGetValue(window, out var host) || host._disposed || host._failed) return; @@ -156,7 +165,8 @@ NativeGForceMeterView or GForceMeterView or Laps.LapDeltaView or Laps.TrackMapVi private void RequestCapture() { - if (_disposed || _failed || _retryPending || _scheduled || _capturing || _window.Dispatcher.HasShutdownStarted) return; + if (_disposed || _failed || _retryPending || _hostRecovery.RebuildPending || _scheduled || _capturing || + _window.Dispatcher.HasShutdownStarted) return; _scheduled = true; _window.Dispatcher.BeginInvoke(DispatcherPriority.DataBind, new Action(() => { @@ -293,9 +303,9 @@ private void OnPresented(HudWindowSnapshot presented) if (_window.Dispatcher.HasShutdownStarted) return; _window.Dispatcher.BeginInvoke(new Action(() => { - if (_disposed || _failed || _snapshot is null || !presented.CompatibleWith(_snapshot)) return; + if (_disposed || _failed || _hostRecovery.RebuildPending || _snapshot is null || !presented.CompatibleWith(_snapshot)) return; _recovery.Reset(); - _hostRetries = 0; + _hostRecovery.Presented(); try { _compositionWindow?.SetLayoutCloaked(!_interactive); } catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException) { OnHostFailure(error.HResult); return; } @@ -315,24 +325,35 @@ private void OnHostFailure(int hresult) OnStatus(false, hresult); } - // The layout window, its owner or its monitor can change under a Win32 call while a display is - // added or removed or the game closes. The renderer is intact; the host window is synchronised - // again shortly, up to HostRetries times before the failure retires the HUD. - internal const int HostRetries = 3; - private static readonly TimeSpan HostRetryDelay = TimeSpan.FromMilliseconds(500); - private int _hostRetries; - + // A host-window Win32 call failed; HudNativeHostRecovery bounds the retries, the rebuild on the + // next game attach and the retirement. The report names the call and carries HRESULT_FROM_WIN32 + // of its error code, once per episode, since every Win32Exception reports E_FAIL itself. private void OnHostInterop(Win32Exception error) { - DebugLogging.HealthContextRecorder.Current.RecordRendererFailure(error.HResult, DebugLogging.HealthFailureStage.RendererHost); - if (_disposed || _failed || _retryPending) return; - if (++_hostRetries > HostRetries) + if (_disposed || _failed || _retryPending || _hostRecovery.RebuildPending) return; + var hresult = NativeHostInteropException.FailureCodeOf(error); + var call = NativeHostInteropException.CallOf(error); + if (_hostRecovery.Retries == 0) + DebugLogging.HealthContextRecorder.Current.RecordRendererFailure(hresult, DebugLogging.HealthFailureStage.RendererHost); + switch (_hostRecovery.Fail()) { - OnHostFailure(error.HResult); - return; + case HudNativeHostRecoveryAction.Retry: + // The renderer is intact; the host window is synchronized again shortly. + _retryPending = true; + _vm?.ReportRendererHost(this, RendererHostState.Starting, $"HUD renderer: host window retry after {call} (0x{hresult:X8})"); + _ = RestartRendererAfterRelease(Task.CompletedTask, HudNativeHostRecovery.HostRetryDelay); + return; + case HudNativeHostRecoveryAction.Rebuild: + // The window stays unusable at this transition. Its renderer and HWND are released + // and built again when the game attaches or returns to the foreground; the WPF + // artwork stays suppressed meanwhile, as during any other native failure. + RetireRenderer(); + _vm?.ReportRendererHost(this, RendererHostState.Starting, $"HUD renderer: host window rebuild pending after {call} (0x{hresult:X8})"); + return; + default: + ReportStatus(false, hresult, call); + return; } - _retryPending = true; - _ = RestartRendererAfterRelease(Task.CompletedTask, HostRetryDelay); } // A retired worker's late status must not fail or restart its replacement. @@ -344,12 +365,14 @@ private void OnWorkerStatus(int revision, bool ready, int hresult) if (revision == _workerRevision) ApplyStatus(ready, hresult, fromWorker: true); })); } - private void OnStatus(bool ready, int hresult) + private void OnStatus(bool ready, int hresult) => ReportStatus(ready, hresult, call: null); + // `call` names the host-window Win32 call behind a failure, when one is known. + private void ReportStatus(bool ready, int hresult, string? call) { if (_window.Dispatcher.HasShutdownStarted) return; - _window.Dispatcher.BeginInvoke(new Action(() => ApplyStatus(ready, hresult))); + _window.Dispatcher.BeginInvoke(new Action(() => ApplyStatus(ready, hresult, call: call))); } - private void ApplyStatus(bool ready, int hresult, bool fromWorker = false) + private void ApplyStatus(bool ready, int hresult, bool fromWorker = false, string? call = null) { if (_disposed) return; // An occluded device may initialize without submitting. Only the @@ -359,8 +382,9 @@ private void ApplyStatus(bool ready, int hresult, bool fromWorker = false) // One device loss can be reported by the render thread and the needle motion thread; the // second report must not spend another retry or restart a retry already scheduled. A worker // that reports while a host-window retry is pending has ended its thread: it is retired now, - // so that retry rebuilds the window and the renderer instead of reusing a dead worker. - if (_retryPending) + // so that retry rebuilds the window and the renderer instead of reusing a dead worker. A + // host waiting for its rebuild has released its worker already; a late report changes nothing. + if (_retryPending || _hostRecovery.RebuildPending) { if (fromWorker) RetireRenderer(); return; @@ -377,7 +401,9 @@ private void ApplyStatus(bool ready, int hresult, bool fromWorker = false) return; } _failed = true; - _vm?.ReportRendererHost(this, RendererHostState.Failed, $"HUD renderer: unavailable (0x{hresult:X8})"); + _vm?.ReportRendererHost(this, RendererHostState.Failed, call is null + ? $"HUD renderer: unavailable (0x{hresult:X8})" + : $"HUD renderer: unavailable after {call} (0x{hresult:X8})"); } // Disposes the worker and its composition window; the returned task completes when the worker // has released the renderer. diff --git a/src/Wisp.App/NativeRendering/HudNativeHostRecovery.cs b/src/Wisp.App/NativeRendering/HudNativeHostRecovery.cs new file mode 100644 index 00000000..36a89a87 --- /dev/null +++ b/src/Wisp.App/NativeRendering/HudNativeHostRecovery.cs @@ -0,0 +1,57 @@ +namespace Wisp.App.NativeRendering; + +internal enum HudNativeHostRecoveryAction { Retry, Rebuild, Retire } + +// Bounded recovery of a HUD window's native host from failed host-window Win32 calls. The layout +// window, its owner or its monitor can change under such a call while a display is added or +// removed, the game attaches or the player returns from another window. The renderer is intact, +// so the host window is synchronized again after HostRetryDelay, up to HostRetries times in one +// episode. An episode whose retries run out releases the host window and its renderer instead of +// retiring the HUD until Wisp restarts: the same window presents normally at the next transition +// on the same PC, so the host is built again on the next game attach or foreground return, once +// per such event. Only MaximumFailedEpisodes consecutive exhausted episodes, with no frame +// presented between them, retire the HUD. +internal sealed class HudNativeHostRecovery +{ + internal const int HostRetries = 3; + internal static readonly TimeSpan HostRetryDelay = TimeSpan.FromMilliseconds(500); + internal const int MaximumFailedEpisodes = 3; + private int _retries, _failedEpisodes; + private bool _rebuildPending; + + // Failed calls in the current episode; zero before an episode's first failure. + internal int Retries => _retries; + internal int FailedEpisodes => _failedEpisodes; + // True while the released host waits for the next attach or foreground return. + internal bool RebuildPending => _rebuildPending; + + // One failed host-window call: synchronize again within the episode, release the host and + // rebuild it on the next event once the retries ran out, or retire the HUD after + // MaximumFailedEpisodes such episodes. + internal HudNativeHostRecoveryAction Fail() + { + if (++_retries <= HostRetries) return HudNativeHostRecoveryAction.Retry; + _retries = 0; + if (++_failedEpisodes >= MaximumFailedEpisodes) return HudNativeHostRecoveryAction.Retire; + _rebuildPending = true; + return HudNativeHostRecoveryAction.Rebuild; + } + + // Called on each attach or foreground return. True once per pending rebuild, so one event + // starts at most one rebuild attempt; that attempt's own failures form the next episode. + internal bool TryBeginRebuild() + { + if (!_rebuildPending) return false; + _rebuildPending = false; + return true; + } + + // A presented frame proves the host window works; the next failure starts a first episode. A + // host waiting for its rebuild has no renderer, so a late frame from the released one does not count. + internal void Presented() + { + if (_rebuildPending) return; + _retries = 0; + _failedEpisodes = 0; + } +} diff --git a/src/Wisp.App/NativeRendering/NativeCompositionWindow.cs b/src/Wisp.App/NativeRendering/NativeCompositionWindow.cs index c2e19f45..b14523e9 100644 --- a/src/Wisp.App/NativeRendering/NativeCompositionWindow.cs +++ b/src/Wisp.App/NativeRendering/NativeCompositionWindow.cs @@ -25,6 +25,28 @@ internal static NativeHostPlacement Create(NativeHostBounds layout, NativeHostBo } } +// A failed host-window Win32 call, named. Win32Exception reports E_FAIL as its HResult whichever +// call failed, so failure reports carry the call and HRESULT_FROM_WIN32 of its error code instead. +internal sealed class NativeHostInteropException : Win32Exception +{ + private const int UnspecifiedFailure = unchecked((int)0x80004005); // E_FAIL + + internal NativeHostInteropException(string call, int error) : base(error) => Call = call; + + internal string Call { get; } + internal int FailureCode => FailureCodeOf(this); + + internal static string CallOf(Win32Exception error) => (error as NativeHostInteropException)?.Call ?? "Win32"; + + // HRESULT_FROM_WIN32 of the error code. A call that left no error code keeps E_FAIL. + internal static int FailureCodeOf(Win32Exception error) => error.NativeErrorCode switch + { + 0 => UnspecifiedFailure, + < 0 => error.NativeErrorCode, + var code => unchecked((int)0x80070000) | (code & 0xFFFF) + }; +} + // A Win32-only target: no HwndSource/HwndTarget, GDI bitmap, or WPF pixels. // The WPF window remains the layout/editing model, outside the visible HUD path. internal sealed class NativeCompositionWindow : IDisposable @@ -61,7 +83,7 @@ internal NativeCompositionWindow(HwndSource layoutSource, Action changed, Action Handle = CreateWindowEx(NoRedirection | Transparent | ToolWindow | NoActivate | 0x00080000, ClassName, "Wisp native HUD", 0x80000000, 0, 0, 1, 1, IntPtr.Zero, IntPtr.Zero, GetModuleHandle(null), IntPtr.Zero); - if (Handle == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (Handle == IntPtr.Zero) throw Failure("CreateWindowEx"); _ = OverlayPassiveUpdate.Apply(Handle, native: true); _layoutSource.AddHook(LayoutWindowProcedure); } @@ -74,8 +96,8 @@ internal void Synchronize(bool visible, bool raise = false) { var layout = _layoutSource.Handle; var origin = new Point(); - if (!GetClientRect(layout, out var client) || !ClientToScreen(layout, ref origin)) - throw new Win32Exception(Marshal.GetLastWin32Error()); + if (!GetClientRect(layout, out var client)) throw Failure("GetClientRect"); + if (!ClientToScreen(layout, ref origin)) throw Failure("ClientToScreen"); var layoutBounds = new NativeHostBounds(origin.X, origin.Y, origin.X + client.Right, origin.Y + client.Bottom); var monitorBounds = layoutBounds; @@ -83,8 +105,8 @@ internal void Synchronize(bool visible, bool raise = false) { var monitor = MonitorFromWindow(layout, 2); // MONITOR_DEFAULTTONEAREST var info = new MonitorInfo { Size = (uint)Marshal.SizeOf() }; - if (monitor == IntPtr.Zero || !GetMonitorInfo(monitor, ref info)) - throw new Win32Exception(Marshal.GetLastWin32Error()); + if (monitor == IntPtr.Zero) throw Failure("MonitorFromWindow"); + if (!GetMonitorInfo(monitor, ref info)) throw Failure("GetMonitorInfo"); monitorBounds = new(info.Monitor.Left, info.Monitor.Top, info.Monitor.Right, info.Monitor.Bottom); } var placement = NativeHostPlacement.Create(layoutBounds, monitorBounds, _fillMonitor); @@ -105,7 +127,7 @@ internal void Synchronize(bool visible, bool raise = false) Marshal.SetLastPInvokeError(0); var previousOwner = SetWindowLongPtr(Handle, -8, owner); var error = Marshal.GetLastPInvokeError(); - if (previousOwner == IntPtr.Zero && error != 0) throw new Win32Exception(error); + if (previousOwner == IntPtr.Zero && error != 0) throw new NativeHostInteropException("SetWindowLongPtr", error); raise = true; } var topmost = (GetWindowLong(layout, -20) & 8) != 0; @@ -116,7 +138,7 @@ internal void Synchronize(bool visible, bool raise = false) if (!raise && _visible && _topmost == topmost) flags |= 0x0004; // NOZORDER if (!SetWindowPos(Handle, order, bounds.Left, bounds.Top, bounds.Right - bounds.Left, bounds.Bottom - bounds.Top, flags)) - throw new Win32Exception(Marshal.GetLastWin32Error()); + throw Failure("SetWindowPos"); _bounds = bounds; _topmost = topmost; } @@ -193,10 +215,13 @@ private static ushort RegisterWindowClass() ClassName = ClassName }; var atom = RegisterClassEx(ref windowClass); - if (atom == 0) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (atom == 0) throw Failure("RegisterClassEx"); return atom; } + // Called directly after the failed call, before any other interop call replaces its error code. + private static NativeHostInteropException Failure(string call) => new(call, Marshal.GetLastWin32Error()); + private static IntPtr WindowProc(IntPtr hwnd, uint message, IntPtr word, IntPtr data) { if (message == 0x0082) OverlayPassiveUpdate.Forget(hwnd); // WM_NCDESTROY diff --git a/src/Wisp.App/WindowZOrder.cs b/src/Wisp.App/WindowZOrder.cs index d3581c29..2fe35873 100644 --- a/src/Wisp.App/WindowZOrder.cs +++ b/src/Wisp.App/WindowZOrder.cs @@ -46,6 +46,9 @@ public static bool AttachAboveGame(Window? window, IntPtr gameWindow, bool raise helper.Owner = gameWindow; NativeRendering.HudNativeHost.SynchronizeWindow(window); } + // The HUD is above the game again, whether the game attached or came back to the + // foreground: a native host released after failed window calls is built again now. + NativeRendering.HudNativeHost.RebuildOnGameAttach(window); if (raise) { diff --git a/tests/Wisp.App.Tests/HudNativeHostRecoveryTests.cs b/tests/Wisp.App.Tests/HudNativeHostRecoveryTests.cs new file mode 100644 index 00000000..88d88fa9 --- /dev/null +++ b/tests/Wisp.App.Tests/HudNativeHostRecoveryTests.cs @@ -0,0 +1,227 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using Wisp.App.DebugLogging; +using Wisp.App.NativeRendering; +using Xunit; + +namespace Wisp.App.Tests; + +// A host-window Win32 call that failed four times at a transition (alt-tab return, game attach) +// retired the HUD window's native renderer until Wisp restarted, and its report carried E_FAIL +// instead of the Win32 error or the call. The host is released and built again on the next attach +// instead, and only a bounded number of consecutive failed episodes retires it. +public sealed class HudNativeHostRecoveryTests +{ + private const int InvalidWindowHandle = 1400; // ERROR_INVALID_WINDOW_HANDLE + private const int InvalidWindowHandleResult = unchecked((int)0x80070578); // HRESULT_FROM_WIN32(1400) + private const int UnspecifiedFailure = unchecked((int)0x80004005); // E_FAIL + + [Fact] + public void FourFailedSynchronizationsReleaseTheHostUntilTheNextAttach() + { + var recovery = new HudNativeHostRecovery(); + for (var attempt = 1; attempt <= HudNativeHostRecovery.HostRetries; attempt++) + { + Assert.Equal(HudNativeHostRecoveryAction.Retry, recovery.Fail()); + Assert.Equal(attempt, recovery.Retries); + Assert.False(recovery.RebuildPending); + } + Assert.Equal(HudNativeHostRecoveryAction.Rebuild, recovery.Fail()); + Assert.True(recovery.RebuildPending); + Assert.Equal(0, recovery.Retries); + Assert.Equal(1, recovery.FailedEpisodes); + // The next attach or foreground return starts one rebuild; the same event cannot start another. + Assert.True(recovery.TryBeginRebuild()); + Assert.False(recovery.RebuildPending); + Assert.False(recovery.TryBeginRebuild()); + recovery.Presented(); + Assert.Equal(0, recovery.FailedEpisodes); + Assert.Equal(0, recovery.Retries); + } + + [Fact] + public void ConsecutiveFailedEpisodesRetireTheHud() + { + var recovery = new HudNativeHostRecovery(); + for (var episode = 1; episode < HudNativeHostRecovery.MaximumFailedEpisodes; episode++) + { + Assert.Equal(HudNativeHostRecoveryAction.Rebuild, FailEpisode(recovery)); + Assert.Equal(episode, recovery.FailedEpisodes); + Assert.True(recovery.TryBeginRebuild()); + } + Assert.Equal(HudNativeHostRecoveryAction.Retire, FailEpisode(recovery)); + Assert.Equal(HudNativeHostRecovery.MaximumFailedEpisodes, recovery.FailedEpisodes); + Assert.False(recovery.RebuildPending); + } + + [Fact] + public void APresentedFrameEndsTheEpisodeSequence() + { + var recovery = new HudNativeHostRecovery(); + for (var episode = 1; episode < HudNativeHostRecovery.MaximumFailedEpisodes; episode++) + { + Assert.Equal(HudNativeHostRecoveryAction.Rebuild, FailEpisode(recovery)); + Assert.True(recovery.TryBeginRebuild()); + } + recovery.Presented(); + Assert.Equal(0, recovery.FailedEpisodes); + Assert.Equal(HudNativeHostRecoveryAction.Rebuild, FailEpisode(recovery)); + Assert.Equal(1, recovery.FailedEpisodes); + } + + [Fact] + public void ALateFrameFromTheReleasedRendererDoesNotCountWhileARebuildIsPending() + { + var recovery = new HudNativeHostRecovery(); + Assert.Equal(HudNativeHostRecoveryAction.Rebuild, FailEpisode(recovery)); + recovery.Presented(); + Assert.True(recovery.RebuildPending); + Assert.Equal(1, recovery.FailedEpisodes); + } + + [Fact] + public void AFailureNamesTheCallAndCarriesTheWin32CodeAsAnHResult() + { + var error = new NativeHostInteropException("SetWindowPos", InvalidWindowHandle); + Assert.Equal("SetWindowPos", error.Call); + Assert.Equal(InvalidWindowHandle, error.NativeErrorCode); + Assert.Equal(UnspecifiedFailure, error.HResult); // Win32Exception itself reports E_FAIL whichever call failed. + Assert.Equal(InvalidWindowHandleResult, error.FailureCode); + Assert.Equal("SetWindowPos", NativeHostInteropException.CallOf(error)); + Assert.Equal(InvalidWindowHandleResult, NativeHostInteropException.FailureCodeOf(error)); + } + + [Theory] + [InlineData(5, 0x80070005u)] // ERROR_ACCESS_DENIED + [InlineData(0, 0x80004005u)] // no error code was left: E_FAIL stays + public void AnUnnamedWin32FailureStillCarriesItsCode(int code, uint expected) + { + var error = new Win32Exception(code); + Assert.Equal("Win32", NativeHostInteropException.CallOf(error)); + Assert.Equal(unchecked((int)expected), NativeHostInteropException.FailureCodeOf(error)); + } + + private static HudNativeHostRecoveryAction FailEpisode(HudNativeHostRecovery recovery) + { + for (var attempt = 0; attempt < HudNativeHostRecovery.HostRetries; attempt++) + Assert.Equal(HudNativeHostRecoveryAction.Retry, recovery.Fail()); + return recovery.Fail(); + } + + // Driven from HudNativeHostTests on the shared WPF dispatcher against a live, presenting host. + // One failed window call is retried with the renderer kept; four end the episode, release the + // host and leave it waiting until the window is attached above a game again. + internal static void AssertHostRebuildOnAttach(OverlayWindow window, HudNativeHost host, AppController controller, + UserControl analog, Action seed) + { + var retired = HudNativeHost.PresentationHandle(window); + Assert.NotEqual(IntPtr.Zero, retired); + var worker = Worker(host); + var recovery = Field(host, "_hostRecovery"); + Assert.Equal(0, recovery.Retries); + Assert.Equal(0, recovery.FailedEpisodes); + + // A first failure is reported once, with the call and HRESULT_FROM_WIN32 of its error code, + // and the same window is synchronized again after the retry delay. + var before = DateTimeOffset.UtcNow.AddMilliseconds(-1); + Callback(host, "OnHostInterop", new NativeHostInteropException("ClientToScreen", InvalidWindowHandle)); + Assert.Equal(1, recovery.Retries); + Assert.True(Field(host, "_retryPending")); + Assert.Equal("HUD renderer: host window retry after ClientToScreen (0x80070578)", controller.ViewModel.NativeRendererStatus); + seed(); // captured by the retried synchronization, so that a changed frame is presented + var failure = Assert.Single(RendererFailures(before)); + Assert.Equal(InvalidWindowHandleResult, failure.ErrorCode); + Assert.Equal(HealthFailureStage.RendererHost, failure.FailureStage); + PumpUntil(() => !Field(host, "_retryPending") && recovery.Retries == 0 && + controller.ViewModel.NativeRendererStatus.Contains("DirectComposition", StringComparison.Ordinal), 4000, + () => $"step=retry; status={controller.ViewModel.NativeRendererStatus}; retries={recovery.Retries}"); + Assert.Same(worker, Worker(host)); + Assert.Equal(retired, HudNativeHost.PresentationHandle(window)); + Assert.False(Field(host, "_failed")); + + // Three failed synchronizations were retried already; the fourth ends the episode. The + // renderer and its HWND are released, the WPF artwork stays suppressed and nothing short of + // an attach above the game builds the host again. + for (var attempt = 0; attempt < HudNativeHostRecovery.HostRetries; attempt++) + Assert.Equal(HudNativeHostRecoveryAction.Retry, recovery.Fail()); + Callback(host, "OnHostInterop", new NativeHostInteropException("SetWindowPos", InvalidWindowHandle)); + Assert.True(recovery.RebuildPending); + Assert.Equal(1, recovery.FailedEpisodes); + Assert.Equal(0, recovery.Retries); + Assert.False(Field(host, "_failed")); + Assert.False(Field(host, "_retryPending")); + Assert.Single(RendererFailures(before)); // the episode's later failures add no breadcrumb + Assert.Equal("HUD renderer: host window rebuild pending after SetWindowPos (0x80070578)", controller.ViewModel.NativeRendererStatus); + Assert.Equal(IntPtr.Zero, HudNativeHost.PresentationHandle(window)); + Assert.Null(typeof(HudNativeHost).GetField("_worker", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(host)); + PumpUntil(() => !IsWindow(retired), 2000, () => "step=release; the released host window was not destroyed"); + Assert.False(HudNativeHost.IsPresented(analog)); + Assert.True(HudNativeHost.IsWpfContentSuppressed(analog)); + Assert.Equal(Visibility.Hidden, ((UIElement)analog.Content).Visibility); + seed(); + Pump(); + Thread.Sleep(100); + Pump(); + Assert.Equal(IntPtr.Zero, HudNativeHost.PresentationHandle(window)); + Assert.True(recovery.RebuildPending); + Assert.True(window.IsVisible); + + using var owner = ForeignWindowOwner.Start(); + try + { + Assert.True(WindowZOrder.AttachAboveGame(window, owner.Handle, raise: false)); + Assert.False(recovery.RebuildPending); + seed(); + PumpUntil(() => HudNativeHost.PresentationHandle(window) is var rebuilt && rebuilt != IntPtr.Zero && + rebuilt != retired && HudNativeHost.IsPresented(analog), 8000, + () => $"step=rebuild; status={controller.ViewModel.NativeRendererStatus}; handle={HudNativeHost.PresentationHandle(window)}; pending={recovery.RebuildPending}"); + var rebuiltHandle = HudNativeHost.PresentationHandle(window); + Assert.Equal(owner.Handle, GetWindow(rebuiltHandle, 4)); + Assert.NotSame(worker, Worker(host)); + Assert.True(Worker(host).SurfaceVisible); + Assert.Equal(Visibility.Hidden, ((UIElement)analog.Content).Visibility); + PumpUntil(() => recovery.FailedEpisodes == 0, 2000, () => $"step=presented; episodes={recovery.FailedEpisodes}"); + Assert.False(Field(host, "_failed")); + Assert.DoesNotContain("unavailable", controller.ViewModel.NativeRendererStatus, StringComparison.Ordinal); + // An attach without a pending rebuild leaves the working host alone. + var current = Worker(host); + HudNativeHost.RebuildOnGameAttach(window); + Pump(); + Assert.Equal(rebuiltHandle, HudNativeHost.PresentationHandle(window)); + Assert.Same(current, Worker(host)); + } + finally { WindowZOrder.DetachFromGame(window); } + } + + // The breadcrumbs of the injected Win32 failure, apart from anything the renderer itself reports. + private static HealthBreadcrumb[] RendererFailures(DateTimeOffset since) => + HealthContextRecorder.Current.CaptureForReport().Breadcrumbs.Where(crumb => + crumb.Code == HealthEventCode.RendererFailed && crumb.ErrorCode == InvalidWindowHandleResult && + crumb.TimestampUtc >= since).ToArray(); + + private static AnalogHudRenderWorker Worker(HudNativeHost host) => + Assert.IsType(typeof(HudNativeHost) + .GetField("_worker", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(host)); + private static T Field(HudNativeHost host, string name) => + (T)typeof(HudNativeHost).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(host)!; + private static void Callback(HudNativeHost host, string name, params object[] arguments) => + typeof(HudNativeHost).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(host, arguments); + private static void PumpUntil(Func predicate, int milliseconds, Func failureDetails) + { + var timer = Stopwatch.StartNew(); + while (!predicate() && timer.ElapsedMilliseconds < milliseconds) { Pump(); Thread.Sleep(10); } + var reached = predicate(); + Assert.True(reached, reached ? null : $"The native HUD host did not reach the expected recovery state. {failureDetails()}"); + } + private static void Pump() => Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.ContextIdle); + [DllImport("user32.dll")] + private static extern IntPtr GetWindow(IntPtr window, uint command); + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindow(IntPtr window); +} diff --git a/tests/Wisp.App.Tests/HudNativeHostTests.cs b/tests/Wisp.App.Tests/HudNativeHostTests.cs index f89cb6a6..94cc2515 100644 --- a/tests/Wisp.App.Tests/HudNativeHostTests.cs +++ b/tests/Wisp.App.Tests/HudNativeHostTests.cs @@ -140,6 +140,11 @@ internal static void AssertOnCurrentDispatcher() AssertMinimizedHostStopsRendering(window, host, handle, Rows); AssertForeignOwnerRestart(window, host, presentationHandle, Rows, () => Seed(analog, electric, digital, electricDigital, 6)); + // Failed host-window Win32 calls release the host and rebuild it on the next attach; + // the rebuilt host presents through a new HWND. + HudNativeHostRecoveryTests.AssertHostRebuildOnAttach(window, host, controller, analog, + () => Seed(analog, electric, digital, electricDigital, 8)); + presentationHandle = HudNativeHost.PresentationHandle(window); // Native E_OUTOFMEMORY (allocation pressure while the game starts) restarts // the native HUD instead of retiring it: a new target and worker present again.