Close display verification races

This commit is contained in:
Gabriel Brown
2026-08-18 02:55:43 -04:00
parent efc53435a2
commit 0a18290137
5 changed files with 66 additions and 13 deletions
@@ -36,6 +36,19 @@ ShellRoot {
return Displays.apply(monitor.name, mode, scale, monitor.transform); return Displays.apply(monitor.name, mode, scale, monitor.transform);
} }
function refreshIdentityFixture(): string {
const modes = Displays.normaliseModes([
"[email protected]",
"[email protected]"
]);
const monitor = { width: 1920, height: 1080, refreshRate: 59.94 };
return JSON.stringify({
count: modes.length,
modes: modes.map(mode => mode.mode),
selected: modes.filter(mode => Displays.modeIsCurrent(monitor, mode)).map(mode => mode.mode)
});
}
function applyBad(kind: string): bool { function applyBad(kind: string): bool {
const monitor = Displays.monitors[0]; const monitor = Displays.monitors[0];
if (!monitor) return false; if (!monitor) return false;
@@ -64,9 +64,8 @@ Column {
required property var modelData required property var modelData
readonly property bool selected: root.monitor readonly property bool selected: resolution.isCurrent
&& resolution.isCurrent && Displays.modeIsCurrent(root.monitor, rate.modelData)
&& Math.round(root.monitor.refreshRate) === rate.modelData.refresh
implicitWidth: Math.max(74, rateCaption.implicitWidth + 22) implicitWidth: Math.max(74, rateCaption.implicitWidth + 22)
implicitHeight: 30 implicitHeight: 30
@@ -127,14 +127,16 @@ SettingsPage {
TextRow { TextRow {
label: "Color mode" label: "Color mode"
detail: "Wide-gamut SDR at 10-bit. Full-time HDR is left to the Hyprland config: it currently breaks screenshots, OBS, and the lock screen's blurred background." detail: "Wide-gamut SDR at 10-bit. Full-time HDR is left to the Hyprland config: it currently breaks screenshots, OBS, and the lock screen's blurred background."
value: SystemSettings.colorPreset || "wide" value: root.monitor
? `${root.monitor.colorPreset || "standard"} · ${root.monitor.currentFormat || "detecting format"}`
: "Detecting"
} }
TextRow { TextRow {
label: "Variable refresh" label: "Variable refresh"
detail: SystemSettings.monitorVrrActive detail: root.monitor && root.monitor.vrr
? "Active for current fullscreen content" ? "Active on this output for current fullscreen content"
: "Ready when game or video content requests it" : "This output is ready when game or video content requests it"
value: SystemSettings.monitorVrrActive ? "Active" : "Standby" value: root.monitor && root.monitor.vrr ? "Active" : "Standby"
divider: Displays.isOverridden(root.monitor ? root.monitor.name : "") divider: Displays.isOverridden(root.monitor ? root.monitor.name : "")
} }
ActionRow { ActionRow {
+34 -5
View File
@@ -38,6 +38,9 @@ Singleton {
property bool revertQueued: false property bool revertQueued: false
property var revertExpected: null property var revertExpected: null
property string revertReason: "" property string revertReason: ""
property bool revertVerificationActive: false
property int operationGeneration: 0
property int revertGeneration: -1
property int secondsLeft: 0 property int secondsLeft: 0
readonly property bool awaitingConfirmation: root.pendingOutput !== "" readonly property bool awaitingConfirmation: root.pendingOutput !== ""
@@ -63,13 +66,16 @@ Singleton {
Process { Process {
id: query id: query
property int generation: 0
command: ["hyprctl", "-j", "monitors"] command: ["hyprctl", "-j", "monitors"]
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: root.parse(this.text) onStreamFinished: root.parse(this.text, query.generation)
} }
onExited: (exitCode, exitStatus) => { onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) if (exitCode !== 0)
root.lastError = "Could not read the connected displays."; root.lastError = "Could not read the connected displays.";
if (root.revertQueued && !applyRun.running && root.awaitingConfirmation)
root.performRevert();
} }
} }
@@ -96,6 +102,7 @@ Singleton {
onExited: (exitCode, exitStatus) => { onExited: (exitCode, exitStatus) => {
// Exit status is advisory only. Hyprland's Lua bridge can report // Exit status is advisory only. Hyprland's Lua bridge can report
// success without applying a value, so exact readback decides. // success without applying a value, so exact readback decides.
root.revertVerificationActive = true;
revertVerifyTimer.attempts = 0; revertVerifyTimer.attempts = 0;
revertVerifyTimer.restart(); revertVerifyTimer.restart();
} }
@@ -104,11 +111,13 @@ Singleton {
Component.onCompleted: root.refresh() Component.onCompleted: root.refresh()
function refresh(): void { function refresh(): void {
if (!query.running) if (!query.running) {
query.generation = root.operationGeneration;
query.running = true; query.running = true;
} }
}
function parse(text: string): void { function parse(text: string, generation: int): void {
try { try {
const raw = JSON.parse(text); const raw = JSON.parse(text);
root.monitors = raw.map(monitor => { root.monitors = raw.map(monitor => {
@@ -129,6 +138,9 @@ Singleton {
mode: current?.mode ?? `${width}x${height}@${refreshRate}`, mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
scale: monitor.scale ?? 1, scale: monitor.scale ?? 1,
transform: monitor.transform ?? 0, transform: monitor.transform ?? 0,
currentFormat: monitor.currentFormat ?? "",
colorPreset: monitor.colorManagementPreset ?? "",
vrr: monitor.vrr === true,
modes: modes modes: modes
}; };
}); });
@@ -137,9 +149,13 @@ Singleton {
root.pendingVerified = true; root.pendingVerified = true;
verifyTimer.stop(); verifyTimer.stop();
root.lastError = ""; root.lastError = "";
} else if (root.revertExpected } else if (root.revertVerificationActive
&& generation === root.revertGeneration
&& root.revertExpected
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) { && root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) {
revertVerifyTimer.stop(); revertVerifyTimer.stop();
root.revertVerificationActive = false;
root.revertGeneration = -1;
root.revertExpected = null; root.revertExpected = null;
if (root.revertReason === "") if (root.revertReason === "")
root.lastError = ""; root.lastError = "";
@@ -240,6 +256,13 @@ Singleton {
&& monitor.transform === requested.transform; && monitor.transform === requested.transform;
} }
function modeIsCurrent(monitor: var, candidate: var): bool {
return !!monitor && !!candidate
&& monitor.width === candidate.width
&& monitor.height === candidate.height
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
}
// Applies immediately and starts the countdown. Nothing is stored yet: the // Applies immediately and starts the countdown. Nothing is stored yet: the
// settings file is only written by confirm(). // settings file is only written by confirm().
function apply(output: string, mode: string, scale: real, transform: int): bool { function apply(output: string, mode: string, scale: real, transform: int): bool {
@@ -275,6 +298,7 @@ Singleton {
scale: monitor.scale, scale: monitor.scale,
transform: monitor.transform transform: monitor.transform
}; };
root.operationGeneration++;
root.pendingRequested = { root.pendingRequested = {
output: output, output: output,
mode: mode, mode: mode,
@@ -348,7 +372,7 @@ Singleton {
root.revertReason = message; root.revertReason = message;
if (message !== "") if (message !== "")
root.lastError = message; root.lastError = message;
if (applyRun.running) { if (applyRun.running || query.running) {
root.revertQueued = true; root.revertQueued = true;
return; return;
} }
@@ -357,7 +381,10 @@ Singleton {
function performRevert(): void { function performRevert(): void {
const previous = root.pendingPrevious; const previous = root.pendingPrevious;
root.operationGeneration++;
root.revertGeneration = root.operationGeneration;
root.revertExpected = previous; root.revertExpected = previous;
root.revertVerificationActive = false;
root.clearPending(); root.clearPending();
if (previous) { if (previous) {
revertRun.exec(["hyprctl", "eval", revertRun.exec(["hyprctl", "eval",
@@ -405,6 +432,8 @@ Singleton {
attempts++; attempts++;
if (attempts > 25) { if (attempts > 25) {
stop(); stop();
root.revertVerificationActive = false;
root.revertGeneration = -1;
root.revertExpected = null; root.revertExpected = null;
root.revertReason = ""; root.revertReason = "";
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually."; root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
+10
View File
@@ -37,6 +37,8 @@ fail() {
for contract in \ for contract in \
'property var pendingRequested:' \ 'property var pendingRequested:' \
'property var revertExpected:' \ 'property var revertExpected:' \
'property bool revertVerificationActive:' \
'property int revertGeneration:' \
'readonly property bool canConfirm:' \ 'readonly property bool canConfirm:' \
'function matchesRequest(' \ 'function matchesRequest(' \
'function scalesForMode(' \ 'function scalesForMode(' \
@@ -180,6 +182,14 @@ done
run ipc show 2>/dev/null | rg -q '^target displays-test$' || fail 'test IPC target did not start' run ipc show 2>/dev/null | rg -q '^target displays-test$' || fail 'test IPC target did not start'
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')" harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
refresh_fixture="$(run ipc call displays-test refreshIdentityFixture)"
jq -e '
.count == 2
and .modes == ["[email protected]", "[email protected]"]
and .selected == ["[email protected]"]
' <<<"$refresh_fixture" >/dev/null \
|| fail "59.94 Hz and 60.00 Hz lost their distinct selection identity: $refresh_fixture"
for _ in $(seq 1 50); do for _ in $(seq 1 50); do
[[ "$(status | jq -r .count)" != "0" ]] && break [[ "$(status | jq -r .count)" != "0" ]] && break
sleep 0.1 sleep 0.1