Make settings restore crash-safe
This commit is contained in:
@@ -24,9 +24,24 @@ trap cleanup EXIT
|
||||
settings="$work/config/panama/settings.json"
|
||||
home="$work/state/panama/panama-home.json"
|
||||
backups="$work/state/panama/backups"
|
||||
transaction_dir="$work/state/panama/transactions/settings-restore"
|
||||
mkdir -p "$(dirname "$settings")"
|
||||
|
||||
run() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" "$@"; }
|
||||
run_with() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" env "$@"; }
|
||||
|
||||
assert_transaction_clean() {
|
||||
if [[ -d "$transaction_dir" ]] && find "$transaction_dir" -mindepth 1 -print -quit | rg -q .; then
|
||||
fail 'restore left staged, rollback, or journal files behind'
|
||||
fi
|
||||
if find "$work" -type f \( \
|
||||
-name '.settings-restore.*' -o -name '.home-restore.*' \
|
||||
-o -name '*rollback*' -o -name '.journal.json.*' \
|
||||
-o -name '.settings.json.*' -o -name '.panama-home.json.*' \
|
||||
\) -print -quit | rg -q .; then
|
||||
fail 'restore left a temporary target or journal file behind'
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Nothing to back up ───────────────────────────────────────────────────────
|
||||
run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported success'
|
||||
@@ -62,6 +77,22 @@ absent_result="$(run restore "$absent_name")" || fail 'restore failed for a snap
|
||||
jq -e '.home.present == false and (.home | has("data") | not)' <<<"$absent_result" >/dev/null \
|
||||
|| fail 'restore did not return absent Home state for the live service to reload'
|
||||
|
||||
# Desktop absence is symmetric: a Home-only snapshot removes a desktop file
|
||||
# created later and restores the Home store.
|
||||
rm -f "$settings"
|
||||
printf '{"initialized":true,"favorites":[{"id":"light.porch","alias":"Porch"}]}' >"$home"
|
||||
run save >/dev/null || fail 'save failed when desktop settings were absent'
|
||||
desktop_absent_name="$(run list | jq -r '.[0].name')"
|
||||
printf '{"gapsOut":47}' >"$settings"
|
||||
printf '{"initialized":false,"favorites":[]}' >"$home"
|
||||
run restore "$desktop_absent_name" >/dev/null || fail 'Home-only snapshot restore failed'
|
||||
[[ ! -e "$settings" ]] || fail 'restore did not preserve the snapshot’s absent desktop state'
|
||||
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.porch" ]] \
|
||||
|| fail 'Home-only snapshot did not restore Home state'
|
||||
assert_transaction_clean
|
||||
|
||||
printf '{"gapsOut":17}' >"$settings"
|
||||
|
||||
# A legacy settings-only snapshot predates presence metadata. Its safest
|
||||
# interpretation is to restore desktop settings without deleting current Home
|
||||
# state that the old format knew nothing about.
|
||||
@@ -72,6 +103,43 @@ run restore "$legacy" >/dev/null || fail 'legacy snapshot restore failed'
|
||||
[[ "$(jq -r .gapsOut "$settings")" == "17" ]] || fail 'legacy snapshot did not restore desktop settings'
|
||||
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.office" ]] || fail 'legacy snapshot destroyed Home state it did not describe'
|
||||
|
||||
# `version` is a valid unknown desktop preference. It is only an envelope when
|
||||
# the complete v2 shape is present.
|
||||
legacy_version="settings-20000101-010203005.json"
|
||||
printf '{"version":77,"gapsOut":19}' >"$backups/$legacy_version"
|
||||
run restore "$legacy_version" >/dev/null || fail 'a legacy snapshot with an unknown version key was rejected'
|
||||
[[ "$(jq -r '.version' "$settings")" == "77" ]] || fail 'legacy version key was not restored as desktop data'
|
||||
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.office" ]] || fail 'legacy version key changed Home state'
|
||||
|
||||
# ── A durable journal recovers a process/power-loss split ────────────────────
|
||||
printf '{"gapsOut":28,"windowRounding":12}' >"$settings"
|
||||
printf '{"initialized":true,"favorites":[{"id":"light.desk","alias":"Snapshot"}]}' >"$home"
|
||||
run save >/dev/null || fail 'could not create crash-recovery snapshot'
|
||||
crash_name="$(run list | jq -r '.[0].name')"
|
||||
|
||||
printf '{"gapsOut":91,"windowRounding":3}' >"$settings"
|
||||
printf '{"initialized":true,"favorites":[{"id":"light.office","alias":"Before crash"}]}' >"$home"
|
||||
run_with PANAMA_SETTINGS_BACKUP_TEST_CRASH=after-desktop "$helper" restore "$crash_name" >/dev/null 2>&1 \
|
||||
&& fail 'crash injection completed restore instead of terminating after the first replacement'
|
||||
[[ "$(jq -r '.gapsOut' "$settings")" == "28" ]] || fail 'crash did not occur after desktop replacement'
|
||||
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'crash unexpectedly replaced Home state'
|
||||
[[ -f "$transaction_dir/journal.json" ]] || fail 'crash left no durable recovery journal'
|
||||
|
||||
# Every entry point must recover before doing its own work. `list` is the least
|
||||
# invasive proof and must put both stores back to the pre-restore generation.
|
||||
run list >/dev/null || fail 'next invocation could not recover the interrupted restore'
|
||||
[[ "$(jq -r '.gapsOut' "$settings")" == "91" ]] || fail 'recovery did not roll desktop settings back'
|
||||
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'recovery did not keep Home state in the same generation'
|
||||
assert_transaction_clean
|
||||
|
||||
# Cleanup is installed before staging. A deterministic pre-journal failure
|
||||
# must leave both destinations untouched and no hidden artifacts behind.
|
||||
run_with PANAMA_SETTINGS_BACKUP_TEST_FAIL=after-desktop-stage "$helper" restore "$crash_name" >/dev/null 2>&1 \
|
||||
&& fail 'staging failure injection unexpectedly restored the snapshot'
|
||||
[[ "$(jq -r '.gapsOut' "$settings")" == "91" ]] || fail 'staging failure changed desktop settings'
|
||||
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'staging failure changed Home state'
|
||||
assert_transaction_clean
|
||||
|
||||
# ── Restoring snapshots what it replaced, so it is undoable ──────────────────
|
||||
count="$(run list | jq 'length')"
|
||||
[[ "$count" -ge 2 ]] || fail "restore did not snapshot the replaced settings (only $count snapshots)"
|
||||
@@ -81,7 +149,7 @@ bad="settings-19990101-000000000.json"
|
||||
mkdir -p "$backups"
|
||||
printf '{ truncated' >"$backups/$bad"
|
||||
run restore "$bad" >/dev/null 2>&1 && fail 'a corrupt snapshot was restored'
|
||||
[[ "$(jq -r .gapsOut "$settings")" == "17" ]] || fail 'a refused restore still damaged the settings file'
|
||||
[[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'a refused restore still damaged the settings file'
|
||||
|
||||
invalid_home="settings-19990101-000000001.json"
|
||||
jq -n '{
|
||||
@@ -96,7 +164,7 @@ jq -n '{
|
||||
}}
|
||||
}' >"$backups/$invalid_home"
|
||||
run restore "$invalid_home" >/dev/null 2>&1 && fail 'a snapshot with duplicate Home favourites was restored'
|
||||
[[ "$(jq -r .gapsOut "$settings")" == "17" ]] || fail 'an invalid Home snapshot still damaged desktop settings'
|
||||
[[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'an invalid Home snapshot still damaged desktop settings'
|
||||
|
||||
printf '{ truncated' >"$home"
|
||||
run save >/dev/null 2>&1 && fail 'a corrupt Home state file was backed up'
|
||||
|
||||
@@ -1,47 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The helper proves the two stores round-trip in an isolated XDG tree. This
|
||||
# source contract pins the live handoff without launching a second copy of the
|
||||
# daily-driver shell or invoking Hyprland during tests.
|
||||
# Behavioral coverage for the QML handoff after the helper commits a restore.
|
||||
# The harness has a unique shell identity, isolated XDG roots, and fake external
|
||||
# consumers. It records the real SettingsBackup call order without touching the
|
||||
# daily-driver shell, compositor, keymap, or wallpaper.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/SettingsBackup.qml"
|
||||
harness="$repo_dir/config/dot/quickshell/settings-backup-harness.qml"
|
||||
work="$(mktemp -d /tmp/panama-settings-backup-live.XXXXXX)"
|
||||
|
||||
fail() {
|
||||
printf 'settings backup live contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
rg -q 'DesktopPreferences\.reload\(\)' "$service" \
|
||||
|| fail 'restore does not reload DesktopPreferences'
|
||||
rg -q 'HomePreferences\.resetHomeDefaults\(\)' "$service" \
|
||||
|| fail 'restore does not clear current Home state before reloading it'
|
||||
rg -q 'HomePreferences\.initialize\(' "$service" \
|
||||
|| fail 'restore does not reload restored Home favourites through the public API'
|
||||
rg -q 'HomePreferences\.setAlias\(' "$service" \
|
||||
|| fail 'restore does not reload restored Home aliases through the public API'
|
||||
if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$service"; then
|
||||
fail 'restore bypasses the durable HomePreferences API with direct alias mutation'
|
||||
fi
|
||||
rg -q 'SystemSettings\.applyPersistedDisplayPolicy\(\)' "$service" \
|
||||
|| fail 'restore does not reapply compositor-backed preferences'
|
||||
rg -q 'Keybinds\.applyReload\(\)' "$service" \
|
||||
|| fail 'restore does not regenerate and reload rebound shortcuts'
|
||||
rg -q 'Wallpaper\.set\(' "$service" \
|
||||
|| fail 'restore does not reapply the restored wallpaper'
|
||||
rg -q 'Quickshell\.reload\(false\)' "$service" \
|
||||
|| fail 'restore does not soft-reload HomePreferences and reactive theme state'
|
||||
rg -q 'actionRun\.exec\(\[root\.helperPath, "save", root\.serialiseHomeState\(\)\]\)' "$service" \
|
||||
|| fail 'save does not hand the live HomePreferences state to the canonical backup store'
|
||||
qs_test() {
|
||||
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
# User-controlled snapshot names must remain argv values. Restoring through a
|
||||
# shell command would make validation in the helper the only line of defence.
|
||||
rg -q 'actionRun\.exec\(\[root\.helperPath, "restore", name\]\)' "$service" \
|
||||
cleanup() {
|
||||
qs_test kill >/dev/null 2>&1 || true
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# The production command boundary must remain argv-only.
|
||||
rg -Fq 'actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);' "$service" \
|
||||
|| fail 'save does not pass live Home state as one argument'
|
||||
rg -Fq 'actionRun.exec([root.helperPath, "restore", name]);' "$service" \
|
||||
|| fail 'restore is not executed through an argument array'
|
||||
if rg -q 'bash.*-c|sh.*-c' "$service"; then
|
||||
fail 'the restore service constructs a shell command'
|
||||
fi
|
||||
|
||||
# The harness replaces these seams, while these mappings prove the production
|
||||
# defaults still delegate to Panama's existing public service APIs.
|
||||
for mapping in \
|
||||
'HomePreferences.resetHomeDefaults();' \
|
||||
'HomePreferences.initialize(ids);' \
|
||||
'HomePreferences.setAlias(id, alias);' \
|
||||
'DesktopPreferences.reload();' \
|
||||
'SystemSettings.applyPersistedDisplayPolicy();' \
|
||||
'Keybinds.applyReload();' \
|
||||
'Wallpaper.set(path);' \
|
||||
'Quickshell.reload(false);'; do
|
||||
rg -Fq "$mapping" "$service" || fail "production restore seam is missing: $mapping"
|
||||
done
|
||||
|
||||
qs_test --daemonize >"$work/quickshell.log" 2>&1
|
||||
ready=false
|
||||
for _ in $(seq 1 60); do
|
||||
if qs_test ipc show 2>/dev/null | rg -q '^target settings-backup-behavior$'; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if [[ "$ready" != true ]]; then
|
||||
sed -n '1,200p' "$work/quickshell.log" >&2
|
||||
fail 'isolated SettingsBackup harness did not start'
|
||||
fi
|
||||
|
||||
qs_test ipc call settings-backup-behavior reset >/dev/null
|
||||
payload='{"restored":"settings-20260818-010203004.json","home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"},{"id":"light.office","alias":"Office"}]}}}'
|
||||
[[ "$(qs_test ipc call settings-backup-behavior apply "$payload")" == "true" ]] \
|
||||
|| fail 'valid restore output was rejected'
|
||||
|
||||
status=""
|
||||
for _ in $(seq 1 50); do
|
||||
status="$(qs_test ipc call settings-backup-behavior status)"
|
||||
jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '
|
||||
.calls == [
|
||||
"home.reset",
|
||||
"home.initialize:light.desk,light.office",
|
||||
"home.alias:light.desk=Desk",
|
||||
"home.alias:light.office=Office",
|
||||
"desktop.reload",
|
||||
"system.apply",
|
||||
"keybinds.reload",
|
||||
"wallpaper.set:/tmp/restored-wallpaper.jpg",
|
||||
"shell.reload"
|
||||
]
|
||||
and .initialized == true
|
||||
and .favorites == [
|
||||
{"id":"light.desk","alias":"Desk"},
|
||||
{"id":"light.office","alias":"Office"}
|
||||
]
|
||||
' <<<"$status" >/dev/null || fail "restore handoff order/state was wrong: $status"
|
||||
|
||||
# Invalid output is rejected before Home state or external consumers change.
|
||||
qs_test ipc call settings-backup-behavior reset >/dev/null
|
||||
invalid='{"home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"One"},{"id":"light.desk","alias":"Two"}]}}}'
|
||||
[[ "$(qs_test ipc call settings-backup-behavior apply "$invalid")" == "false" ]] \
|
||||
|| fail 'duplicate Home state was accepted'
|
||||
status="$(qs_test ipc call settings-backup-behavior status)"
|
||||
jq -e '.calls == [] and .initialized == false and .favorites == []' <<<"$status" >/dev/null \
|
||||
|| fail 'invalid restore output caused partial live mutations'
|
||||
|
||||
# An absent Home generation uses the same ordered external handoff but leaves
|
||||
# the live Home service reset rather than manufacturing an initialized store.
|
||||
qs_test ipc call settings-backup-behavior reset >/dev/null
|
||||
absent='{"restored":"settings-20260818-010203005.json","home":{"present":false}}'
|
||||
[[ "$(qs_test ipc call settings-backup-behavior apply "$absent")" == "true" ]] \
|
||||
|| fail 'absent Home restore output was rejected'
|
||||
for _ in $(seq 1 50); do
|
||||
status="$(qs_test ipc call settings-backup-behavior status)"
|
||||
jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '
|
||||
.calls == [
|
||||
"home.reset",
|
||||
"desktop.reload",
|
||||
"system.apply",
|
||||
"keybinds.reload",
|
||||
"wallpaper.set:/tmp/restored-wallpaper.jpg",
|
||||
"shell.reload"
|
||||
]
|
||||
and .initialized == false
|
||||
and .favorites == []
|
||||
' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status"
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'settings backup live contract: PASS\n'
|
||||
|
||||
Reference in New Issue
Block a user