diff --git a/src/Wisp.App/NativeRendering/HudNativeHost.cs b/src/Wisp.App/NativeRendering/HudNativeHost.cs index 3f66fa9..7fc29c6 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 0000000..36a89a8 --- /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 c2e19f4..b14523e 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 d3581c2..2fe3587 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 0000000..88d88fa --- /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 f89cb6a..94cc251 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.