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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-25 13:15:24 +02:00
parent 10ccce3f2d
commit 14fbe92b08
4 changed files with 76 additions and 22 deletions

View file

@ -8,7 +8,7 @@ internal interface IDisplayFramePacingSurface
{ {
bool VSync { get; set; } bool VSync { get; set; }
int? ActiveMonitorRefreshHz { get; } bool TryGetActiveMonitorRefreshHz(out int refreshHz);
} }
/// <summary>Narrow Silk window adapter for display pacing.</summary> /// <summary>Narrow Silk window adapter for display pacing.</summary>
@ -27,12 +27,25 @@ internal sealed class SilkDisplayFramePacingSurface : IDisplayFramePacingSurface
set => _window.VSync = value; set => _window.VSync = value;
} }
public int? ActiveMonitorRefreshHz public bool TryGetActiveMonitorRefreshHz(out int refreshHz)
{ {
get refreshHz = 0;
try
{ {
int? refreshHz = _window.Monitor?.VideoMode.RefreshRate; int? activeRefreshHz = _window.Monitor?.VideoMode.RefreshRate;
return refreshHz is > 0 ? refreshHz : null; 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() public void RefreshActiveMonitor()
{ {
_activeMonitorRefreshHz = _surface?.ActiveMonitorRefreshHz; _activeMonitorRefreshHz =
_surface?.TryGetActiveMonitorRefreshHz(out int refreshHz) == true
? refreshHz
: null;
ApplyResolvedPolicy(); ApplyResolvedPolicy();
} }

View file

@ -98,6 +98,37 @@ public sealed class DisplayFramePacingControllerTests
Assert.Equal(new FramePacingPolicy(false, 144d), controller.Policy); 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] [Fact]
public void Explicit_uncapped_mode_disables_both_pacing_mechanisms() public void Explicit_uncapped_mode_disables_both_pacing_mechanisms()
{ {
@ -172,16 +203,22 @@ public sealed class DisplayFramePacingControllerTests
public int RefreshReadCount { get; private set; } public int RefreshReadCount { get; private set; }
public bool MonitorAvailable { get; set; } = true;
public int? ActiveMonitorRefreshHz public int? ActiveMonitorRefreshHz
{ {
get get => _activeMonitorRefreshHz;
{
RefreshReadCount++;
return _activeMonitorRefreshHz;
}
set => _activeMonitorRefreshHz = value; set => _activeMonitorRefreshHz = value;
} }
public bool TryGetActiveMonitorRefreshHz(out int refreshHz)
{
RefreshReadCount++;
refreshHz = _activeMonitorRefreshHz.GetValueOrDefault();
return MonitorAvailable &&
_activeMonitorRefreshHz is > 0;
}
public void Dispose() => Disposed = true; public void Dispose() => Disposed = true;
} }

View file

@ -789,13 +789,11 @@ public sealed class SilkWindowCallbackBindingTests
public bool VSync { get; set; } public bool VSync { get; set; }
public int RefreshReadCount { get; private set; } public int RefreshReadCount { get; private set; }
public int? ActiveMonitorRefreshHz public bool TryGetActiveMonitorRefreshHz(out int refreshHz)
{ {
get RefreshReadCount++;
{ refreshHz = 144;
RefreshReadCount++; return true;
return 144;
}
} }
} }
} }

View file

@ -1171,13 +1171,16 @@ public sealed class RuntimeSettingsControllerTests
public int? ActiveMonitorRefreshHz public int? ActiveMonitorRefreshHz
{ {
get get => _refreshRate;
{
RefreshReadCount++;
return _refreshRate;
}
set => _refreshRate = value; set => _refreshRate = value;
} }
public bool TryGetActiveMonitorRefreshHz(out int refreshHz)
{
RefreshReadCount++;
refreshHz = _refreshRate.GetValueOrDefault();
return _refreshRate is > 0;
}
} }
private sealed class NullKeyboardSource : IKeyboardSource private sealed class NullKeyboardSource : IKeyboardSource