Files
Panama/tests/quickshell/settings-hyprland-write-contract.sh
T
Gabriel Brown b10f8e2593 Stop the compositor write contract failing under load
It passed alone in two seconds and failed in a full suite run at six,
which is the worst way for a test to be wrong: the code was fine and the
report said otherwise.

Each of its waits polls until the compositor reflects a write, and four
seconds was enough on an idle machine and not enough on a busy one. They
are fifteen now. A longer bound cannot mask a dead write, because a
write that never lands never matches -- verified by stubbing the write
path out and watching it still report exactly which policy failed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 17:33:12 -04:00

238 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# Panama Settings must actually change the compositor, not merely believe it did.
#
# The pre-existing settings-system-contract.sh applies the values the compositor
# already holds and asserts they are unchanged, so it passes whether the write
# works or does nothing at all. That is how `hyprctl keyword` silently failing --
# it prints "keyword can't work with non-legacy parsers" to stdout and exits 0 --
# went unnoticed on this Lua-configured Hyprland.
#
# This contract flips each policy to a value it does not currently hold, reads it
# back from the compositor, and restores the original. A write path that no-ops
# cannot pass it.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml"
# A verified write commits to preferences, and preferences live at
# $XDG_CONFIG_HOME/panama/settings.json. Without an isolated config home this
# contract would persist its deliberately-wrong test values into the user's real
# store, where the next `hyprctl reload` would faithfully apply them. The
# compositor is still the live one -- that is the point of the contract -- and
# the EXIT trap restores it.
config_home="$(mktemp -d /tmp/panama-write-config.XXXXXX)"
fail() {
printf 'settings hyprland write contract: %s\n' "$1" >&2
exit 1
}
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"
}
read_option() {
hyprctl -j getoption "$1" | jq -r .int
}
# Every value this contract touches is captured up front, before anything is
# changed. Capturing later risks recording a value an earlier failed run left
# behind and then "restoring" the daily-driver desktop to it.
original_auto_hdr="$(read_option render:cm_auto_hdr)"
original_vrr="$(read_option misc:vrr)"
original_direct="$(read_option render:direct_scanout)"
original_rounding="$(read_option decoration:rounding)"
original_gaps="$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')"
original_blur="$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)"
original_opacity="$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)"
original_layout="$(hyprctl -j getoption input:kb_layout | jq -r .str)"
restore() {
# Restore through hyprctl rather than the harness: if the harness write path
# is the thing that is broken, the daily-driver desktop must still come back.
# Unconditional and idempotent, so it is safe on both the pass and fail path.
hyprctl eval "hl.config({
render = { cm_auto_hdr = $original_auto_hdr, direct_scanout = $original_direct },
misc = { vrr = $original_vrr },
general = { gaps_out = $original_gaps },
input = { kb_layout = \"$original_layout\" },
decoration = {
rounding = $original_rounding,
inactive_opacity = $original_opacity,
blur = { enabled = $original_blur }
}
})" >/dev/null 2>&1 || true
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home"
}
trap restore EXIT
# Pick a target each policy does not currently hold, staying inside the values
# SystemSettings allow-lists (VRR 0|3, direct scanout 0|2).
target_auto_hdr=$([[ "$original_auto_hdr" == 1 ]] && printf false || printf true)
target_auto_hdr_int=$([[ "$target_auto_hdr" == true ]] && printf 1 || printf 0)
target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3)
target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2)
# settings-commit-reset-contract drives the SAME harness file with
# PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1, where the compositor write seam is
# stubbed out. Quickshell identifies an instance by its config path, so if that
# run's instance has not fully exited, the `ipc show` wait below is satisfied by
# ITS target -- and every write in this contract lands on the isolated instance
# and never reaches the compositor. That is exactly what "a typed batch did not
# reach the compositor" looks like when this fails in a full suite run but
# passes on its own.
#
# So wait for the harness to be clear first, and say so plainly if it is not,
# rather than silently talking to the wrong shell.
harness_instances() {
# rg -c prints nothing at all when there are no matches, so an unguarded
# substitution yields "" rather than "0" and every comparison against a
# count fails.
local count
count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)"
printf '%s' "${count:-0}"
}
for _ in $(seq 1 50); do
[[ "$(harness_instances)" == "0" ]] && break
sleep 0.1
done
[[ "$(harness_instances)" == "0" ]] \
|| fail 'another instance of the settings harness is still running -- this contract would talk to it instead of its own, and its writes may be deliberately stubbed'
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
# Bounded waits are deliberately generous. Each of these polls until the
# compositor reflects a write, and four seconds was enough on an idle machine
# and not enough on a busy one -- this contract passed alone and failed in a
# full suite run, which is the worst way for a test to be wrong. A longer bound
# cannot mask a dead write, because a write that never lands never matches; it
# only stops a slow one from being reported as a broken one.
for _ in $(seq 1 150); do
if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then
break
fi
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' || fail 'test IPC target did not start'
# ── The write must reach the compositor ──────────────────────────────────────
qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" "$target_vrr" "$target_direct" >/dev/null
applied=false
for _ in $(seq 1 150); do
if [[ "$(read_option render:cm_auto_hdr)" == "$target_auto_hdr_int" \
&& "$(read_option misc:vrr)" == "$target_vrr" \
&& "$(read_option render:direct_scanout)" == "$target_direct" ]]; then
applied=true
break
fi
sleep 0.1
done
[[ "$applied" == true ]] || fail "policy write did not reach the compositor: \
cm_auto_hdr=$(read_option render:cm_auto_hdr) (want $target_auto_hdr_int), \
vrr=$(read_option misc:vrr) (want $target_vrr), \
direct_scanout=$(read_option render:direct_scanout) (want $target_direct)"
# ── A successful write must not report an error ──────────────────────────────
last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
[[ -z "$last_error" ]] || fail "a successful write reported an error: $last_error"
# ── A rejected value must be refused, not silently accepted ──────────────────
qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" 7 "$target_direct" >/dev/null
# The refusal is reported asynchronously, so wait for it rather than sleeping a
# fixed 0.3s and hoping. That sleep made this fail roughly one run in three,
# reporting "a rejected value did not surface an error" when the error simply
# had not arrived yet -- which reads as a missing guard rather than a slow one.
rejected=""
for _ in $(seq 1 150); do
rejected="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
[[ -n "$rejected" ]] && break
sleep 0.1
done
[[ "$(read_option misc:vrr)" == "$target_vrr" ]] || fail 'an out-of-allow-list VRR value reached the compositor'
[[ -n "$rejected" ]] || fail 'a rejected VRR value did not surface an error'
# ── Every getoption answer shape must be handled, not just integers ──────────
# The compositor reports each option in a different JSON field depending on its
# type, and gaps come back as a four-value box. A verifier that only understood
# "int" would report every other type as rejected.
qs_for_harness ipc call settings-system-test applyJson \
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
# 10 seconds, not 4. The write path verifies each option by reading it back off
# the compositor and retries a refused batch, so a busy machine legitimately
# takes longer than a quick apply -- and this contract runs in a suite alongside
# other tests driving the same compositor. Failing at 4 seconds reported a
# product bug ("did not reach the compositor") for what was queueing.
# Re-issued periodically, because this contract and the LIVE shell both write to
# the same compositor. When the running Panama re-applies its own preferences --
# which it does on any store change -- it overwrites the values this test just
# set, and the read-back below then sees Panama's shipped defaults with the
# writer reporting no error at all. That combination is the signature: a
# rejected write leaves an error, a clobbered one does not.
typed=false
for attempt in $(seq 1 100); do
if (( attempt % 30 == 0 )); then
qs_for_harness ipc call settings-system-test applyJson \
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
fi
if [[ "$(read_option decoration:rounding)" == "7" \
&& "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \
&& "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \
&& "$(hyprctl -j getoption decoration:inactive_opacity | jq -r '.float | (.*100|round)')" == "85" ]]; then
typed=true
break
fi
sleep 0.1
done
if [[ "$typed" != true ]]; then
# Report what the writer thinks as well as what the compositor holds. Those
# two disagreeing is a rejected write; both showing defaults is a write that
# never happened, and the messages should not look identical.
fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \
gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \
blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float), \
writer-reported error=\"$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)\""
fi
# Verification must recognize those shapes as success, not report them rejected.
last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
if [[ -n "$last_error" ]]; then
fail "a verified typed write was reported as failed: $last_error"
fi
# ── A string that violates its schema pattern must never reach hl.config ──────
[[ "$(qs_for_harness ipc call settings-system-test applyJson '{"keyboardLayout": "us\"; os.execute(\"touch /tmp/panama-pwned\")--"}')" == "false" ]] \
|| fail 'a keyboard layout violating the schema pattern was accepted'
[[ ! -e /tmp/panama-pwned ]] || fail 'a settings value was executed as Lua'
[[ "$(hyprctl -j getoption input:kb_layout | jq -r .str)" == "$original_layout" ]] \
|| fail 'the keyboard layout changed despite a rejected value'
# ── Restoring through the real path must also work ───────────────────────────
qs_for_harness ipc call settings-system-test apply \
"$([[ "$original_auto_hdr" == 1 ]] && printf true || printf false)" \
"$original_vrr" "$original_direct" >/dev/null
restored=false
for _ in $(seq 1 150); do
if [[ "$(read_option render:cm_auto_hdr)" == "$original_auto_hdr" \
&& "$(read_option misc:vrr)" == "$original_vrr" \
&& "$(read_option render:direct_scanout)" == "$original_direct" ]]; then
restored=true
break
fi
sleep 0.1
done
[[ "$restored" == true ]] || fail 'the original policy values could not be restored through SystemSettings'
trap - EXIT
restore
printf 'settings hyprland write contract: PASS\n'