From 14fbe92b08a74326e59b085eba34d7b772e9c940 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 13:15:24 +0200 Subject: [PATCH] fix(rendering): survive display topology handoff Model active-monitor refresh telemetry as optional so GLFW display replacement during console/RDP transitions cannot escape the native move callback and terminate the client. Preserve the existing software pacing fallback until a later monitor event succeeds. Co-authored-by: OpenAI Codex --- .../Rendering/DisplayFramePacingController.cs | 28 ++++++++--- .../DisplayFramePacingControllerTests.cs | 47 +++++++++++++++++-- .../SilkWindowCallbackBindingTests.cs | 10 ++-- .../RuntimeSettingsControllerTests.cs | 13 +++-- 4 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/AcDream.App/Rendering/DisplayFramePacingController.cs b/src/AcDream.App/Rendering/DisplayFramePacingController.cs index a9f378b2..3d1fc0bb 100644 --- a/src/AcDream.App/Rendering/DisplayFramePacingController.cs +++ b/src/AcDream.App/Rendering/DisplayFramePacingController.cs @@ -8,7 +8,7 @@ internal interface IDisplayFramePacingSurface { bool VSync { get; set; } - int? ActiveMonitorRefreshHz { get; } + bool TryGetActiveMonitorRefreshHz(out int refreshHz); } /// Narrow Silk window adapter for display pacing. @@ -27,12 +27,25 @@ internal sealed class SilkDisplayFramePacingSurface : IDisplayFramePacingSurface set => _window.VSync = value; } - public int? ActiveMonitorRefreshHz + public bool TryGetActiveMonitorRefreshHz(out int refreshHz) { - get + refreshHz = 0; + try { - int? refreshHz = _window.Monitor?.VideoMode.RefreshRate; - return refreshHz is > 0 ? refreshHz : null; + int? activeRefreshHz = _window.Monitor?.VideoMode.RefreshRate; + if (activeRefreshHz is not > 0) + return false; + + refreshHz = activeRefreshHz.Value; + return true; + } + catch (Silk.NET.GLFW.GlfwException) + { + // GLFW can retain a monitor handle while Windows is replacing the + // display topology during a console/RDP handoff. Refresh-rate + // telemetry is optional; report it as unavailable until the next + // move/state event rather than terminating the render host. + return false; } } } @@ -96,7 +109,10 @@ internal sealed class DisplayFramePacingController : IDisposable public void RefreshActiveMonitor() { - _activeMonitorRefreshHz = _surface?.ActiveMonitorRefreshHz; + _activeMonitorRefreshHz = + _surface?.TryGetActiveMonitorRefreshHz(out int refreshHz) == true + ? refreshHz + : null; ApplyResolvedPolicy(); } diff --git a/tests/AcDream.App.Tests/Rendering/DisplayFramePacingControllerTests.cs b/tests/AcDream.App.Tests/Rendering/DisplayFramePacingControllerTests.cs index bfd804ec..e1b7da06 100644 --- a/tests/AcDream.App.Tests/Rendering/DisplayFramePacingControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/DisplayFramePacingControllerTests.cs @@ -98,6 +98,37 @@ public sealed class DisplayFramePacingControllerTests Assert.Equal(new FramePacingPolicy(false, 144d), controller.Policy); } + [Fact] + public void Unavailable_monitor_during_display_handoff_uses_fallback_policy() + { + using var profiler = new FrameProfiler(); + using var controller = new DisplayFramePacingController( + uncappedRendering: false, + profiler, + new FramePacingController( + new FakeClock(1_000), + new RecordingWaiter())); + var surface = new FakeSurface + { + VSync = false, + ActiveMonitorRefreshHz = 144, + }; + controller.InitializeStartup(requestedVSync: false); + controller.BindSurface(surface); + controller.RefreshActiveMonitor(); + Assert.Equal(new FramePacingPolicy(false, 144d), controller.Policy); + + surface.MonitorAvailable = false; + controller.OnWindowMoved(default); + + Assert.Equal( + new FramePacingPolicy( + false, + FramePacingPolicy.FallbackRefreshHz), + controller.Policy); + Assert.False(surface.VSync); + } + [Fact] public void Explicit_uncapped_mode_disables_both_pacing_mechanisms() { @@ -172,16 +203,22 @@ public sealed class DisplayFramePacingControllerTests public int RefreshReadCount { get; private set; } + public bool MonitorAvailable { get; set; } = true; + public int? ActiveMonitorRefreshHz { - get - { - RefreshReadCount++; - return _activeMonitorRefreshHz; - } + get => _activeMonitorRefreshHz; set => _activeMonitorRefreshHz = value; } + public bool TryGetActiveMonitorRefreshHz(out int refreshHz) + { + RefreshReadCount++; + refreshHz = _activeMonitorRefreshHz.GetValueOrDefault(); + return MonitorAvailable && + _activeMonitorRefreshHz is > 0; + } + public void Dispose() => Disposed = true; } diff --git a/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs b/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs index f7480830..0be84c18 100644 --- a/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs +++ b/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs @@ -789,13 +789,11 @@ public sealed class SilkWindowCallbackBindingTests public bool VSync { get; set; } public int RefreshReadCount { get; private set; } - public int? ActiveMonitorRefreshHz + public bool TryGetActiveMonitorRefreshHz(out int refreshHz) { - get - { - RefreshReadCount++; - return 144; - } + RefreshReadCount++; + refreshHz = 144; + return true; } } } diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs index f582d2ab..b08611ab 100644 --- a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs +++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs @@ -1171,13 +1171,16 @@ public sealed class RuntimeSettingsControllerTests public int? ActiveMonitorRefreshHz { - get - { - RefreshReadCount++; - return _refreshRate; - } + get => _refreshRate; set => _refreshRate = value; } + + public bool TryGetActiveMonitorRefreshHz(out int refreshHz) + { + RefreshReadCount++; + refreshHz = _refreshRate.GetValueOrDefault(); + return _refreshRate is > 0; + } } private sealed class NullKeyboardSource : IKeyboardSource