Merge current Panama main
This commit is contained in:
@@ -7,10 +7,22 @@
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/health-harness.qml"
|
||||
source_harness="$repo_dir/config/dot/quickshell/health-harness.qml"
|
||||
service="$repo_dir/config/dot/quickshell/services/Health.qml"
|
||||
shell="$repo_dir/config/dot/quickshell/shell.qml"
|
||||
warning_snapshot='{"schemaVersion":1,"generatedAt":"2026-08-18T00:00:00Z","summary":{"status":"warning","healthy":0,"warnings":2,"errors":0,"unconfigured":0},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"warning","detail":"Duplicate inhibitors are active.","action":{"kind":"repair","label":"Release duplicate inhibitors","confirm":false}}]}'
|
||||
confirm_snapshot="$(jq -c '
|
||||
.summary.status = "error"
|
||||
| .summary.errors = 1
|
||||
| .checks += [{
|
||||
id: "desktop.quickshell",
|
||||
group: "desktop-foundation",
|
||||
title: "Quickshell",
|
||||
status: "error",
|
||||
detail: "Panama shell needs to restart.",
|
||||
action: {kind: "repair", label: "Restart Panama", confirm: true}
|
||||
}]
|
||||
' <<<"$warning_snapshot")"
|
||||
projection_snapshot="$(jq -c '
|
||||
.fixtureSecret = "fixture-secret"
|
||||
| .summary.fixtureSecret = "fixture-secret"
|
||||
@@ -33,7 +45,7 @@ fail() {
|
||||
}
|
||||
|
||||
[[ -f "$service" ]] || fail 'Health.qml is missing'
|
||||
[[ -f "$harness" ]] || fail 'health harness is missing'
|
||||
[[ -f "$source_harness" ]] || fail 'health harness is missing'
|
||||
[[ -f "$shell" ]] || fail 'shell.qml is missing'
|
||||
|
||||
# shell.qml is not started here: it is the active desktop shell. Keep this
|
||||
@@ -72,12 +84,41 @@ if keys != ["summary", "busy", "generation", "acceptedGeneration", "checks"]:
|
||||
PY
|
||||
|
||||
fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)"
|
||||
config_path="$fixture_dir/quickshell"
|
||||
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
|
||||
harness="$config_path/health-harness.qml"
|
||||
python3 - "$harness" <<'PY'
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
source = open(path, encoding="utf-8").read()
|
||||
needle = ' function repair(id: string): bool { return Health.repair(id, false); }\n'
|
||||
replacement = needle + ''' function externalRepair(id: string): bool { return Health.repair(id, true); }
|
||||
function pendingRefreshRace(): string {
|
||||
const before = Health.generation;
|
||||
Health.finishRepair(0, "panama.caffeine", false, JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
checkId: "panama.caffeine",
|
||||
accepted: true,
|
||||
exitCode: 0,
|
||||
message: "Fixture repair completed."
|
||||
}));
|
||||
const accepted = Health.refresh();
|
||||
return JSON.stringify({ accepted: accepted, before: before });
|
||||
}
|
||||
'''
|
||||
if needle not in source:
|
||||
raise SystemExit("health harness repair seam is missing")
|
||||
open(path, "w", encoding="utf-8").write(source.replace(needle, replacement))
|
||||
PY
|
||||
helper="$fixture_dir/panama-doctor"
|
||||
copy_bin="$fixture_dir/bin"
|
||||
copy_file="$fixture_dir/copied-report.json"
|
||||
repair_mode_file="$fixture_dir/repair-mode"
|
||||
repair_log="$fixture_dir/repair.log"
|
||||
notification_log="$fixture_dir/notifications.log"
|
||||
repair_started_file="$fixture_dir/repair-started"
|
||||
repair_release_file="$fixture_dir/repair-release"
|
||||
printf 'success\n' >"$repair_mode_file"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
@@ -87,8 +128,12 @@ printf '%s\n' \
|
||||
" printf '%s\\n' '$warning_snapshot'" \
|
||||
' exit 0' \
|
||||
'fi' \
|
||||
'if [[ "$1" == "--repair" ]]; then' \
|
||||
' repair_start_time="$(awk '\''{ print $22 }'\'' "/proc/$$/stat")"' \
|
||||
' printf "%s|%s\n" "$$" "$repair_start_time" >"$PANAMA_HEALTH_REPAIR_STARTED"' \
|
||||
' while [[ ! -e "$PANAMA_HEALTH_REPAIR_RELEASE" ]]; do sleep 0.02; done' \
|
||||
'fi' \
|
||||
'if [[ "$1" == "--repair" && "$2" == "panama.caffeine" && "$3" == "--json" ]]; then' \
|
||||
' sleep 0.25' \
|
||||
' case "$(cat "$PANAMA_HEALTH_REPAIR_MODE_FILE")" in' \
|
||||
' success) printf "{\"schemaVersion\":1,\"checkId\":\"panama.caffeine\",\"accepted\":true,\"exitCode\":0,\"message\":\"Duplicate inhibitors were released.\"}\\n"; exit 0 ;;' \
|
||||
' failed) printf "{\"schemaVersion\":1,\"checkId\":\"panama.caffeine\",\"accepted\":true,\"exitCode\":7,\"message\":\"Duplicate inhibitors could not be released.\"}\\n"; exit 7 ;;' \
|
||||
@@ -96,6 +141,10 @@ printf '%s\n' \
|
||||
' *) printf "not-json\\n"; exit 0 ;;' \
|
||||
' esac' \
|
||||
'fi' \
|
||||
'if [[ "$1" == "--repair" && "$2" == "desktop.quickshell" && "$3" == "--json" ]]; then' \
|
||||
' printf "{\"schemaVersion\":1,\"checkId\":\"desktop.quickshell\",\"accepted\":true,\"exitCode\":0,\"message\":\"Panama shell restart was requested.\"}\\n"' \
|
||||
' exit 0' \
|
||||
'fi' \
|
||||
'exit 2' >"$helper"
|
||||
chmod +x "$helper"
|
||||
mkdir -p "$copy_bin"
|
||||
@@ -110,12 +159,57 @@ chmod +x "$copy_bin/wl-copy" "$copy_bin/notify-send"
|
||||
run() {
|
||||
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \
|
||||
PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
|
||||
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" qs -p "$harness" "$@"
|
||||
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \
|
||||
PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
harness_pid=""
|
||||
harness_start_time=""
|
||||
|
||||
process_identity_matches() {
|
||||
local pid="$1" expected_start_time="$2" expected_command="${3:-}" current_start_time
|
||||
|
||||
[[ "$pid" =~ ^[0-9]+$ && "$expected_start_time" =~ ^[0-9]+$ ]] || return 1
|
||||
[[ -r "/proc/$pid/stat" ]] || return 1
|
||||
current_start_time="$(awk '{ print $22 }' "/proc/$pid/stat" 2>/dev/null)" || return 1
|
||||
[[ "$current_start_time" == "$expected_start_time" ]] || return 1
|
||||
if [[ -n "$expected_command" ]]; then
|
||||
[[ -r "/proc/$pid/cmdline" ]] || return 1
|
||||
tr '\0' '\n' <"/proc/$pid/cmdline" | grep -Fxq "$expected_command"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
[[ -n "$harness_pid" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
||||
: >"$repair_release_file"
|
||||
if [[ -f "$repair_started_file" ]]; then
|
||||
IFS='|' read -r repair_pid repair_start_time <"$repair_started_file" || true
|
||||
if process_identity_matches "$repair_pid" "$repair_start_time" "$helper"; then
|
||||
for _ in $(seq 1 40); do
|
||||
! process_identity_matches "$repair_pid" "$repair_start_time" "$helper" && break
|
||||
sleep 0.05
|
||||
done
|
||||
if process_identity_matches "$repair_pid" "$repair_start_time" "$helper"; then
|
||||
kill "$repair_pid" >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 20); do
|
||||
! process_identity_matches "$repair_pid" "$repair_start_time" "$helper" && break
|
||||
sleep 0.05
|
||||
done
|
||||
if process_identity_matches "$repair_pid" "$repair_start_time" "$helper"; then
|
||||
kill -KILL "$repair_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if process_identity_matches "$harness_pid" "$harness_start_time"; then
|
||||
kill "$harness_pid" >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 40); do
|
||||
! process_identity_matches "$harness_pid" "$harness_start_time" && break
|
||||
sleep 0.05
|
||||
done
|
||||
if process_identity_matches "$harness_pid" "$harness_start_time"; then
|
||||
kill -KILL "$harness_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
rm -rf "$fixture_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -123,6 +217,7 @@ trap cleanup EXIT
|
||||
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \
|
||||
PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
|
||||
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \
|
||||
PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \
|
||||
qs -p "$harness" --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
run ipc show 2>/dev/null | rg -q '^target health-test$' && break
|
||||
@@ -130,6 +225,9 @@ for _ in $(seq 1 40); do
|
||||
done
|
||||
run ipc show 2>/dev/null | rg -q '^target health-test$' || fail 'test IPC target did not start'
|
||||
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
|
||||
harness_start_time="$(awk '{ print $22 }' "/proc/$harness_pid/stat" 2>/dev/null || true)"
|
||||
process_identity_matches "$harness_pid" "$harness_start_time" \
|
||||
|| fail 'could not capture a stable health harness process identity'
|
||||
|
||||
[[ "$(run ipc call health-test accept "$warning_snapshot" 0)" == "true" ]] \
|
||||
|| fail 'valid warning snapshot was rejected'
|
||||
@@ -186,14 +284,21 @@ jq -e '.busy == false and .generation == ($before + 2) and .queuedRefresh == fal
|
||||
>/dev/null <<<"$state" || fail "queued refresh did not run exactly once: $state"
|
||||
|
||||
printf 'success\n' >"$repair_mode_file"
|
||||
rm -f "$repair_started_file" "$repair_release_file"
|
||||
repair_generation="$(jq -r .generation <<<"$state")"
|
||||
[[ "$(run ipc call health-test repair panama.caffeine)" == "true" ]] \
|
||||
|| fail 'repairable check was refused'
|
||||
for _ in $(seq 1 100); do
|
||||
[[ -s "$repair_started_file" ]] && break
|
||||
sleep 0.05
|
||||
done
|
||||
[[ -s "$repair_started_file" ]] || fail 'repair helper never reached the started marker'
|
||||
run ipc call health-test queue >/dev/null
|
||||
working_state="$(run ipc call health-test status)"
|
||||
jq -e '.repairingId == "panama.caffeine" and .queuedRefresh == true
|
||||
and (.checkStates[] | select(.id == "panama.caffeine") | .status) == "warning"' \
|
||||
>/dev/null <<<"$working_state" || fail "repair did not retain the degraded row while working: $working_state"
|
||||
: >"$repair_release_file"
|
||||
for _ in $(seq 1 120); do
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' --argjson before "$repair_generation" \
|
||||
@@ -243,12 +348,52 @@ jq -e '.lastRepair.checkId == "panama.caffeine" and .lastRepair.accepted == fals
|
||||
--argjson before "$mismatch_generation" >/dev/null <<<"$state" \
|
||||
|| fail "mismatched repair JSON escaped containment: $state"
|
||||
|
||||
# A refresh arriving after repair settlement but before the deferred mandatory
|
||||
# scan is coalesced into that scan instead of starting an extra generation.
|
||||
pending_race="$(run ipc call health-test pendingRefreshRace)"
|
||||
jq -e '.accepted == false' >/dev/null <<<"$pending_race" \
|
||||
|| fail "refresh escaped the post-repair pending window: $pending_race"
|
||||
pending_generation="$(jq -r .before <<<"$pending_race")"
|
||||
for _ in $(seq 1 120); do
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' \
|
||||
--argjson before "$pending_generation" >/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' \
|
||||
--argjson before "$pending_generation" >/dev/null <<<"$state" \
|
||||
|| fail "pending-window refresh created duplicate scans: $state"
|
||||
|
||||
# External IPC cannot bypass an authored confirmation. The same current row is
|
||||
# still repairable through Settings' external=false path after UI confirmation.
|
||||
confirm_generation="$(jq -r .generation <<<"$state")"
|
||||
[[ "$(run ipc call health-test accept "$confirm_snapshot" "$confirm_generation")" == "true" ]] \
|
||||
|| fail 'confirmation fixture was rejected'
|
||||
before_repair_lines="$(wc -l <"$repair_log")"
|
||||
[[ "$(run ipc call health-test externalRepair desktop.quickshell)" == "false" ]] \
|
||||
|| fail 'external repair bypassed confirmation'
|
||||
[[ "$(wc -l <"$repair_log")" == "$before_repair_lines" ]] \
|
||||
|| fail 'external confirmation rejection started a process'
|
||||
[[ "$(run ipc call health-test repair desktop.quickshell)" == "true" ]] \
|
||||
|| fail 'confirmed Settings repair was refused'
|
||||
for _ in $(seq 1 120); do
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.busy == false and .generation == ($before + 1)' \
|
||||
--argjson before "$confirm_generation" >/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.lastRepair == {schemaVersion:1, checkId:"desktop.quickshell", accepted:true, exitCode:0, message:"Panama shell restart was requested."}
|
||||
and .generation == ($before + 1)' --argjson before "$confirm_generation" \
|
||||
>/dev/null <<<"$state" || fail "confirmed Settings repair did not complete safely: $state"
|
||||
[[ "$(grep -Fc -- '--repair desktop.quickshell --json' "$repair_log")" == 1 ]] \
|
||||
|| fail 'confirmed Settings repair did not start exactly one repair process'
|
||||
|
||||
[[ "$(run ipc call health-test repair unknown.check)" == "false" ]] \
|
||||
|| fail 'unknown check started a repair'
|
||||
[[ "$(run ipc call health-test repair integration.calendar)" == "false" ]] \
|
||||
|| fail 'non-repairable check started a repair'
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$mismatch_generation" \
|
||||
jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$confirm_generation" \
|
||||
>/dev/null <<<"$state" || fail "rejected repair altered process state: $state"
|
||||
|
||||
python3 - "$service" <<'PY' || fail 'external repair failure notification is not bounded'
|
||||
|
||||
@@ -58,11 +58,19 @@ rg -Fq 'implicitHeight: 62' "$settings_dir/HealthCheckRow.qml" \
|
||||
|| fail 'health rows are below the approved 62px target'
|
||||
rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'opening System Health does not request a fresh scan'
|
||||
# The Fedora hand-off is a row per subject, not one button that opened the
|
||||
# network panel whatever it was labelled. What matters is that each row reaches
|
||||
# the panel that owns it, so this checks the boundary still exists and that
|
||||
# every panel it names is one openGnomePanel accepts -- a name outside that
|
||||
# allow-list opens nothing and reports an error, i.e. a dead button.
|
||||
rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary does not open GNOME Settings'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Users handoff'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("sharing")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Sharing handoff'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("color")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Colour profiles handoff'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("wellbeing")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Digital wellbeing handoff'
|
||||
# Exact authored handoffs are asserted above. Also prove every panel named by
|
||||
# this boundary is accepted by SystemSettings, so a typo cannot ship a dead
|
||||
# button even if its copy still looks correct.
|
||||
rg -Fq 'title: "Fedora system settings"' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'the Fedora ownership boundary card is gone'
|
||||
|
||||
@@ -296,6 +304,12 @@ jq -e '
|
||||
and (.renderedRows | map(.id) | length) == 6
|
||||
and (.renderedRows | map(.id) | unique | length) == 6
|
||||
and .emptyQuietGroups == ["desktop-foundation"]
|
||||
and .fedoraHandoffs == [
|
||||
{id:"users", label:"Users", action:"Open users"},
|
||||
{id:"sharing", label:"Sharing", action:"Open sharing"},
|
||||
{id:"color", label:"Colour profiles", action:"Open colour"},
|
||||
{id:"wellbeing", label:"Digital wellbeing", action:"Open wellbeing"}
|
||||
]
|
||||
and .summaryHeight == 126
|
||||
and (.rowHeights | length) == 6
|
||||
and (.rowHeights | all(. >= 62))
|
||||
|
||||
@@ -130,6 +130,134 @@ class KdeConnectBridgeTest(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_status_falls_back_to_paired_dbus_device_when_cli_is_empty(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
device_path = f"/modules/kdeconnect/devices/{device_id}"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["kdeconnect-cli", "--list-devices"]:
|
||||
return subprocess.CompletedProcess(command, 0, "0 devices found\n", "")
|
||||
if command == ["busctl", "--user", "tree", "org.kde.kdeconnect"]:
|
||||
return subprocess.CompletedProcess(command, 0, f"└─ {device_path}\n", "")
|
||||
if command[:3] == ["busctl", "--user", "call"]:
|
||||
return subprocess.CompletedProcess(command, 0, "as 0\n", "")
|
||||
if command[:3] == ["busctl", "--user", "get-property"]:
|
||||
values = {
|
||||
"name": 's "Fixture iPhone"\n',
|
||||
"type": 's "phone"\n',
|
||||
"isPaired": "b true\n",
|
||||
"isReachable": "b false\n",
|
||||
"supportedPlugins": (
|
||||
'as 3 "kdeconnect_share" "kdeconnect_clipboard" '
|
||||
'"kdeconnect_findmyphone"\n'
|
||||
),
|
||||
}
|
||||
return subprocess.CompletedProcess(command, 0, values[command[-1]], "")
|
||||
raise AssertionError(command)
|
||||
|
||||
self.assertEqual(
|
||||
bridge.collect_status(runner),
|
||||
{
|
||||
"available": True,
|
||||
"devices": [
|
||||
{
|
||||
"id": device_id,
|
||||
"name": "Fixture iPhone",
|
||||
"type": "phone",
|
||||
"paired": True,
|
||||
"reachable": False,
|
||||
"actions": ["clipboard", "ring", "share"],
|
||||
}
|
||||
],
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
|
||||
def test_dbus_plugin_timeout_keeps_device_with_no_actions(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["busctl", "--user", "tree", "org.kde.kdeconnect"]:
|
||||
path = f"/modules/kdeconnect/devices/{device_id}"
|
||||
return subprocess.CompletedProcess(command, 0, f"└─ {path}\n", "")
|
||||
if command[:3] == ["busctl", "--user", "call"]:
|
||||
return subprocess.CompletedProcess(command, 0, "as 0\n", "")
|
||||
if command[:3] == ["busctl", "--user", "get-property"]:
|
||||
values = {
|
||||
"name": 's "Fixture iPhone"\n',
|
||||
"type": 's "phone"\n',
|
||||
"isPaired": "b true\n",
|
||||
"isReachable": "b false\n",
|
||||
}
|
||||
if command[-1] == "supportedPlugins":
|
||||
raise subprocess.TimeoutExpired(command, 8)
|
||||
return subprocess.CompletedProcess(command, 0, values[command[-1]], "")
|
||||
raise AssertionError(command)
|
||||
|
||||
self.assertEqual(bridge.dbus_devices(runner)[0]["actions"], [])
|
||||
|
||||
def test_cli_device_plugin_timeout_fails_closed(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
listing = f"- Fixture iPhone: {device_id} (paired and reachable)\n"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["kdeconnect-cli", "--list-devices"]:
|
||||
return subprocess.CompletedProcess(command, 0, listing, "")
|
||||
raise subprocess.TimeoutExpired(command, 8)
|
||||
|
||||
status = bridge.collect_status(runner)
|
||||
|
||||
self.assertEqual(status["devices"][0]["type"], "phone")
|
||||
self.assertEqual(status["devices"][0]["actions"], [])
|
||||
|
||||
def test_dbus_inventory_excludes_unpaired_peers(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["busctl", "--user", "tree", "org.kde.kdeconnect"]:
|
||||
path = f"/modules/kdeconnect/devices/{device_id}"
|
||||
return subprocess.CompletedProcess(command, 0, f"└─ {path}\n", "")
|
||||
if command[:3] == ["busctl", "--user", "get-property"]:
|
||||
values = {
|
||||
"name": 's "Nearby Stranger"\n',
|
||||
"type": 's "phone"\n',
|
||||
"isPaired": "b false\n",
|
||||
"isReachable": "b true\n",
|
||||
}
|
||||
return subprocess.CompletedProcess(command, 0, values[command[-1]], "")
|
||||
raise AssertionError(command)
|
||||
|
||||
self.assertEqual(bridge.dbus_devices(runner), [])
|
||||
|
||||
def test_status_merges_paired_dbus_device_missing_from_cli(self) -> None:
|
||||
cli_id = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
dbus_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
listing = f"- Fixture Laptop: {cli_id} (paired and reachable)\n"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["kdeconnect-cli", "--list-devices"]:
|
||||
return subprocess.CompletedProcess(command, 0, listing, "")
|
||||
if command == ["busctl", "--user", "tree", "org.kde.kdeconnect"]:
|
||||
path = f"/modules/kdeconnect/devices/{dbus_id}"
|
||||
return subprocess.CompletedProcess(command, 0, f"└─ {path}\n", "")
|
||||
if command[:3] == ["busctl", "--user", "call"]:
|
||||
return subprocess.CompletedProcess(command, 0, "as 0\n", "")
|
||||
if command[:3] == ["busctl", "--user", "get-property"]:
|
||||
is_dbus_device = dbus_id in command[4]
|
||||
values = {
|
||||
"name": 's "Fixture iPhone"\n',
|
||||
"type": 's "phone"\n' if is_dbus_device else 's "desktop"\n',
|
||||
"isPaired": "b true\n",
|
||||
"isReachable": "b false\n",
|
||||
"supportedPlugins": "as 0\n",
|
||||
}
|
||||
return subprocess.CompletedProcess(command, 0, values[command[-1]], "")
|
||||
raise AssertionError(command)
|
||||
|
||||
status = bridge.collect_status(runner)
|
||||
|
||||
self.assertEqual({device["id"] for device in status["devices"]}, {cli_id, dbus_id})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -16,7 +16,15 @@ fail() {
|
||||
}
|
||||
|
||||
fixture="$(mktemp -d /tmp/panama-doctor.XXXXXX)"
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
child_pids=()
|
||||
cleanup() {
|
||||
for pid in "${child_pids[@]}"; do
|
||||
kill "$pid" >/dev/null 2>&1 || true
|
||||
wait "$pid" >/dev/null 2>&1 || true
|
||||
done
|
||||
rm -rf "$fixture"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
home="$fixture/home"
|
||||
config_home="$home/.config"
|
||||
@@ -25,7 +33,7 @@ runtime_dir="$fixture/runtime"
|
||||
bin_dir="$fixture/bin"
|
||||
data_home="$home/.local/share"
|
||||
|
||||
mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae"
|
||||
mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae/scripts"
|
||||
cp "$fixture_root/bin/"* "$bin_dir/"
|
||||
chmod +x "$bin_dir"/*
|
||||
|
||||
@@ -81,7 +89,7 @@ touch "$config_home/autostart/nextcloud.desktop"
|
||||
for name in hypr quickshell uwsm vicinae; do
|
||||
ln -s "$repo_dir/config/dot/$name" "$config_home/$name"
|
||||
done
|
||||
ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts"
|
||||
ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts/panama"
|
||||
|
||||
run_doctor() {
|
||||
HOME="$home" \
|
||||
@@ -130,6 +138,20 @@ check_status() {
|
||||
|
||||
snapshot="$(run_doctor --json)"
|
||||
assert_schema_and_redaction "$snapshot"
|
||||
check_status "$snapshot" panama.vicinae-commands ok
|
||||
|
||||
# The diagnostic follows the actual installer contract: the scripts parent is
|
||||
# a directory and only its Panama child is an authored link.
|
||||
rm "$data_home/vicinae/scripts/panama"
|
||||
unlinked_vicinae="$(run_doctor --json)"
|
||||
check_status "$unlinked_vicinae" panama.vicinae-commands warning
|
||||
PANAMA_PATH="$repo_dir" VICINAE_DATA_DIR="$data_home/vicinae" HOME="$home" \
|
||||
PATH="$bin_dir:/usr/bin" "$repo_dir/setup/scripts/link-vicinae-scripts"
|
||||
relinked_vicinae="$(run_doctor --json)"
|
||||
check_status "$relinked_vicinae" panama.vicinae-commands ok
|
||||
[[ -L "$data_home/vicinae/scripts/panama" \
|
||||
&& "$(readlink "$data_home/vicinae/scripts/panama")" == "$repo_dir/config/local/share/vicinae/scripts" ]] \
|
||||
|| fail 'authored Vicinae helper did not create the diagnosed child link'
|
||||
|
||||
# A healthy systemd-backed service stays healthy.
|
||||
check_status "$snapshot" desktop.hyprpaper ok
|
||||
@@ -237,12 +259,14 @@ summary="$(run_doctor --summary)"
|
||||
|
||||
# Repairs run against a second, disposable Panama root. Every process boundary
|
||||
# records its argv, and every filesystem assertion is confined to this fixture.
|
||||
repair_root="$fixture/repair-root"
|
||||
repair_root="$home/.local/share/Panama"
|
||||
repair_log="$runtime_dir/repair.log"
|
||||
mkdir -p "$repair_root/config/dot" "$repair_root/setup/scripts"
|
||||
mkdir -p "$repair_root/config/dot" "$repair_root/config/local/share/vicinae/scripts" \
|
||||
"$repair_root/setup/scripts"
|
||||
for name in hypr quickshell uwsm vicinae; do
|
||||
mkdir -p "$repair_root/config/dot/$name"
|
||||
done
|
||||
cp "$repo_dir/setup/scripts/link-vicinae-scripts" "$repair_root/setup/scripts/link-vicinae-scripts"
|
||||
|
||||
mv "$bin_dir/systemctl" "$bin_dir/systemctl-probe"
|
||||
cat >"$bin_dir/systemctl" <<'EOF'
|
||||
@@ -266,51 +290,59 @@ printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
printf '\n' >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
EOF
|
||||
|
||||
cat >"$bin_dir/kill" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
printf 'kill' >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
printf '\n' >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
EOF
|
||||
|
||||
cat >"$bin_dir/systemd-inhibit" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
printf 'systemd-inhibit' >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
printf '\n' >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
count_file="$XDG_RUNTIME_DIR/caffeine-list-count"
|
||||
count=0
|
||||
[[ ! -f "$count_file" ]] || read -r count <"$count_file"
|
||||
count=$((count + 1))
|
||||
printf '%s\n' "$count" >"$count_file"
|
||||
read -r preserved duplicate <"$XDG_RUNTIME_DIR/caffeine-pids"
|
||||
uid="$(/usr/bin/id -u)"
|
||||
printf 'Panama %s fixture-user 4101 systemd-inhibit sleep:idle Caffeine block\n' "$uid"
|
||||
printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\n' "$uid"
|
||||
mode="$(<"$XDG_RUNTIME_DIR/caffeine-mode")"
|
||||
if [[ "$mode" == disappear && "$count" -ge 2 ]]; then
|
||||
/usr/bin/touch "$XDG_RUNTIME_DIR/release-disappearing-pid"
|
||||
for _ in $(/usr/bin/seq 1 100); do
|
||||
[[ ! -e "/proc/$duplicate" ]] && break
|
||||
/usr/bin/sleep 0.01
|
||||
done
|
||||
fi
|
||||
preserved_comm=systemd-inhibit
|
||||
if [[ "$mode" == preserve-altered && "$count" -ge 2 ]]; then
|
||||
preserved_comm=changed-command
|
||||
fi
|
||||
printf 'Panama %s fixture-user %s %s sleep:idle Caffeine block\n' "$uid" "$preserved" "$preserved_comm"
|
||||
if [[ "$mode" != multiplicity || "$count" -lt 2 ]]; then
|
||||
printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$preserved"
|
||||
fi
|
||||
if [[ "$mode" == altered && "$count" -ge 2 ]]; then
|
||||
printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Other block\n' "$uid" "$duplicate"
|
||||
else
|
||||
printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$duplicate"
|
||||
fi
|
||||
printf 'Other %s fixture-user 4999 systemd-inhibit sleep:idle Caffeine block\n' "$uid"
|
||||
printf 'Panama 99999 fixture-user 4998 systemd-inhibit sleep:idle Caffeine block\n'
|
||||
printf 'Panama %s fixture-user 4997 systemd-inhibit sleep:idle Other block\n' "$uid"
|
||||
printf 'Panama %s fixture-user 4996 systemd-inhibit sleep:idle Caffeine delay\n' "$uid"
|
||||
EOF
|
||||
|
||||
cat >"$repair_root/setup/scripts/link-vicinae-scripts" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
printf 'link-vicinae-scripts|%s' "$0" >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
if (( $# > 0 )); then
|
||||
printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
fi
|
||||
printf '\n' >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
EOF
|
||||
chmod +x "$bin_dir/systemctl" "$bin_dir/panama-action" "$bin_dir/kill" \
|
||||
chmod +x "$bin_dir/systemctl" "$bin_dir/panama-action" \
|
||||
"$bin_dir/systemd-inhibit" "$repair_root/setup/scripts/link-vicinae-scripts"
|
||||
|
||||
run_repair() {
|
||||
HOME="$home" \
|
||||
PATH="$bin_dir" \
|
||||
PATH="$bin_dir:/usr/bin" \
|
||||
XDG_CURRENT_DESKTOP=Hyprland \
|
||||
PANAMA_DOCTOR_ROOT="$repair_root" \
|
||||
PANAMA_DOCTOR_HOME="$home" \
|
||||
PANAMA_DOCTOR_CONFIG_HOME="$config_home" \
|
||||
PANAMA_DOCTOR_STATE_HOME="$state_home" \
|
||||
PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \
|
||||
PANAMA_DOCTOR_PATH="$bin_dir" \
|
||||
PANAMA_DOCTOR_PATH="$bin_dir:/usr/bin" \
|
||||
PANAMA_DOCTOR_TIMEOUT=0.2 \
|
||||
/usr/bin/python3 "$doctor" "$@"
|
||||
}
|
||||
@@ -364,58 +396,327 @@ assert_repair_result desktop.vicinae true 5
|
||||
[[ "$(<"$repair_log")" == 'systemctl|--user|restart|vicinae.service' ]] \
|
||||
|| fail 'failed repair changed the authored argv'
|
||||
|
||||
# The Vicinae repair executes only the authored setup helper with no arguments.
|
||||
: >"$repair_log"
|
||||
# The real authored Vicinae helper converges the exact child link diagnosed by
|
||||
# panama-doctor under the isolated HOME.
|
||||
rm -f "$data_home/vicinae/scripts/panama"
|
||||
before_vicinae_repair="$(run_repair --json)"
|
||||
check_status "$before_vicinae_repair" panama.vicinae-commands warning
|
||||
invoke_repair panama.vicinae-commands
|
||||
[[ "$repair_status" == 0 ]] || fail "Vicinae command repair returned $repair_status"
|
||||
assert_repair_result panama.vicinae-commands true 0
|
||||
[[ "$(<"$repair_log")" == "link-vicinae-scripts|$repair_root/setup/scripts/link-vicinae-scripts" ]] \
|
||||
|| fail "Vicinae command repair argv was not exact: $(<"$repair_log")"
|
||||
after_vicinae_repair="$(run_repair --json)"
|
||||
check_status "$after_vicinae_repair" panama.vicinae-commands ok
|
||||
[[ -L "$data_home/vicinae/scripts/panama" \
|
||||
&& "$(readlink "$data_home/vicinae/scripts/panama")" == "$repair_root/config/local/share/vicinae/scripts" ]] \
|
||||
|| fail 'Vicinae repair did not install the diagnosed child link'
|
||||
|
||||
# Runtime-link repair may replace only the four authored symlink names. Broken
|
||||
# or absent links are recreated toward authored tracked destinations; regular
|
||||
# files and directories remain untouched and make the result incomplete.
|
||||
# Runtime-link repair may replace only absent links or symlinks whose lexical
|
||||
# target proves Panama ownership. Every other object remains untouched.
|
||||
for name in hypr quickshell uwsm vicinae; do
|
||||
path="$config_home/$name"
|
||||
if [[ -e "$path" || -L "$path" ]]; then
|
||||
mv "$path" "$fixture/pre-repair-$name"
|
||||
fi
|
||||
done
|
||||
ln -s "$fixture/missing-hypr" "$config_home/hypr"
|
||||
ln -s "$fixture/missing-quickshell" "$config_home/quickshell"
|
||||
printf 'user-owned file\n' >"$config_home/uwsm"
|
||||
mkdir "$config_home/vicinae"
|
||||
ln -s "$repair_root/config/dot/hypr" "$config_home/hypr"
|
||||
correct_inode="$(stat -c %i "$config_home/hypr")"
|
||||
ln -s "$repair_root/config/dot/quickshell" "$config_home/uwsm"
|
||||
ln -s "$fixture/external-broken-link" "$config_home/vicinae"
|
||||
ln -s "$fixture/untouched" "$config_home/not-panama"
|
||||
: >"$repair_log"
|
||||
invoke_repair panama.runtime-links
|
||||
[[ "$repair_status" == 1 ]] || fail "blocked runtime-link repair returned $repair_status"
|
||||
assert_repair_result panama.runtime-links true 1
|
||||
[[ -L "$config_home/hypr" && "$(readlink "$config_home/hypr")" == "$repair_root/config/dot/hypr" ]] \
|
||||
|| fail 'hypr link was not recreated toward its authored destination'
|
||||
|| fail 'correct runtime link changed'
|
||||
[[ "$(stat -c %i "$config_home/hypr")" == "$correct_inode" ]] \
|
||||
|| fail 'correct runtime link was replaced instead of left untouched'
|
||||
[[ -L "$config_home/quickshell" && "$(readlink "$config_home/quickshell")" == "$repair_root/config/dot/quickshell" ]] \
|
||||
|| fail 'quickshell link was not recreated toward its authored destination'
|
||||
[[ -f "$config_home/uwsm" && "$(<"$config_home/uwsm")" == 'user-owned file' ]] \
|
||||
|| fail 'runtime-link repair replaced a regular file'
|
||||
[[ -d "$config_home/vicinae" && ! -L "$config_home/vicinae" ]] \
|
||||
|| fail 'runtime-link repair replaced a user-owned directory'
|
||||
|| fail 'absent quickshell link was not created'
|
||||
[[ -L "$config_home/uwsm" && "$(readlink "$config_home/uwsm")" == "$repair_root/config/dot/uwsm" ]] \
|
||||
|| fail 'provably Panama-owned stale link was not repaired'
|
||||
[[ -L "$config_home/vicinae" && "$(readlink "$config_home/vicinae")" == "$fixture/external-broken-link" ]] \
|
||||
|| fail 'external broken symlink was replaced'
|
||||
[[ -L "$config_home/not-panama" && "$(readlink "$config_home/not-panama")" == "$fixture/untouched" ]] \
|
||||
|| fail 'runtime-link repair touched an unauthored link name'
|
||||
[[ ! -s "$repair_log" ]] || fail 'runtime-link repair launched a process'
|
||||
|
||||
# Caffeine repair parses exact authored metadata, keeps the first valid lock,
|
||||
# and releases only later exact matches.
|
||||
# Regular files and directories also remain untouched.
|
||||
rm "$config_home/vicinae"
|
||||
rm "$config_home/uwsm"
|
||||
printf 'user-owned file\n' >"$config_home/uwsm"
|
||||
mkdir "$config_home/vicinae"
|
||||
invoke_repair panama.runtime-links
|
||||
[[ "$repair_status" == 1 ]] || fail 'file/directory blockers did not make repair incomplete'
|
||||
[[ -f "$config_home/uwsm" && "$(<"$config_home/uwsm")" == 'user-owned file' ]] \
|
||||
|| fail 'runtime-link repair replaced a regular file'
|
||||
[[ -d "$config_home/vicinae" && ! -L "$config_home/vicinae" ]] \
|
||||
|| fail 'runtime-link repair replaced a user-owned directory'
|
||||
|
||||
# An injected exchange failure occurs after the authored candidate symlink is
|
||||
# made; the original link must still be intact.
|
||||
/usr/bin/python3 - "$doctor" "$repair_root" "$fixture/atomic-config" <<'PY' \
|
||||
|| fail 'atomic replacement failure did not preserve the original link'
|
||||
import importlib.util
|
||||
import importlib.machinery
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
doctor_path, root_text, config_text = sys.argv[1:]
|
||||
loader = importlib.machinery.SourceFileLoader("panama_doctor_contract", doctor_path)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
loader.exec_module(module)
|
||||
root = Path(root_text)
|
||||
config_home = Path(config_text)
|
||||
config_home.mkdir(parents=True)
|
||||
destination = config_home / "hypr"
|
||||
original = root / "config/dot/quickshell"
|
||||
destination.symlink_to(original, target_is_directory=True)
|
||||
config = module.DoctorConfig(root, config_home.parent, config_home, config_home.parent / "state", config_home.parent / "runtime", "", 0.2)
|
||||
real_exchange = module.rename_exchange
|
||||
module.rename_exchange = lambda source, target: (_ for _ in ()).throw(OSError("fixture exchange failure"))
|
||||
try:
|
||||
result = module.repair_runtime_links(config)
|
||||
finally:
|
||||
module.rename_exchange = real_exchange
|
||||
assert result.exit_code == 1
|
||||
assert destination.is_symlink()
|
||||
assert os.readlink(destination) == str(original)
|
||||
assert not list(config_home.glob(".panama-link-*"))
|
||||
PY
|
||||
|
||||
# A deterministic swap at the ownership/replacement boundary must be detected
|
||||
# from the exchanged-out object and rolled back, preserving the external link.
|
||||
/usr/bin/python3 - "$doctor" "$repair_root" "$fixture/toctou-config" "$fixture/external-race-target" <<'PY' \
|
||||
|| fail 'runtime-link exchange did not restore a boundary-swapped external link'
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
doctor_path, root_text, config_text, external_text = sys.argv[1:]
|
||||
loader = importlib.machinery.SourceFileLoader("panama_doctor_toctou", doctor_path)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
loader.exec_module(module)
|
||||
root = Path(root_text)
|
||||
config_home = Path(config_text)
|
||||
config_home.mkdir(parents=True)
|
||||
for name, relative in module.RUNTIME_LINK_TARGETS:
|
||||
(config_home / name).symlink_to(root / relative, target_is_directory=True)
|
||||
destination = config_home / "uwsm"
|
||||
destination.unlink()
|
||||
destination.symlink_to(root / "config/dot/quickshell", target_is_directory=True)
|
||||
external = Path(external_text)
|
||||
real_exchange = module.rename_exchange
|
||||
first = True
|
||||
|
||||
def race_exchange(candidate, target):
|
||||
global first
|
||||
if first:
|
||||
first = False
|
||||
target.unlink()
|
||||
target.symlink_to(external, target_is_directory=True)
|
||||
real_exchange(candidate, target)
|
||||
|
||||
module.rename_exchange = race_exchange
|
||||
config = module.DoctorConfig(root, config_home.parent, config_home, config_home.parent / "state", config_home.parent / "runtime", "", 0.2)
|
||||
try:
|
||||
result = module.repair_runtime_links(config)
|
||||
finally:
|
||||
module.rename_exchange = real_exchange
|
||||
assert result.exit_code == 1
|
||||
assert destination.is_symlink()
|
||||
assert os.readlink(destination) == str(external)
|
||||
assert not list(config_home.glob(".panama-link-*"))
|
||||
PY
|
||||
|
||||
# Caffeine repair deduplicates rows, pins each distinct duplicate with a
|
||||
# pidfd, revalidates authored metadata, and signals only the duplicate.
|
||||
/usr/bin/sleep 30 &
|
||||
preserved_pid=$!
|
||||
child_pids+=("$preserved_pid")
|
||||
/usr/bin/sleep 30 &
|
||||
duplicate_pid=$!
|
||||
child_pids+=("$duplicate_pid")
|
||||
printf '%s %s\n' "$preserved_pid" "$duplicate_pid" >"$runtime_dir/caffeine-pids"
|
||||
printf 'dedupe\n' >"$runtime_dir/caffeine-mode"
|
||||
rm -f "$runtime_dir/caffeine-list-count"
|
||||
: >"$repair_log"
|
||||
invoke_repair panama.caffeine
|
||||
[[ "$repair_status" == 0 ]] || fail "Caffeine repair returned $repair_status"
|
||||
assert_repair_result panama.caffeine true 0
|
||||
expected_caffeine=$'systemd-inhibit|--list|--no-pager|--no-legend\nkill|--|4102'
|
||||
expected_caffeine=$'systemd-inhibit|--list|--no-pager|--no-legend\nsystemd-inhibit|--list|--no-pager|--no-legend'
|
||||
[[ "$(<"$repair_log")" == "$expected_caffeine" ]] \
|
||||
|| fail "Caffeine repair did not preserve/filter exact inhibitors: $(<"$repair_log")"
|
||||
kill -0 "$preserved_pid" >/dev/null 2>&1 || fail 'repeated inhibitor rows killed the preserved process'
|
||||
for _ in $(seq 1 40); do
|
||||
kill -0 "$duplicate_pid" >/dev/null 2>&1 || break
|
||||
sleep 0.05
|
||||
done
|
||||
! kill -0 "$duplicate_pid" >/dev/null 2>&1 || fail 'distinct duplicate inhibitor was not terminated'
|
||||
|
||||
# Changed second-list metadata invalidates the candidate before any signal.
|
||||
/usr/bin/sleep 30 &
|
||||
altered_preserved=$!
|
||||
child_pids+=("$altered_preserved")
|
||||
/usr/bin/sleep 30 &
|
||||
altered_duplicate=$!
|
||||
child_pids+=("$altered_duplicate")
|
||||
printf '%s %s\n' "$altered_preserved" "$altered_duplicate" >"$runtime_dir/caffeine-pids"
|
||||
printf 'altered\n' >"$runtime_dir/caffeine-mode"
|
||||
rm -f "$runtime_dir/caffeine-list-count"
|
||||
invoke_repair panama.caffeine
|
||||
[[ "$repair_status" == 1 ]] || fail 'altered inhibitor metadata was not safely refused'
|
||||
assert_repair_result panama.caffeine true 1
|
||||
kill -0 "$altered_preserved" >/dev/null 2>&1 || fail 'metadata refusal signaled the preserved process'
|
||||
kill -0 "$altered_duplicate" >/dev/null 2>&1 || fail 'metadata refusal signaled the candidate process'
|
||||
|
||||
# Changing metadata on the preserved row is also a full-identity mismatch,
|
||||
# even though every duplicate PID remains present.
|
||||
/usr/bin/sleep 30 &
|
||||
preserve_changed_keep=$!
|
||||
child_pids+=("$preserve_changed_keep")
|
||||
/usr/bin/sleep 30 &
|
||||
preserve_changed_duplicate=$!
|
||||
child_pids+=("$preserve_changed_duplicate")
|
||||
printf '%s %s\n' "$preserve_changed_keep" "$preserve_changed_duplicate" >"$runtime_dir/caffeine-pids"
|
||||
printf 'preserve-altered\n' >"$runtime_dir/caffeine-mode"
|
||||
rm -f "$runtime_dir/caffeine-list-count"
|
||||
invoke_repair panama.caffeine
|
||||
[[ "$repair_status" == 1 ]] || fail 'preserved-row metadata change was not safely refused'
|
||||
assert_repair_result panama.caffeine true 1
|
||||
kill -0 "$preserve_changed_keep" >/dev/null 2>&1 || fail 'preserved-row mismatch signaled the preserved process'
|
||||
kill -0 "$preserve_changed_duplicate" >/dev/null 2>&1 || fail 'preserved-row mismatch signaled the duplicate process'
|
||||
|
||||
# A repeated exact row disappearing between lists changes multiplicity and is
|
||||
# refused before signaling any pinned duplicate.
|
||||
/usr/bin/sleep 30 &
|
||||
multiplicity_keep=$!
|
||||
child_pids+=("$multiplicity_keep")
|
||||
/usr/bin/sleep 30 &
|
||||
multiplicity_duplicate=$!
|
||||
child_pids+=("$multiplicity_duplicate")
|
||||
printf '%s %s\n' "$multiplicity_keep" "$multiplicity_duplicate" >"$runtime_dir/caffeine-pids"
|
||||
printf 'multiplicity\n' >"$runtime_dir/caffeine-mode"
|
||||
rm -f "$runtime_dir/caffeine-list-count"
|
||||
invoke_repair panama.caffeine
|
||||
[[ "$repair_status" == 1 ]] || fail 'inhibitor row multiplicity change was not safely refused'
|
||||
assert_repair_result panama.caffeine true 1
|
||||
kill -0 "$multiplicity_keep" >/dev/null 2>&1 || fail 'multiplicity mismatch signaled the preserved process'
|
||||
kill -0 "$multiplicity_duplicate" >/dev/null 2>&1 || fail 'multiplicity mismatch signaled the duplicate process'
|
||||
|
||||
# The production pidfd release function preflights every candidate before any
|
||||
# SIGTERM. A refused second preflight leaves both disposable children alive.
|
||||
/usr/bin/sleep 30 &
|
||||
preflight_first=$!
|
||||
child_pids+=("$preflight_first")
|
||||
/usr/bin/sleep 30 &
|
||||
preflight_second=$!
|
||||
child_pids+=("$preflight_second")
|
||||
/usr/bin/python3 - "$doctor" "$preflight_first" "$preflight_second" <<'PY' \
|
||||
|| fail 'pidfd preflight failure signaled a disposable duplicate'
|
||||
import errno
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
doctor_path = sys.argv[1]
|
||||
pids = [int(value) for value in sys.argv[2:]]
|
||||
loader = importlib.machinery.SourceFileLoader("panama_doctor_preflight", doctor_path)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
loader.exec_module(module)
|
||||
pidfds = [os.pidfd_open(pid, 0) for pid in pids]
|
||||
calls = []
|
||||
|
||||
def sender(pidfd, sig, siginfo, flags):
|
||||
calls.append(sig)
|
||||
if sig == 0 and pidfd == pidfds[1]:
|
||||
raise PermissionError(errno.EPERM, "fixture preflight refusal")
|
||||
signal.pidfd_send_signal(pidfd, sig, siginfo, flags)
|
||||
|
||||
try:
|
||||
outcome = module.signal_caffeine_pidfds(pidfds, sender)
|
||||
finally:
|
||||
for pidfd in pidfds:
|
||||
os.close(pidfd)
|
||||
assert outcome == "preflight-failed"
|
||||
assert calls == [0, 0]
|
||||
for pid in pids:
|
||||
os.kill(pid, 0)
|
||||
PY
|
||||
kill -0 "$preflight_first" >/dev/null 2>&1 || fail 'preflight refusal killed the first duplicate'
|
||||
kill -0 "$preflight_second" >/dev/null 2>&1 || fail 'preflight refusal killed the second duplicate'
|
||||
|
||||
# A candidate that disappears after pidfd acquisition and second-list request
|
||||
# is a safe failure; an unrelated disposable process must remain untouched.
|
||||
/usr/bin/sleep 30 &
|
||||
unrelated_pid=$!
|
||||
child_pids+=("$unrelated_pid")
|
||||
(
|
||||
/usr/bin/sleep 30 &
|
||||
disappearing_pid=$!
|
||||
trap 'kill "$disappearing_pid" >/dev/null 2>&1 || true; wait "$disappearing_pid" >/dev/null 2>&1 || true' EXIT
|
||||
printf '%s\n' "$disappearing_pid" >"$runtime_dir/disappearing-pid"
|
||||
while [[ ! -e "$runtime_dir/release-disappearing-pid" ]]; do
|
||||
/usr/bin/sleep 0.01
|
||||
done
|
||||
kill "$disappearing_pid"
|
||||
wait "$disappearing_pid" >/dev/null 2>&1 || true
|
||||
trap - EXIT
|
||||
) &
|
||||
disappearance_controller=$!
|
||||
child_pids+=("$disappearance_controller")
|
||||
for _ in $(seq 1 100); do
|
||||
[[ -s "$runtime_dir/disappearing-pid" ]] && break
|
||||
sleep 0.01
|
||||
done
|
||||
[[ -s "$runtime_dir/disappearing-pid" ]] || fail 'disappearing PID fixture did not start'
|
||||
disappearing_pid="$(<"$runtime_dir/disappearing-pid")"
|
||||
printf '%s %s\n' "$altered_preserved" "$disappearing_pid" >"$runtime_dir/caffeine-pids"
|
||||
printf 'disappear\n' >"$runtime_dir/caffeine-mode"
|
||||
rm -f "$runtime_dir/caffeine-list-count"
|
||||
invoke_repair panama.caffeine
|
||||
[[ "$repair_status" == 1 ]] || fail 'disappeared inhibitor PID was not safely refused'
|
||||
assert_repair_result panama.caffeine true 1
|
||||
wait "$disappearance_controller"
|
||||
kill -0 "$unrelated_pid" >/dev/null 2>&1 || fail 'PID disappearance signaled an unrelated process'
|
||||
|
||||
# Rejected IDs are complete JSON, exit 2, and cause neither a process launch
|
||||
# nor a filesystem mutation.
|
||||
fixture_state() {
|
||||
find "$config_home" -mindepth 1 -printf '%P|%y|%l\n' | sort | sha256sum | awk '{print $1}'
|
||||
/usr/bin/python3 - "$fixture" <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(root.rglob("*"), key=lambda item: os.fsencode(str(item.relative_to(root)))):
|
||||
relative = os.fsencode(str(path.relative_to(root)))
|
||||
metadata = path.lstat()
|
||||
digest.update(relative + b"\0" + oct(stat.S_IMODE(metadata.st_mode)).encode() + b"\0")
|
||||
if path.is_symlink():
|
||||
digest.update(b"link\0" + os.fsencode(os.readlink(path)) + b"\0")
|
||||
elif path.is_file():
|
||||
digest.update(b"file\0" + hashlib.sha256(path.read_bytes()).digest())
|
||||
elif path.is_dir():
|
||||
digest.update(b"dir\0")
|
||||
else:
|
||||
digest.update(b"other\0")
|
||||
print(digest.hexdigest())
|
||||
PY
|
||||
}
|
||||
for rejected_id in unknown.check integration.home-assistant input.brightness \
|
||||
desktop.notifications ../../escape 'desktop.vicinae;touch injected'; do
|
||||
|
||||
@@ -85,8 +85,13 @@ fi
|
||||
state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)"
|
||||
source_config_path="$repo_dir/config/dot/quickshell"
|
||||
config_path="$state_home/quickshell"
|
||||
harness="$config_path/settings-pages-harness.qml"
|
||||
test_bin="$state_home/bin"
|
||||
shell_log="$state_home/quickshell.log"
|
||||
production_config_path="$HOME/.config/quickshell/shell.qml"
|
||||
harness_pid=""
|
||||
harness_shell_id=""
|
||||
production_before=""
|
||||
|
||||
cleanup_bootstrap() {
|
||||
rm -rf "$state_home"
|
||||
@@ -95,6 +100,21 @@ trap cleanup_bootstrap EXIT
|
||||
|
||||
mkdir -p "$test_bin"
|
||||
cp -a "$source_config_path" "$config_path"
|
||||
python3 - "$config_path/shell.qml" "$harness" "$$" <<'PY'
|
||||
import sys
|
||||
|
||||
source_path, harness_path, identity = sys.argv[1:]
|
||||
source = open(source_path, encoding="utf-8").read()
|
||||
needle = "ShellRoot {\n"
|
||||
replacement = (
|
||||
needle
|
||||
+ f' readonly property string settingsPagesHarnessIdentity: "settings-pages-contract-{identity}"\n'
|
||||
)
|
||||
if source.count(needle) != 1:
|
||||
raise SystemExit("shell.qml does not have exactly one ShellRoot")
|
||||
with open(harness_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(source.replace(needle, replacement, 1))
|
||||
PY
|
||||
|
||||
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
@@ -138,52 +158,141 @@ EOF
|
||||
chmod +x "$test_bin/flatpak"
|
||||
|
||||
qs_for_test() {
|
||||
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
|
||||
qs -p "$config_path" "$@"
|
||||
if [[ "${1:-}" == "ipc" && "$harness_pid" =~ ^[0-9]+$ ]]; then
|
||||
PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \
|
||||
qs -p "$harness" ipc --pid "$harness_pid" "${@:2}"
|
||||
else
|
||||
PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \
|
||||
qs -p "$harness" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_test_shell() {
|
||||
qs_for_test kill >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 80); do
|
||||
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
|
||||
&& ! qs_for_test ipc show >/dev/null 2>&1; then
|
||||
return 0
|
||||
instances_for_path() {
|
||||
local expected_path="$1" listing
|
||||
|
||||
listing="$(qs list --all 2>/dev/null)" || return 1
|
||||
awk -v expected="$expected_path" '
|
||||
/^Instance / { pid = ""; shell_id = "" }
|
||||
/^[[:space:]]*Process ID:/ { pid = $3 }
|
||||
/^[[:space:]]*Shell ID:/ { shell_id = $3 }
|
||||
/^[[:space:]]*Config path:/ {
|
||||
path = $0
|
||||
sub(/^[[:space:]]*Config path: /, "", path)
|
||||
if (path == expected && pid ~ /^[0-9]+$/ && shell_id != "")
|
||||
print pid "|" shell_id
|
||||
}
|
||||
' <<<"$listing"
|
||||
}
|
||||
|
||||
harness_identity_matches() {
|
||||
local current
|
||||
|
||||
[[ "$harness_pid" =~ ^[0-9]+$ && -n "$harness_shell_id" ]] || return 1
|
||||
current="$(instances_for_path "$harness")" || return 1
|
||||
grep -Fxq "$harness_pid|$harness_shell_id" <<<"$current"
|
||||
}
|
||||
|
||||
production_is_preserved() {
|
||||
local current record pid shell_id
|
||||
|
||||
current="$(instances_for_path "$production_config_path")" || return 1
|
||||
while IFS='|' read -r pid shell_id; do
|
||||
[[ -n "$pid" ]] || continue
|
||||
kill -0 "$pid" >/dev/null 2>&1 || return 1
|
||||
record="$pid|$shell_id"
|
||||
grep -Fxq "$record" <<<"$current" || return 1
|
||||
done <<<"$production_before"
|
||||
}
|
||||
|
||||
stop_harness() {
|
||||
local remaining
|
||||
|
||||
if harness_identity_matches; then
|
||||
kill "$harness_pid" >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 80); do
|
||||
! kill -0 "$harness_pid" >/dev/null 2>&1 && break
|
||||
sleep 0.05
|
||||
done
|
||||
if kill -0 "$harness_pid" >/dev/null 2>&1 && harness_identity_matches; then
|
||||
kill -KILL "$harness_pid" >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 20); do
|
||||
! kill -0 "$harness_pid" >/dev/null 2>&1 && break
|
||||
sleep 0.05
|
||||
done
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
return 1
|
||||
fi
|
||||
remaining="$(instances_for_path "$harness")" || return 1
|
||||
harness_pid=""
|
||||
harness_shell_id=""
|
||||
[[ -z "$remaining" ]]
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
qs_for_test ipc call settings close >/dev/null 2>&1 || true
|
||||
if stop_test_shell; then
|
||||
local cleanup_ok=0
|
||||
|
||||
stop_harness || cleanup_ok=1
|
||||
production_is_preserved || cleanup_ok=1
|
||||
if (( cleanup_ok == 0 )); then
|
||||
rm -rf "$state_home"
|
||||
else
|
||||
printf 'settings pages contract: branch shell did not stop; retained %s\n' \
|
||||
printf 'settings pages contract: isolated harness cleanup failed; retained %s\n' \
|
||||
"$state_home" >&2
|
||||
fi
|
||||
return "$cleanup_ok"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
start_test_shell() {
|
||||
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
|
||||
local harness_instances production_pid production_shell_id
|
||||
|
||||
harness_instances="$(instances_for_path "$harness")" \
|
||||
|| fail 'could not inspect Quickshell instances before starting the runtime harness'
|
||||
[[ -z "$harness_instances" ]] \
|
||||
|| fail 'an unexpected process already uses the runtime harness path'
|
||||
for _attempt in 1 2; do
|
||||
qs_for_test --daemonize >"$shell_log" 2>&1
|
||||
for _ in $(seq 1 80); do
|
||||
if qs_for_test ipc show 2>/dev/null | rg '^target settings$' >/dev/null; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
harness_instances="$(instances_for_path "$harness")" \
|
||||
|| fail 'could not inspect the runtime harness instance'
|
||||
[[ -n "$harness_instances" ]] && break
|
||||
sleep 0.05
|
||||
done
|
||||
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
|
||||
if [[ "$(wc -l <<<"$harness_instances")" == 1 && -n "$harness_instances" ]]; then
|
||||
IFS='|' read -r harness_pid harness_shell_id <<<"$harness_instances"
|
||||
[[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
||||
|| fail 'runtime harness did not expose a numeric PID'
|
||||
|
||||
while IFS='|' read -r production_pid production_shell_id; do
|
||||
[[ -n "$production_pid" ]] || continue
|
||||
[[ "$harness_shell_id" != "$production_shell_id" ]] || {
|
||||
stop_harness
|
||||
fail 'runtime harness shares a Shell ID with production'
|
||||
}
|
||||
done <<<"$production_before"
|
||||
production_is_preserved || {
|
||||
stop_harness
|
||||
fail 'production changed before isolated page routing began'
|
||||
}
|
||||
|
||||
for _ in $(seq 1 80); do
|
||||
if qs_for_test ipc show 2>/dev/null | rg '^target settings$' >/dev/null; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
fi
|
||||
stop_harness || fail 'failed runtime harness attempt did not stop cleanly'
|
||||
done
|
||||
sed -n '1,200p' "$shell_log" >&2
|
||||
fail 'isolated branch shell did not start'
|
||||
}
|
||||
|
||||
production_before="$(instances_for_path "$production_config_path")" \
|
||||
|| fail 'could not list Quickshell instances for the production baseline'
|
||||
production_is_preserved || fail 'could not capture a stable production instance set'
|
||||
start_test_shell
|
||||
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
||||
shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
|
||||
shell_pid="$harness_pid"
|
||||
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
|
||||
|
||||
pages=(home appearance displays connectivity home-phone desktop sound notifications screen-intelligence shortcuts services about)
|
||||
@@ -218,6 +327,8 @@ intelligence_desktop_file="$HOME/.local/share/applications/panama-screen-intelli
|
||||
desktop-file-validate "$intelligence_desktop_file" >/dev/null || fail 'Screen Intelligence desktop entry is invalid'
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
cleanup || fail 'runtime harness did not stop without disturbing production'
|
||||
[[ ! -e "$state_home" ]] || fail 'temporary Settings state was not removed after shell exit'
|
||||
printf 'settings pages contract: PASS\n'
|
||||
production_pids="$(cut -d'|' -f1 <<<"$production_before" | paste -sd, -)"
|
||||
[[ -n "$production_pids" ]] || production_pids="none"
|
||||
printf 'settings pages contract: PASS (production PIDs preserved: %s)\n' "$production_pids"
|
||||
|
||||
Reference in New Issue
Block a user