Apply monitor layouts transactionally
This commit is contained in:
@@ -39,6 +39,49 @@ ShellRoot {
|
||||
return Displays.apply(monitor.name, mode, scale, monitor.transform);
|
||||
}
|
||||
|
||||
function transactionStatus(): string {
|
||||
return JSON.stringify({
|
||||
layout: Displays.currentLayout(),
|
||||
pending: Displays.pendingRequestedLayout,
|
||||
previous: Displays.pendingPreviousLayout,
|
||||
reverting: Displays.revertExpectedLayout,
|
||||
awaiting: Displays.awaitingConfirmation,
|
||||
canConfirm: Displays.canConfirm,
|
||||
busy: Displays.busy,
|
||||
generation: Displays.operationGeneration,
|
||||
revertGeneration: Displays.revertGeneration,
|
||||
lastError: Displays.lastError
|
||||
});
|
||||
}
|
||||
|
||||
function applyLayoutFixture(secondX: int, secondY: int): bool {
|
||||
const layout = Displays.currentLayout();
|
||||
if (layout.length !== 2) return false;
|
||||
layout[0].x = 0;
|
||||
layout[0].y = 0;
|
||||
layout[0].primary = true;
|
||||
layout[1].x = secondX;
|
||||
layout[1].y = secondY;
|
||||
layout[1].primary = false;
|
||||
return Displays.applyLayout(layout);
|
||||
}
|
||||
|
||||
function makePrimaryFixture(output: string): bool {
|
||||
return Displays.makePrimary(output);
|
||||
}
|
||||
|
||||
function injectReadback(text: string, generation: int): void {
|
||||
Displays.parse(text, generation);
|
||||
}
|
||||
|
||||
function expireApplyVerification(): void {
|
||||
Displays.verificationTimedOut();
|
||||
}
|
||||
|
||||
function expireRevertVerification(): void {
|
||||
Displays.revertVerificationTimedOut();
|
||||
}
|
||||
|
||||
function refreshIdentityFixture(): string {
|
||||
const modes = Displays.normaliseModes([
|
||||
"[email protected]",
|
||||
|
||||
@@ -21,6 +21,7 @@ import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import "DisplayLayout.js" as DisplayLayout
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
@@ -31,12 +32,11 @@ Singleton {
|
||||
property string lastError: ""
|
||||
|
||||
// Set while a change is applied but not yet confirmed.
|
||||
property string pendingOutput: ""
|
||||
property var pendingPrevious: null
|
||||
property var pendingRequested: null
|
||||
property var pendingPreviousLayout: null
|
||||
property var pendingRequestedLayout: null
|
||||
property bool pendingVerified: false
|
||||
property bool revertQueued: false
|
||||
property var revertExpected: null
|
||||
property var revertExpectedLayout: null
|
||||
property string revertReason: ""
|
||||
property bool revertVerificationActive: false
|
||||
property int operationGeneration: 0
|
||||
@@ -44,12 +44,12 @@ Singleton {
|
||||
property bool externalChangeBlocked: false
|
||||
property int secondsLeft: 0
|
||||
|
||||
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
|
||||
readonly property bool awaitingConfirmation: root.pendingRequestedLayout !== null
|
||||
readonly property bool canConfirm: root.awaitingConfirmation
|
||||
&& root.pendingVerified
|
||||
&& !root.busy
|
||||
readonly property bool busy: query.running || applyRun.running || revertRun.running
|
||||
|| root.revertExpected !== null
|
||||
|| root.revertExpectedLayout !== null
|
||||
|
||||
readonly property int confirmSeconds: 15
|
||||
|
||||
@@ -163,25 +163,26 @@ Singleton {
|
||||
modes: modes
|
||||
};
|
||||
});
|
||||
if (root.awaitingConfirmation && root.pendingRequested
|
||||
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||
if (root.awaitingConfirmation && root.pendingRequestedLayout
|
||||
&& generation === root.operationGeneration
|
||||
&& root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
|
||||
root.pendingVerified = true;
|
||||
verifyTimer.stop();
|
||||
root.lastError = "";
|
||||
} else if (root.revertVerificationActive
|
||||
&& generation === root.revertGeneration
|
||||
&& root.revertExpected
|
||||
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) {
|
||||
&& root.revertExpectedLayout
|
||||
&& root.matchesLayout(root.monitors, root.revertExpectedLayout)) {
|
||||
revertVerifyTimer.stop();
|
||||
root.revertVerificationActive = false;
|
||||
root.revertGeneration = -1;
|
||||
root.revertExpected = null;
|
||||
root.revertExpectedLayout = null;
|
||||
if (root.revertReason === "")
|
||||
root.lastError = "";
|
||||
else
|
||||
root.lastError = root.revertReason;
|
||||
root.revertReason = "";
|
||||
} else if (!root.awaitingConfirmation && !root.revertExpected && (
|
||||
} else if (!root.awaitingConfirmation && !root.revertExpectedLayout && (
|
||||
root.lastError === "Could not read the connected displays."
|
||||
|| root.lastError === "The display list could not be read.")) {
|
||||
root.lastError = "";
|
||||
@@ -274,16 +275,41 @@ Singleton {
|
||||
choices[0]);
|
||||
}
|
||||
|
||||
function matchesRequest(monitor: var, requested: var): bool {
|
||||
if (!monitor || !requested || monitor.name !== requested.output)
|
||||
function currentLayout(): var {
|
||||
return root.monitors.map(monitor => ({
|
||||
name: monitor.name,
|
||||
width: monitor.width,
|
||||
height: monitor.height,
|
||||
refreshRate: monitor.refreshRate,
|
||||
mode: monitor.mode,
|
||||
scale: monitor.scale,
|
||||
transform: monitor.transform,
|
||||
x: monitor.x,
|
||||
y: monitor.y,
|
||||
primary: monitor.primary === true
|
||||
}));
|
||||
}
|
||||
|
||||
function matchesLayout(monitors: var, layout: var): bool {
|
||||
if (!Array.isArray(monitors) || !Array.isArray(layout)
|
||||
|| monitors.length !== layout.length)
|
||||
return false;
|
||||
const parts = root.modeParts(requested.mode);
|
||||
return !!parts
|
||||
&& monitor.width === parts.width
|
||||
&& monitor.height === parts.height
|
||||
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.01
|
||||
&& Math.abs(monitor.scale - requested.scale) < 0.001
|
||||
&& monitor.transform === requested.transform;
|
||||
const expected = Array.from(layout).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const actual = Array.from(monitors).sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (let index = 0; index < expected.length; index++) {
|
||||
const requested = expected[index];
|
||||
const monitor = actual[index];
|
||||
const parts = root.modeParts(requested.mode);
|
||||
if (!parts || monitor.name !== requested.name
|
||||
|| monitor.width !== parts.width
|
||||
|| monitor.height !== parts.height
|
||||
|| Math.abs(monitor.refreshRate - parts.refresh) >= 0.01
|
||||
|| Math.abs(monitor.scale - requested.scale) >= 0.001
|
||||
|| monitor.transform !== requested.transform
|
||||
|| monitor.x !== requested.x || monitor.y !== requested.y)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function modeIsCurrent(monitor: var, candidate: var): bool {
|
||||
@@ -293,9 +319,47 @@ Singleton {
|
||||
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
|
||||
}
|
||||
|
||||
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||
// settings file is only written by confirm().
|
||||
function validRequestedLayout(layout: var): bool {
|
||||
if (!DisplayLayout.validate(layout) || layout.length !== root.monitors.length)
|
||||
return false;
|
||||
const currentNames = root.monitors.map(monitor => monitor.name).sort();
|
||||
const requestedNames = layout.map(record => record.name).sort();
|
||||
if (JSON.stringify(currentNames) !== JSON.stringify(requestedNames))
|
||||
return false;
|
||||
return layout.every(record => {
|
||||
const monitor = root.monitorNamed(record.name);
|
||||
const parts = root.modeParts(record.mode);
|
||||
return !!monitor && !!parts
|
||||
&& record.width === parts.width && record.height === parts.height
|
||||
&& monitor.modes.some(candidate => candidate.mode === record.mode)
|
||||
&& root.isScaleClean(record.mode, record.scale)
|
||||
&& root.transforms.some(candidate => candidate.value === record.transform);
|
||||
});
|
||||
}
|
||||
|
||||
// One-field controls remain callers of the complete-layout transaction.
|
||||
// Their edit is cloned into the current layout so every output's position
|
||||
// participates in apply, verification, and rollback.
|
||||
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
||||
const layout = root.currentLayout();
|
||||
const record = layout.find(candidate => candidate.name === output);
|
||||
const parts = root.modeParts(mode);
|
||||
if (!record || !parts) {
|
||||
root.lastError = record ? "That display does not offer that mode." : "That display is not connected.";
|
||||
return false;
|
||||
}
|
||||
record.mode = mode;
|
||||
record.width = parts.width;
|
||||
record.height = parts.height;
|
||||
record.refreshRate = parts.refresh;
|
||||
record.scale = scale;
|
||||
record.transform = transform;
|
||||
return root.applyLayout(layout);
|
||||
}
|
||||
|
||||
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||
// complete connected layout is only written by confirm().
|
||||
function applyLayout(layout: var): bool {
|
||||
if (root.externalChangeBlocked) {
|
||||
root.lastError = "Wait for Settings to finish restoring before changing a display.";
|
||||
return false;
|
||||
@@ -308,58 +372,46 @@ Singleton {
|
||||
root.lastError = "Finish the current display change first.";
|
||||
return false;
|
||||
}
|
||||
const monitor = root.monitorNamed(output);
|
||||
if (!monitor) {
|
||||
root.lastError = "That display is not connected.";
|
||||
return false;
|
||||
}
|
||||
if (!monitor.modes.some(candidate => candidate.mode === mode)) {
|
||||
root.lastError = "That display does not offer that mode.";
|
||||
return false;
|
||||
}
|
||||
if (!root.isScaleClean(mode, scale)) {
|
||||
root.lastError = "That scale does not divide this resolution cleanly.";
|
||||
return false;
|
||||
}
|
||||
if (!root.transforms.some(candidate => candidate.value === transform)) {
|
||||
root.lastError = "That rotation is not one Panama offers.";
|
||||
const normalized = DisplayLayout.normalize(layout);
|
||||
if (!root.validRequestedLayout(normalized)) {
|
||||
root.lastError = "That complete display layout is not valid for the connected displays.";
|
||||
return false;
|
||||
}
|
||||
|
||||
root.pendingPrevious = {
|
||||
output: output,
|
||||
mode: monitor.mode,
|
||||
scale: monitor.scale,
|
||||
transform: monitor.transform
|
||||
};
|
||||
root.pendingPreviousLayout = root.currentLayout();
|
||||
root.operationGeneration++;
|
||||
root.pendingRequested = {
|
||||
output: output,
|
||||
mode: mode,
|
||||
scale: scale,
|
||||
transform: transform
|
||||
};
|
||||
root.pendingOutput = output;
|
||||
root.pendingRequestedLayout = normalized;
|
||||
root.pendingVerified = false;
|
||||
root.revertQueued = false;
|
||||
root.secondsLeft = root.confirmSeconds;
|
||||
root.lastError = "";
|
||||
countdown.restart();
|
||||
|
||||
root.push(output, mode, scale, transform);
|
||||
root.pushLayout(normalized, applyRun);
|
||||
return true;
|
||||
}
|
||||
|
||||
function push(output: string, mode: string, scale: real, transform: int): void {
|
||||
// Values are validated above and the output name comes from the
|
||||
// compositor's own list, so nothing user-authored reaches the payload.
|
||||
applyRun.exec(["hyprctl", "eval",
|
||||
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
|
||||
function makePrimary(output: string): bool {
|
||||
const layout = root.currentLayout();
|
||||
if (!layout.some(record => record.name === output)) {
|
||||
root.lastError = "That display is not connected.";
|
||||
return false;
|
||||
}
|
||||
for (const record of layout)
|
||||
record.primary = record.name === output;
|
||||
return root.applyLayout(DisplayLayout.normalize(layout));
|
||||
}
|
||||
|
||||
function pushLayout(layout: var, runner: var): void {
|
||||
const payload = layout.map(record =>
|
||||
`hl.monitor({ output = "${record.name}", mode = "${record.mode}", position = "${record.x}x${record.y}", scale = ${record.scale}, transform = ${record.transform} })`
|
||||
).join("; ");
|
||||
runner.exec(["hyprctl", "eval", payload]);
|
||||
}
|
||||
|
||||
function confirm(): bool {
|
||||
if (!root.canConfirm || !root.matchesRequest(
|
||||
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||
if (!root.canConfirm
|
||||
|| !root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
|
||||
if (root.awaitingConfirmation)
|
||||
root.lastError = "Wait for the display to finish applying before keeping it.";
|
||||
return false;
|
||||
@@ -367,11 +419,16 @@ Singleton {
|
||||
|
||||
const stored = DesktopPreferences.get("displays");
|
||||
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
|
||||
next[root.pendingOutput] = {
|
||||
mode: root.pendingRequested.mode,
|
||||
scale: root.pendingRequested.scale,
|
||||
transform: root.pendingRequested.transform
|
||||
};
|
||||
for (const record of root.pendingRequestedLayout) {
|
||||
next[record.name] = {
|
||||
mode: record.mode,
|
||||
scale: record.scale,
|
||||
transform: record.transform,
|
||||
x: record.x,
|
||||
y: record.y,
|
||||
primary: record.primary
|
||||
};
|
||||
}
|
||||
if (!DesktopPreferences.set("displays", next)) {
|
||||
root.lastError = "That display setting could not be saved. Revert it and try again.";
|
||||
return false;
|
||||
@@ -385,9 +442,8 @@ Singleton {
|
||||
function clearPending(): void {
|
||||
countdown.stop();
|
||||
verifyTimer.stop();
|
||||
root.pendingOutput = "";
|
||||
root.pendingPrevious = null;
|
||||
root.pendingRequested = null;
|
||||
root.pendingPreviousLayout = null;
|
||||
root.pendingRequestedLayout = null;
|
||||
root.pendingVerified = false;
|
||||
root.revertQueued = false;
|
||||
root.secondsLeft = 0;
|
||||
@@ -414,16 +470,25 @@ Singleton {
|
||||
}
|
||||
|
||||
function performRevert(): void {
|
||||
const previous = root.pendingPrevious;
|
||||
const connected = {};
|
||||
for (const monitor of root.monitors)
|
||||
connected[monitor.name] = true;
|
||||
const previous = (root.pendingPreviousLayout || [])
|
||||
.filter(record => connected[record.name])
|
||||
.map(record => Object.assign({}, record));
|
||||
if (previous.length > 0 && !previous.some(record => record.primary)) {
|
||||
const origin = previous.find(record => record.x === 0 && record.y === 0);
|
||||
(origin || previous[0]).primary = true;
|
||||
}
|
||||
root.operationGeneration++;
|
||||
root.revertGeneration = root.operationGeneration;
|
||||
root.revertExpected = previous;
|
||||
root.revertExpectedLayout = previous.length > 0 ? previous : null;
|
||||
root.revertVerificationActive = false;
|
||||
root.clearPending();
|
||||
if (previous) {
|
||||
revertRun.exec(["hyprctl", "eval",
|
||||
`hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]);
|
||||
}
|
||||
if (previous.length > 0)
|
||||
root.pushLayout(previous, revertRun);
|
||||
else
|
||||
root.lastError = root.revertReason;
|
||||
}
|
||||
|
||||
// Clears any stored override for an output so it returns to the value
|
||||
@@ -442,6 +507,19 @@ Singleton {
|
||||
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
|
||||
}
|
||||
|
||||
function verificationTimedOut(): void {
|
||||
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
|
||||
}
|
||||
|
||||
function revertVerificationTimedOut(): void {
|
||||
revertVerifyTimer.stop();
|
||||
root.revertVerificationActive = false;
|
||||
root.revertGeneration = -1;
|
||||
root.revertExpectedLayout = null;
|
||||
root.revertReason = "";
|
||||
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: verifyTimer
|
||||
property int attempts: 0
|
||||
@@ -451,7 +529,7 @@ Singleton {
|
||||
onTriggered: {
|
||||
ticks++;
|
||||
if (ticks > 50) {
|
||||
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
|
||||
root.verificationTimedOut();
|
||||
return;
|
||||
}
|
||||
if (root.refresh())
|
||||
@@ -468,12 +546,7 @@ Singleton {
|
||||
onTriggered: {
|
||||
ticks++;
|
||||
if (ticks > 50) {
|
||||
stop();
|
||||
root.revertVerificationActive = false;
|
||||
root.revertGeneration = -1;
|
||||
root.revertExpected = null;
|
||||
root.revertReason = "";
|
||||
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
|
||||
root.revertVerificationTimedOut();
|
||||
return;
|
||||
}
|
||||
if (root.refresh())
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/Displays.qml"
|
||||
harness="$repo_dir/config/dot/quickshell/displays-harness.qml"
|
||||
|
||||
fail() {
|
||||
printf 'display transaction contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for contract in \
|
||||
'property var pendingPreviousLayout: null' \
|
||||
'property var pendingRequestedLayout: null' \
|
||||
'property var revertExpectedLayout: null' \
|
||||
'function currentLayout()' \
|
||||
'function applyLayout(layout:' \
|
||||
'function makePrimary(output:' \
|
||||
'function matchesLayout(monitors:' \
|
||||
'function pushLayout(layout:'; do
|
||||
rg -Fq "$contract" "$service" \
|
||||
|| fail "complete-layout service boundary is missing: $contract"
|
||||
done
|
||||
|
||||
rg -Fq 'position = "${record.x}x${record.y}"' "$service" \
|
||||
|| fail 'the compositor payload does not include explicit positions'
|
||||
rg -Fq 'generation === root.operationGeneration' "$service" \
|
||||
|| fail 'stale monitor readback can settle a newer transaction'
|
||||
rg -Fq 'function applyLayoutFixture(' "$harness" \
|
||||
|| fail 'the fixture cannot exercise complete layout transactions'
|
||||
rg -Fq 'function injectReadback(' "$harness" \
|
||||
|| fail 'the fixture cannot prove stale readback isolation'
|
||||
|
||||
fixture="$(mktemp -d /tmp/panama-display-transaction.XXXXXX)"
|
||||
state_home="$fixture/state-home"
|
||||
config_home="$fixture/config-home"
|
||||
fake_bin="$fixture/bin"
|
||||
monitor_state="$fixture/monitors.json"
|
||||
eval_log="$fixture/eval.log"
|
||||
harness_pid=""
|
||||
mkdir -p "$state_home" "$config_home" "$fake_bin"
|
||||
|
||||
cat >"$monitor_state" <<'JSON'
|
||||
[
|
||||
{
|
||||
"name":"DP-2","description":"Primary fixture","width":4500,"height":3000,
|
||||
"refreshRate":60,"scale":1.5,"transform":0,"x":0,"y":0,
|
||||
"availableModes":["[email protected]"]
|
||||
},
|
||||
{
|
||||
"name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440,
|
||||
"refreshRate":60,"scale":1,"transform":0,"x":3000,"y":0,
|
||||
"availableModes":["[email protected]"]
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
cat >"$fake_bin/hyprctl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
fixture="${PANAMA_DISPLAY_FIXTURE:?}"
|
||||
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
|
||||
cat "$fixture/monitors.json"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${1:-}" != "eval" ]]; then
|
||||
exit 2
|
||||
fi
|
||||
|
||||
payload="${2:-}"
|
||||
printf '%s\n' "$payload" >>"$fixture/eval.log"
|
||||
if [[ -f "$fixture/fail-once" ]]; then
|
||||
rm -f "$fixture/fail-once"
|
||||
exit 1
|
||||
fi
|
||||
[[ -f "$fixture/no-apply" ]] && exit 0
|
||||
|
||||
python3 - "$fixture/monitors.json" "$payload" "$fixture/wrong-y" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
state_path = pathlib.Path(sys.argv[1])
|
||||
payload = sys.argv[2]
|
||||
wrong_y = pathlib.Path(sys.argv[3]).exists()
|
||||
monitors = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
by_name = {monitor["name"]: monitor for monitor in monitors}
|
||||
|
||||
for block in re.findall(r"hl\.monitor\(\{([^}]*)\}\)", payload):
|
||||
def field(pattern: str) -> str:
|
||||
match = re.search(pattern, block)
|
||||
if not match:
|
||||
raise SystemExit(f"missing field {pattern}: {block}")
|
||||
return match.group(1)
|
||||
|
||||
name = field(r'output\s*=\s*"([A-Za-z0-9_.-]+)"')
|
||||
mode = field(r'mode\s*=\s*"(\d+x\d+@\d+(?:\.\d+)?)"')
|
||||
position = re.search(r'position\s*=\s*"(-?\d+)x(-?\d+)"', block)
|
||||
if not position or name not in by_name:
|
||||
raise SystemExit(f"bad output or position: {block}")
|
||||
width, height, refresh = re.match(r"(\d+)x(\d+)@(\d+(?:\.\d+)?)", mode).groups()
|
||||
monitor = by_name[name]
|
||||
monitor.update({
|
||||
"width": int(width),
|
||||
"height": int(height),
|
||||
"refreshRate": float(refresh),
|
||||
"scale": float(field(r"scale\s*=\s*([0-9.]+)")),
|
||||
"transform": int(field(r"transform\s*=\s*(\d+)")),
|
||||
"x": int(position.group(1)),
|
||||
"y": int(position.group(2)),
|
||||
})
|
||||
if wrong_y and name == "HDMI-A-1":
|
||||
monitor["y"] += 1
|
||||
|
||||
state_path.write_text(json.dumps(monitors), encoding="utf-8")
|
||||
PY
|
||||
SH
|
||||
chmod +x "$fake_bin/hyprctl"
|
||||
|
||||
export PANAMA_DISPLAY_FIXTURE="$fixture"
|
||||
|
||||
run() {
|
||||
PATH="$fake_bin:$PATH" XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
transaction_status() {
|
||||
run ipc --pid "$harness_pid" call displays-test transactionStatus
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
[[ "$harness_pid" =~ ^[0-9]+$ ]] && kill "$harness_pid" 2>/dev/null || true
|
||||
rm -rf "$fixture"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
PATH="$fake_bin:$PATH" XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \
|
||||
qs -p "$harness" --daemonize >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
harness_pid="$(qs list --all 2>/dev/null | awk -v expected="$harness" '
|
||||
/^Instance / {pid=""} /^[[:space:]]*Process ID:/ {pid=$3}
|
||||
/^[[:space:]]*Config path:/ {path=$0; sub(/^[[:space:]]*Config path: /,"",path); if(path==expected) print pid}' | head -1)"
|
||||
if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then
|
||||
ready="$(transaction_status 2>/dev/null || true)"
|
||||
jq -e '.layout | length == 2' <<<"$ready" >/dev/null 2>&1 && break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'fixture shell did not start'
|
||||
|
||||
wait_for() {
|
||||
local expression="$1"
|
||||
local value=""
|
||||
for _ in $(seq 1 80); do
|
||||
value="$(transaction_status)"
|
||||
jq -e "$expression" <<<"$value" >/dev/null && { printf '%s' "$value"; return 0; }
|
||||
sleep 0.1
|
||||
done
|
||||
fail "timed out waiting for $expression: $value"
|
||||
}
|
||||
|
||||
# A complete request is one evaluator call carrying every connected output.
|
||||
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 100)" == "true" ]] \
|
||||
|| fail 'valid complete layout was refused'
|
||||
wait_for '.canConfirm == true' >/dev/null
|
||||
first_payload="$(sed -n '1p' "$eval_log")"
|
||||
[[ "$(rg -o 'hl\.monitor' <<<"$first_payload" | wc -l)" == "2" ]] \
|
||||
|| fail "layout was not sent as one complete payload: $first_payload"
|
||||
rg -Fq 'output = "DP-2"' <<<"$first_payload" \
|
||||
&& rg -Fq 'position = "0x0"' <<<"$first_payload" \
|
||||
&& rg -Fq 'output = "HDMI-A-1"' <<<"$first_payload" \
|
||||
&& rg -Fq 'position = "3000x100"' <<<"$first_payload" \
|
||||
|| fail "layout payload omitted a literal output position: $first_payload"
|
||||
|
||||
[[ "$(run ipc --pid "$harness_pid" call displays-test confirmChange)" == "true" ]] \
|
||||
|| fail 'verified complete layout could not be kept'
|
||||
store="$config_home/panama/settings.json"
|
||||
jq -e '.displays | length == 2
|
||||
and .["DP-2"].x == 0 and .["DP-2"].y == 0 and .["DP-2"].primary == true
|
||||
and .["HDMI-A-1"].x == 3000 and .["HDMI-A-1"].y == 100
|
||||
and .["HDMI-A-1"].primary == false' "$store" >/dev/null \
|
||||
|| fail 'confirmation did not persist one complete layout with one primary'
|
||||
|
||||
# Wrong readback never enables Keep; explicit revert restores both outputs.
|
||||
touch "$fixture/wrong-y"
|
||||
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 200)" == "true" ]] \
|
||||
|| fail 'wrong-readback fixture could not start'
|
||||
wait_for '.busy == false and .awaiting == true' >/dev/null
|
||||
jq -e '.canConfirm == false' <<<"$(transaction_status)" >/dev/null \
|
||||
|| fail 'Keep enabled while one output had the wrong y coordinate'
|
||||
rm -f "$fixture/wrong-y"
|
||||
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
||||
wait_for '.busy == false and .awaiting == false' >/dev/null
|
||||
jq -e '.[0].x == 0 and .[0].y == 0 and .[1].x == 3000 and .[1].y == 100' \
|
||||
"$monitor_state" >/dev/null || fail 'explicit revert did not restore the complete previous layout'
|
||||
|
||||
# A stale pre-operation query may update visible data, but cannot settle the
|
||||
# current generation or make Keep available.
|
||||
touch "$fixture/no-apply"
|
||||
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 250)" == "true" ]] \
|
||||
|| fail 'stale-generation fixture could not start'
|
||||
wait_for '.busy == false and .awaiting == true' >/dev/null
|
||||
generation="$(transaction_status | jq -r .generation)"
|
||||
stale_json="$(jq '.[1].y = 250' "$monitor_state")"
|
||||
run ipc --pid "$harness_pid" call displays-test injectReadback "$stale_json" "$((generation - 1))" >/dev/null
|
||||
jq -e '.canConfirm == false' <<<"$(transaction_status)" >/dev/null \
|
||||
|| fail 'stale readback confirmed a newer operation'
|
||||
rm -f "$fixture/no-apply"
|
||||
run ipc --pid "$harness_pid" call displays-test expireApplyVerification >/dev/null
|
||||
wait_for '.busy == false and .awaiting == false' >/dev/null
|
||||
|
||||
# A non-zero evaluator exit follows the same whole-layout recovery path.
|
||||
touch "$fixture/fail-once"
|
||||
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 300)" == "true" ]] \
|
||||
|| fail 'failed-evaluator fixture could not start'
|
||||
failed_state="$(wait_for '.busy == false and .awaiting == false')"
|
||||
jq -e '.lastError | contains("rejected")' <<<"$failed_state" >/dev/null \
|
||||
|| fail "failed apply did not retain a useful recovery message: $failed_state"
|
||||
|
||||
# If an output disconnects while a change is pending, rollback sends one
|
||||
# transaction containing every output that is still connected.
|
||||
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 320)" == "true" ]] \
|
||||
|| fail 'disconnect fixture could not start'
|
||||
wait_for '.canConfirm == true' >/dev/null
|
||||
jq '.[0:1]' "$monitor_state" >"$fixture/connected.json"
|
||||
mv "$fixture/connected.json" "$monitor_state"
|
||||
run ipc --pid "$harness_pid" call displays-test refresh >/dev/null
|
||||
wait_for '.layout | length == 1' >/dev/null
|
||||
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
||||
wait_for '.busy == false and .awaiting == false' >/dev/null
|
||||
disconnect_payload="$(tail -1 "$eval_log")"
|
||||
[[ "$(rg -o 'hl\.monitor' <<<"$disconnect_payload" | wc -l)" == "1" ]] \
|
||||
&& ! rg -Fq 'HDMI-A-1' <<<"$disconnect_payload" \
|
||||
|| fail "disconnect rollback targeted an absent output: $disconnect_payload"
|
||||
|
||||
# Restore the second fixture output without touching the real compositor.
|
||||
jq '. + [{
|
||||
"name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440,
|
||||
"refreshRate":60,"scale":1,"transform":0,"x":3000,"y":100,
|
||||
"availableModes":["[email protected]"]
|
||||
}]' "$monitor_state" >"$fixture/reconnected.json"
|
||||
mv "$fixture/reconnected.json" "$monitor_state"
|
||||
run ipc --pid "$harness_pid" call displays-test refresh >/dev/null
|
||||
wait_for '.layout | length == 2' >/dev/null
|
||||
|
||||
# A revert that exits zero but reads back wrong remains an explicit manual
|
||||
# recovery error rather than pretending the desktop was restored.
|
||||
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 400)" == "true" ]] \
|
||||
|| fail 'bad-revert fixture could not start'
|
||||
wait_for '.canConfirm == true' >/dev/null
|
||||
touch "$fixture/wrong-y"
|
||||
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
||||
wait_for '.reverting != null' >/dev/null
|
||||
run ipc --pid "$harness_pid" call displays-test expireRevertVerification >/dev/null
|
||||
manual_state="$(transaction_status)"
|
||||
jq -e '.busy == false and (.lastError | contains("restore it manually"))' \
|
||||
<<<"$manual_state" >/dev/null \
|
||||
|| fail "wrong revert readback was reported as restored: $manual_state"
|
||||
|
||||
printf 'display transaction contract: PASS\n'
|
||||
@@ -35,12 +35,12 @@ fail() {
|
||||
|
||||
# Keep is unavailable until compositor readback exactly matches the request.
|
||||
for contract in \
|
||||
'property var pendingRequested:' \
|
||||
'property var revertExpected:' \
|
||||
'property var pendingRequestedLayout:' \
|
||||
'property var revertExpectedLayout:' \
|
||||
'property bool revertVerificationActive:' \
|
||||
'property int revertGeneration:' \
|
||||
'readonly property bool canConfirm:' \
|
||||
'function matchesRequest(' \
|
||||
'function matchesLayout(' \
|
||||
'function scalesForMode(' \
|
||||
'function isScaleClean(' \
|
||||
'x: Number.isInteger(monitor.x)' \
|
||||
|
||||
Reference in New Issue
Block a user