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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 49 additions & 23 deletions src/Wisp.App/NativeRendering/HudNativeHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(() =>
{
Expand Down Expand Up @@ -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; }
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions src/Wisp.App/NativeRendering/HudNativeHostRecovery.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
41 changes: 33 additions & 8 deletions src/Wisp.App/NativeRendering/NativeCompositionWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand All @@ -74,17 +96,17 @@ 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;
if (_fillMonitor)
{
var monitor = MonitorFromWindow(layout, 2); // MONITOR_DEFAULTTONEAREST
var info = new MonitorInfo { Size = (uint)Marshal.SizeOf<MonitorInfo>() };
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);
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/Wisp.App/WindowZOrder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Loading
Loading