Skip to content

Commit e1977c2

Browse files
carterscodeclaude
andcommitted
fix(drr): cache DRR support probe so it stops stalling input every poll
DrrInterop.IsSupported probes DRR capability by toggling the BOOST_REFRESH_RATE flag and calling SetDisplayConfig(SDC_VALIDATE), which on many GPU/driver combos briefly re-evaluates the display pipeline and stalls the mouse + keyboard. DrrMonitor.CheckDrift ran this on every 30s poll for every active display -- regardless of whether DRR was even monitored (the IsMonitored filter is applied after CheckDrift) -- which is the real cause of the periodic input hitch users still saw after the v0.1.56 circuit-breaker/tiering fix (that only bounded the apply path; this is in the check path). DRR capability is static per display, so cache it: the SDC_VALIDATE probe now runs at most once per display (cleared on a display-topology change), and DrrMonitor skips all display-config work for displays the user isn't monitoring. No log line accompanied the stall because it was a check-time probe, not an apply -- which is why the change log looked idle. Adds 4 caching tests via an injectable probe seam. 553 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 02d52f1 commit e1977c2

5 files changed

Lines changed: 147 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,29 @@ Versions before 1.0.0 are pre-release: features and defaults may still change.
99

1010
## [Unreleased]
1111

12-
> **⚠️ Important update — please upgrade right away.** This release fixes a bug that
13-
> could interrupt your **mouse and keyboard and stutter system performance about every
14-
> 30 seconds** while GamerGuardian was running (a monitored setting was being
15-
> re-applied on a loop). If you're on an earlier version, updating now is strongly
16-
> recommended.
12+
> **⚠️ Important update — please upgrade right away.** This is the real fix for the
13+
> **mouse and keyboard stuttering about every 30 seconds** while GamerGuardian runs.
14+
> The earlier 0.1.56 fix addressed a related auto-apply loop but not this cause, so if
15+
> you're still seeing the hitch, update to this build.
1716
1817
### Fixed
19-
- **Mouse and keyboard no longer hitch every ~30 seconds.** A monitored, auto-applied
20-
setting that Windows kept reverting was re-applied on every poll — and when the app
21-
runs without admin rights each re-apply raised a UAC prompt (and display settings
22-
reconfigured the screen), seizing input. A new circuit breaker stops re-applying a
23-
setting Windows keeps fighting after a few tries and leaves it notify-only for a
24-
cooldown (logged as `[CIRCUIT]` in the change log), so the worst case is one
25-
interruption every several minutes instead of one every 30 seconds.
18+
- **Mouse and keyboard hitch every ~30 seconds — the real cause.** The Dynamic Refresh
19+
Rate (DRR) "is this display capable?" check was running on every 30-second poll for
20+
each monitor, and that check briefly re-validates the display configuration — which on
21+
many GPUs stalls the mouse and keyboard for a moment. DRR capability never changes
22+
while you're using the PC, so it's now checked **once** per display instead of every
23+
poll, and skipped entirely for displays you aren't monitoring. (This ran regardless of
24+
whether you were even monitoring DRR, which is why turning settings off didn't help.)
25+
26+
## [0.1.56] - 2026-06-20
27+
28+
### Fixed
29+
- **Mouse and keyboard no longer hitch every ~30 seconds (auto-apply loop).** A
30+
monitored, auto-applied setting that Windows kept reverting was re-applied on every
31+
poll — and when the app runs without admin rights each re-apply raised a UAC prompt
32+
(and display settings reconfigured the screen), seizing input. A circuit breaker now
33+
stops re-applying a setting Windows keeps fighting after a few tries and leaves it
34+
notify-only for a cooldown (logged as `[CIRCUIT]` in the change log).
2635

2736
### Changed
2837
- **Far less background polling.** Only the display settings (HDR, refresh rate,

src/GamerGuardian/Monitors/DrrMonitor.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,18 @@ public IEnumerable<DriftItem> CheckDrift(AppConfig config)
2121
var active = DisplayHelper.EnumerateActiveDisplays();
2222
foreach (var display in active)
2323
{
24+
// Resolve the preference first and bail before touching the display
25+
// config when DRR isn't being monitored. CheckDrift runs on every poll
26+
// and the support probe below calls SetDisplayConfig, which can stall
27+
// input -- so we must not run it for displays the user isn't watching
28+
// (the drift would be filtered out by IsMonitored anyway).
29+
var pref = DisplayPreferenceResolver.Resolve(config, display, active);
30+
if (!pref.Drr.Monitor) continue;
31+
2432
var read = DrrInterop.ReadState(display.AdapterId, display.TargetId);
2533
if (!read.Found) continue;
2634
if (!DrrInterop.IsSupported(display.AdapterId, display.TargetId)) continue;
2735

28-
var pref = DisplayPreferenceResolver.Resolve(config, display, active);
2936
bool current = read.Enabled;
3037
bool desired = pref.Drr.DesiredOn;
3138
if (current == desired) continue;

src/GamerGuardian/Native/DrrInterop.cs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Concurrent;
12
using static GamerGuardian.Native.DisplayConfig;
23

34
namespace GamerGuardian.Native;
@@ -59,9 +60,35 @@ public static ReadResult ReadState(LUID adapterId, uint targetId)
5960
catch { return new ReadResult(false, false); }
6061
}
6162

62-
/// <summary>True when DRR can be set on this target (the driver/panel accepts
63-
/// the boost-refresh-rate flag under SDC_VALIDATE).</summary>
63+
// Whether a target supports DRR is a static property of the panel + driver +
64+
// OS -- it does not change between polls. The probe below is NOT free: it calls
65+
// SetDisplayConfig (SDC_VALIDATE), which on some GPU/driver combos re-evaluates
66+
// the display pipeline and briefly stalls the mouse/keyboard. Running it on
67+
// every 30s drift poll (DrrMonitor.CheckDrift -> IsSupported) is what caused the
68+
// periodic input hitch, so the result is cached per target and the probe runs
69+
// at most once per display until the topology changes.
70+
private static readonly ConcurrentDictionary<(uint low, int high, uint target), bool> _supportCache = new();
71+
72+
/// <summary>
73+
/// The actual support probe. Exposed as a settable delegate ONLY so tests can
74+
/// substitute a non-native counter (the native probe needs a real display and
75+
/// would itself disturb it). Production never reassigns this.
76+
/// </summary>
77+
public static Func<LUID, uint, bool> SupportProbe { get; set; } = ProbeSupportedNative;
78+
79+
/// <summary>True when DRR can be set on this target. Cached: the underlying
80+
/// SDC_VALIDATE probe runs once per display, not on every poll.</summary>
6481
public static bool IsSupported(LUID adapterId, uint targetId)
82+
=> _supportCache.GetOrAdd(
83+
(adapterId.LowPart, adapterId.HighPart, targetId),
84+
_ => SupportProbe(adapterId, targetId));
85+
86+
/// <summary>Drops the cached support results so they are re-probed once. Call
87+
/// when the display topology changes (monitor hot-plug, driver change) -- DRR
88+
/// support can differ for a newly attached panel.</summary>
89+
public static void ClearSupportCache() => _supportCache.Clear();
90+
91+
private static bool ProbeSupportedNative(LUID adapterId, uint targetId)
6592
{
6693
try
6794
{

src/GamerGuardian/Services/MonitorService.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,13 @@ private void OnSessionSwitch(object? sender, SessionSwitchEventArgs e)
159159
}
160160

161161
private void OnDisplaySettingsChanged(object? sender, EventArgs e)
162-
=> ScheduleCheck(tier: MonitorTier.Volatile, delayMs: 1500);
162+
{
163+
// The display topology may have changed (monitor hot-plug / driver) -- a
164+
// newly attached panel can have different DRR support, so drop the cached
165+
// support results and let the next check re-probe each display once.
166+
GamerGuardian.Native.DrrInterop.ClearSupportCache();
167+
ScheduleCheck(tier: MonitorTier.Volatile, delayMs: 1500);
168+
}
163169

164170
/// <summary>
165171
/// Called by the SettingsWindow's Apply / Save &amp; close path to seed our

tests/GamerGuardian.Tests/DrrInteropTests.cs

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
1+
using System;
2+
using System.Collections.Generic;
13
using GamerGuardian.Native;
4+
using static GamerGuardian.Native.DisplayConfig;
25
using Xunit;
36

47
namespace GamerGuardian.Tests;
58

6-
public class DrrInteropTests
9+
public class DrrInteropTests : IDisposable
710
{
11+
// These tests substitute the native DRR support probe; reset it (and the
12+
// cache) around each test so static state never leaks between tests.
13+
private readonly Func<LUID, uint, bool> _originalProbe = DrrInterop.SupportProbe;
14+
15+
public DrrInteropTests() => DrrInterop.ClearSupportCache();
16+
17+
public void Dispose()
18+
{
19+
DrrInterop.SupportProbe = _originalProbe;
20+
DrrInterop.ClearSupportCache();
21+
}
22+
23+
private static LUID Luid(uint low, int high = 0) => new() { LowPart = low, HighPart = high };
24+
825
[Fact]
926
public void IsDrrEnabled_TrueWhenBoostFlagSet()
1027
{
@@ -27,4 +44,68 @@ public void IsDrrEnabled_IgnoresUnrelatedBits()
2744
// Only unrelated bits set -> not enabled.
2845
Assert.False(DrrInterop.IsDrrEnabled(0x08 | 0x01));
2946
}
47+
48+
// ---- Support-probe caching (the input-stall fix) ----------------------
49+
50+
[Fact]
51+
public void IsSupported_ProbesEachTargetOnce_NotEveryCall()
52+
{
53+
var calls = new Dictionary<(uint, int, uint), int>();
54+
DrrInterop.SupportProbe = (a, t) =>
55+
{
56+
var k = (a.LowPart, a.HighPart, t);
57+
calls[k] = calls.GetValueOrDefault(k) + 1;
58+
return true;
59+
};
60+
61+
// Simulate the calls DrrMonitor.CheckDrift makes across many 30s polls.
62+
for (int poll = 0; poll < 10; poll++)
63+
{
64+
Assert.True(DrrInterop.IsSupported(Luid(1), 100));
65+
Assert.True(DrrInterop.IsSupported(Luid(1), 200));
66+
}
67+
68+
// The disruptive SetDisplayConfig(SDC_VALIDATE) probe must run once per
69+
// target, not on every poll. Before the cache this was 10 -- the bug.
70+
Assert.Equal(1, calls[(1u, 0, 100u)]);
71+
Assert.Equal(1, calls[(1u, 0, 200u)]);
72+
}
73+
74+
[Fact]
75+
public void IsSupported_CachesTheResultValue()
76+
{
77+
DrrInterop.SupportProbe = (_, _) => false;
78+
Assert.False(DrrInterop.IsSupported(Luid(7), 1));
79+
// A later probe that would say true must not override the cached value.
80+
DrrInterop.SupportProbe = (_, _) => true;
81+
Assert.False(DrrInterop.IsSupported(Luid(7), 1));
82+
}
83+
84+
[Fact]
85+
public void ClearSupportCache_ForcesReprobe()
86+
{
87+
int calls = 0;
88+
DrrInterop.SupportProbe = (_, _) => { calls++; return true; };
89+
90+
DrrInterop.IsSupported(Luid(5), 1);
91+
DrrInterop.IsSupported(Luid(5), 1);
92+
Assert.Equal(1, calls);
93+
94+
DrrInterop.ClearSupportCache(); // e.g. a monitor hot-plug
95+
DrrInterop.IsSupported(Luid(5), 1);
96+
Assert.Equal(2, calls);
97+
}
98+
99+
[Fact]
100+
public void DifferentTargetsAndAdapters_ProbedIndependently()
101+
{
102+
int calls = 0;
103+
DrrInterop.SupportProbe = (_, _) => { calls++; return true; };
104+
105+
DrrInterop.IsSupported(Luid(1, 0), 1);
106+
DrrInterop.IsSupported(Luid(1, 0), 2); // same adapter, different target
107+
DrrInterop.IsSupported(Luid(2, 0), 1); // different adapter low part
108+
DrrInterop.IsSupported(Luid(1, 9), 1); // different adapter high part
109+
Assert.Equal(4, calls);
110+
}
30111
}

0 commit comments

Comments
 (0)