aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xarchsetup98
-rw-r--r--docs/prototypes/gallery-widget.el25
-rwxr-xr-xscripts/testing/debug-vm.sh4
-rwxr-xr-xscripts/testing/lib/vm-utils.sh21
-rwxr-xr-xscripts/testing/run-net-scenarios.sh8
-rwxr-xr-xscripts/testing/run-test-baremetal.sh17
-rwxr-xr-xscripts/testing/run-test.sh18
-rw-r--r--scripts/testing/tests/test_desktop.py8
-rw-r--r--tests/gallery-tokens/test_gen_tokens.py10
-rw-r--r--tests/gallery-widgets/test-gallery-widget.el58
-rw-r--r--tests/installer-steps/test_ensure_nvme_early_module.py95
-rw-r--r--tests/installer-steps/test_grub_cmdline.py121
-rw-r--r--tests/installer-steps/test_pacman_hook_order.py72
-rw-r--r--tests/net-scenarios/test_run_net_scenarios.py102
-rw-r--r--tests/vm-framework/test_vm_utils.py130
-rw-r--r--todo.org174
-rw-r--r--working/hyprland-layoutmsg-crash/crash-report-2026-03-07-pid1812.txt184
-rw-r--r--working/hyprland-layoutmsg-crash/crash-report-2026-07-20-pid249443.txt166
-rw-r--r--working/hyprland-layoutmsg-crash/issue-draft.md43
-rw-r--r--working/hyprland-layoutmsg-crash/log-excerpts-2026-07-20-session.txt63
20 files changed, 1289 insertions, 128 deletions
diff --git a/archsetup b/archsetup
index f944334..be5b3f4 100755
--- a/archsetup
+++ b/archsetup
@@ -3017,18 +3017,33 @@ tighten_efi_permissions() {
}
-add_nvme_early_module() {
- # Add nvme module for early loading on NVMe systems
- # Ensures NVMe devices are available when ZFS/other hooks try to access them
- if has_nvme_drives; then
- action="adding nvme to mkinitcpio MODULES for early loading" && display "task" "$action"
- backup_system_file /etc/mkinitcpio.conf
- if grep -q "^MODULES=()" /etc/mkinitcpio.conf; then
- sed -i 's/^MODULES=()/MODULES=(nvme)/' /etc/mkinitcpio.conf
- elif grep -q "^MODULES=(" /etc/mkinitcpio.conf && ! grep -q "nvme" /etc/mkinitcpio.conf; then
- sed -i '/^MODULES=(/ s/)/ nvme)/' /etc/mkinitcpio.conf
- fi
+# Ensure nvme loads early on NVMe systems so the devices exist when ZFS/other
+# hooks look for them. The presence check is scoped to the MODULES line (a
+# comment or nvme_tcp elsewhere must not mask a missing entry), and the
+# initramfs rebuilds whenever the file changed -- unconditionally, because the
+# only nearby mkinitcpio -P used to run behind `if ! is_zfs_root`, which left
+# ZFS-root machines without the early-load hardening compiled in. Conf path is
+# a positional arg defaulting to the system file so the step runs against
+# fixtures.
+ensure_nvme_early_module() {
+ local conf="${1:-/etc/mkinitcpio.conf}"
+ has_nvme_drives || return 0
+ action="adding nvme to mkinitcpio MODULES for early loading" && display "task" "$action"
+ backup_system_file "$conf"
+ if grep -q "^MODULES=()" "$conf"; then
+ sed -i 's/^MODULES=()/MODULES=(nvme)/' "$conf"
+ elif grep -q "^MODULES=(" "$conf" && \
+ ! grep -qE '^MODULES=\(.*\bnvme\b' "$conf"; then
+ sed -i '/^MODULES=(/ s/)/ nvme)/' "$conf"
+ else
+ return 0
fi
+ mkinitcpio -P >> "$logfile" 2>&1 \
+ || error_warn "rebuilding initramfs after adding nvme module" "$?"
+}
+
+add_nvme_early_module() {
+ ensure_nvme_early_module
action="removing distro and date/time from initial screen" && display "task" "$action"
(: >/etc/issue) || error_warn "$action" "$?"
@@ -3148,6 +3163,64 @@ trim_firmware() {
}
+# Merge an existing GRUB cmdline with archsetup's desired tokens. Every
+# existing token survives (this is what keeps cryptdevice=/resume=/zfs= and
+# any other boot-critical parameter alive); where both set the same key,
+# archsetup's value wins. Args: $1 existing value, $2 desired tokens. Prints
+# the merged value.
+merge_grub_cmdline() {
+ local existing="$1" desired="$2"
+ local out="" tok key dtok dkey replaced
+ for tok in $existing; do
+ key="${tok%%=*}"
+ replaced=""
+ for dtok in $desired; do
+ dkey="${dtok%%=*}"
+ [ "$key" = "$dkey" ] && { replaced="$dtok"; break; }
+ done
+ out="$out ${replaced:-$tok}"
+ done
+ for dtok in $desired; do
+ dkey="${dtok%%=*}"
+ case " $out " in
+ *" $dkey "*|*" $dkey="*) ;;
+ *) out="$out $dtok" ;;
+ esac
+ done
+ printf '%s\n' "${out# }"
+}
+
+# Rewrite GRUB_CMDLINE_LINUX_DEFAULT in $1 (default /etc/default/grub) as the
+# merge of its current value and archsetup's tokens. The old code replaced the
+# whole line with a fixed string, which dropped a base install's boot-critical
+# parameters and let grub-mkconfig bake an unbootable config. A safety check
+# refuses to write if any existing token's key would vanish from the merge.
+update_grub_cmdline() {
+ local file="${1:-/etc/default/grub}"
+ local desired="rw loglevel=2 rd.systemd.show_status=auto rd.udev.log_level=2 nvme.noacpi=1 mem_sleep_default=deep nowatchdog random.trust_cpu=off quiet splash"
+ local existing merged tok key
+ existing=$(sed -n 's/^[[:space:]]*GRUB_CMDLINE_LINUX_DEFAULT="\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' "$file" | head -1)
+ merged=$(merge_grub_cmdline "$existing" "$desired")
+ for tok in $existing; do
+ key="${tok%%=*}"
+ case " $merged " in
+ *" $key "*|*" $key="*) ;;
+ *) error_warn "GRUB cmdline merge would drop '$tok'; leaving $file untouched" "1"
+ return 1 ;;
+ esac
+ done
+ if grep -q "^[[:space:]]*GRUB_CMDLINE_LINUX_DEFAULT=" "$file"; then
+ awk -v v="$merged" '
+ /^[[:space:]]*GRUB_CMDLINE_LINUX_DEFAULT=/ {
+ print "GRUB_CMDLINE_LINUX_DEFAULT=\"" v "\""; next
+ }
+ { print }' "$file" > "$file.archsetup-tmp" \
+ && mv "$file.archsetup-tmp" "$file"
+ else
+ printf 'GRUB_CMDLINE_LINUX_DEFAULT="%s"\n' "$merged" >> "$file"
+ fi
+}
+
configure_grub() {
# GRUB: reset timeouts, adjust log levels, larger menu for HiDPI screens, and show splashscreen
# Note: nvme.noacpi=1 disables NVMe ACPI power management to prevent freezes on some drives.
@@ -3156,12 +3229,13 @@ configure_grub() {
action="configuring boot menu for silence and bootsplash" && display "task" "$action"
if [ -f /etc/default/grub ]; then
action="resetting timeouts and adjusting log levels on grub boot" && display "task" "$action"
+ backup_system_file /etc/default/grub
sed -i "s/.*GRUB_TIMEOUT=.*/GRUB_TIMEOUT=2/g" /etc/default/grub
sed -i "s/.*GRUB_DEFAULT=.*/GRUB_DEFAULT=0/g" /etc/default/grub
sed -i 's/.*GRUB_TERMINAL_OUTPUT=console/GRUB_TERMINAL_OUTPUT=gfxterm/' /etc/default/grub
sed -i 's/.*GRUB_GFXMODE=auto/GRUB_GFXMODE=1024x768/' /etc/default/grub
sed -i "s/.*GRUB_RECORDFAIL_TIMEOUT=.*/GRUB_RECORDFAIL_TIMEOUT=2/g" /etc/default/grub
- sed -i "s/.*GRUB_CMDLINE_LINUX_DEFAULT=.*/GRUB_CMDLINE_LINUX_DEFAULT=\"rw loglevel=2 rd.systemd.show_status=auto rd.udev.log_level=2 nvme.noacpi=1 mem_sleep_default=deep nowatchdog random.trust_cpu=off quiet splash\"/g" /etc/default/grub
+ update_grub_cmdline /etc/default/grub
fi
# Regenerate GRUB config after all modifications
diff --git a/docs/prototypes/gallery-widget.el b/docs/prototypes/gallery-widget.el
index 5d69148..5a80e59 100644
--- a/docs/prototypes/gallery-widget.el
+++ b/docs/prototypes/gallery-widget.el
@@ -24,9 +24,16 @@
(require 'svg)
(require 'dom)
+(require 'cl-lib)
-(load (expand-file-name "gallery-tokens.el"
- (file-name-directory (or load-file-name buffer-file-name)))
+(defun gallery-widget--source-dir ()
+ "Directory holding this module's source (and gallery-tokens.el).
+Falls back to `default-directory' when evaluated outside a load and
+outside a file buffer, where both location variables are nil."
+ (let ((src (or load-file-name buffer-file-name)))
+ (if src (file-name-directory src) default-directory)))
+
+(load (expand-file-name "gallery-tokens.el" (gallery-widget--source-dir))
nil t)
(defun gallery-widget-token (name)
@@ -43,11 +50,17 @@ a typo'd token must fail loudly, not render black."
(defconst gallery-widget--gauge-max-deg 60.0
"Needle angle at value 100.")
-(defun gallery-widget--needle-angle (value)
- "Map VALUE (0-100, clamped) onto the gauge's needle angle in degrees."
+(defun gallery-widget--clamp-value (value)
+ "Validate VALUE is a number and clamp it to the gauge's 0-100 range.
+One clamp shared by the needle angle and the readout, so the two halves
+of the widget can never disagree at an out-of-range input."
(unless (numberp value)
(error "Gauge value must be a number, got %S" value))
- (let ((v (min 100.0 (max 0.0 (float value)))))
+ (min 100.0 (max 0.0 (float value))))
+
+(defun gallery-widget--needle-angle (value)
+ "Map VALUE (0-100, clamped) onto the gauge's needle angle in degrees."
+ (let ((v (gallery-widget--clamp-value value)))
(+ gallery-widget--gauge-min-deg
(* (/ v 100.0)
(- gallery-widget--gauge-max-deg gallery-widget--gauge-min-deg)))))
@@ -77,6 +90,8 @@ Positive angles swing right, matching the gauge's needle travel."
Geometry mirrors the web card: 96px-wide semicircular dial, pivot at its
bottom center, 40px needle sweeping -60..+60 degrees, readout below."
(let* ((w 96) (h 64) (cx 48.0) (cy 48.0)
+ ;; clamp once; the angle and the readout render the same value
+ (value (gallery-widget--clamp-value value))
(angle (gallery-widget--needle-angle value))
(tip (gallery-widget--polar cx cy 40.0 angle))
(svg (svg-create w h :viewBox (format "0 0 %d %d" w h))))
diff --git a/scripts/testing/debug-vm.sh b/scripts/testing/debug-vm.sh
index b0fa2b9..d05ed59 100755
--- a/scripts/testing/debug-vm.sh
+++ b/scripts/testing/debug-vm.sh
@@ -46,7 +46,6 @@ fi
# Configuration
TIMESTAMP=$(date +'%Y%m%d-%H%M%S')
VM_IMAGES_DIR="$PROJECT_ROOT/vm-images"
-BASE_DISK="$VM_IMAGES_DIR/archsetup-base.qcow2"
ROOT_PASSWORD="archsetup"
OVERLAY_DISK=""
@@ -54,6 +53,9 @@ OVERLAY_DISK=""
LOGFILE="/tmp/debug-vm-$TIMESTAMP.log"
init_logging "$LOGFILE"
init_vm_paths "$VM_IMAGES_DIR"
+# The profile-correct base image comes from init_vm_paths; a hardcoded
+# archsetup-base.qcow2 booted the btrfs base under FS_PROFILE=zfs.
+BASE_DISK="$DISK_PATH"
cleanup_debug() {
if declare -f vm_is_running >/dev/null 2>&1 && vm_is_running; then
diff --git a/scripts/testing/lib/vm-utils.sh b/scripts/testing/lib/vm-utils.sh
index b85e773..b6686e9 100755
--- a/scripts/testing/lib/vm-utils.sh
+++ b/scripts/testing/lib/vm-utils.sh
@@ -66,9 +66,12 @@ init_vm_paths() {
# let a zfs run's ZFSBootMenu entries clobber the btrfs GRUB entry, leaving
# the btrfs base unbootable (no removable ESP fallback to recover from).
OVMF_VARS="$VM_IMAGES_DIR/OVMF_VARS${img_suffix}.fd"
- PID_FILE="$VM_IMAGES_DIR/qemu.pid"
- MONITOR_SOCK="$VM_IMAGES_DIR/qemu-monitor.sock"
- SERIAL_LOG="$VM_IMAGES_DIR/qemu-serial.log"
+ # Runtime paths carry the same suffix: a concurrent btrfs and zfs run
+ # sharing one PID file let the second run's stop logic kill the first
+ # run's VM, and both truncated the same serial log.
+ PID_FILE="$VM_IMAGES_DIR/qemu${img_suffix}.pid"
+ MONITOR_SOCK="$VM_IMAGES_DIR/qemu-monitor${img_suffix}.sock"
+ SERIAL_LOG="$VM_IMAGES_DIR/qemu-serial${img_suffix}.log"
mkdir -p "$VM_IMAGES_DIR"
}
@@ -287,6 +290,18 @@ kill_qemu() {
pid=$(cat "$PID_FILE" 2>/dev/null)
if [ -n "$pid" ]; then
kill -9 "$pid" 2>/dev/null || true
+ # Wait for the process to actually die before returning: the
+ # force-kill fallback is followed by restore_snapshot, and
+ # qemu-img refuses a qcow2 the dying qemu still holds locked.
+ # `wait` reaps it when it's our child; otherwise poll until the
+ # /proc entry is gone or a zombie (fds released either way).
+ wait "$pid" 2>/dev/null || true
+ local i state
+ for i in $(seq 1 50); do
+ state=$(awk '{print $3}' "/proc/$pid/stat" 2>/dev/null) || state=""
+ [ -z "$state" ] || [ "$state" = "Z" ] && break
+ sleep 0.1
+ done
fi
fi
_cleanup_qemu_files
diff --git a/scripts/testing/run-net-scenarios.sh b/scripts/testing/run-net-scenarios.sh
index 7197e37..8d27438 100755
--- a/scripts/testing/run-net-scenarios.sh
+++ b/scripts/testing/run-net-scenarios.sh
@@ -100,13 +100,19 @@ for f in "${S_FILES[@]}"; do
esac
echo "== $name — $SCENARIO_DESC"
scenario_break || { fail "$name: break failed"; exit 1; }
+ # A diagnose miss still runs the fix + assert (the repair result is
+ # worth having either way) but must fail the scenario: rc carries it to
+ # the subshell exit so the run can't report green over a diagnosis
+ # regression.
+ rc=0
if declare -F scenario_diagnose_expect >/dev/null; then
if scenario_diagnose_expect; then pass "$name: diagnose named it"
- else fail "$name: diagnose did NOT name it (inspect net doctor --json)"; fi
+ else fail "$name: diagnose did NOT name it (inspect net doctor --json)"; rc=1; fi
fi
scenario_fix || info "$name: net doctor --fix returned non-zero"
if scenario_assert; then pass "$name: repaired"
else fail "$name: NOT repaired"; exit 1; fi
+ exit "$rc"
) || fails=$((fails + 1))
done
diff --git a/scripts/testing/run-test-baremetal.sh b/scripts/testing/run-test-baremetal.sh
index d22c424..d837389 100755
--- a/scripts/testing/run-test-baremetal.sh
+++ b/scripts/testing/run-test-baremetal.sh
@@ -228,14 +228,17 @@ if ! $VALIDATE_ONLY; then
if [ $POLL_COUNT -ge $MAX_POLLS ]; then
error "ArchSetup timed out after 90 minutes"
- ARCHSETUP_EXIT_CODE=124
+ ARCHSETUP_COMPLETED=timeout
else
- step "Retrieving archsetup exit status"
+ # The installer runs detached with set -e off, so its real exit
+ # status is unavailable; the marker only proves the script reached
+ # its last line. Testinfra is the pass/fail authority.
+ step "Checking for the archsetup completion marker"
if ssh_cmd "grep -q 'ARCHSETUP_EXECUTION_COMPLETE' /var/log/archsetup-*.log 2>/dev/null"; then
- ARCHSETUP_EXIT_CODE=0
+ ARCHSETUP_COMPLETED=yes
success "ArchSetup completed successfully"
else
- ARCHSETUP_EXIT_CODE=1
+ ARCHSETUP_COMPLETED=no
error "ArchSetup may have encountered errors"
fi
fi
@@ -259,7 +262,7 @@ if ! $VALIDATE_ONLY; then
capture_post_install_state "$TEST_RESULTS_DIR"
else
info "Skipping archsetup (--validate-only)"
- ARCHSETUP_EXIT_CODE=0
+ ARCHSETUP_COMPLETED=yes
mkdir -p "$TEST_RESULTS_DIR/pre-install" "$TEST_RESULTS_DIR/post-install"
fi
@@ -302,7 +305,7 @@ Target: $TARGET_HOST
Test Method: Bare Metal ZFS
Results:
- ArchSetup Exit Code: $ARCHSETUP_EXIT_CODE
+ ArchSetup Completed: $ARCHSETUP_COMPLETED (completion marker, not the installer's exit code)
Validation: $(if $TEST_PASSED; then echo "PASSED"; else echo "FAILED"; fi)
Validation Summary:
@@ -335,7 +338,7 @@ fi
# Final summary
section "Test Complete"
-if [ "$ARCHSETUP_EXIT_CODE" -eq 0 ] && $TEST_PASSED; then
+if [ "$ARCHSETUP_COMPLETED" = "yes" ] && $TEST_PASSED; then
success "TEST PASSED"
exit 0
else
diff --git a/scripts/testing/run-test.sh b/scripts/testing/run-test.sh
index f962df3..a5c3691 100755
--- a/scripts/testing/run-test.sh
+++ b/scripts/testing/run-test.sh
@@ -280,15 +280,17 @@ done
if [ $POLL_COUNT -ge $MAX_POLLS ]; then
error "ArchSetup timed out after 150 minutes"
- ARCHSETUP_EXIT_CODE=124
+ ARCHSETUP_COMPLETED=timeout
else
- # Get exit code from the remote log
- step "Retrieving archsetup exit status..."
- ARCHSETUP_EXIT_CODE=$(vm_exec "$ROOT_PASSWORD" \
- "grep -q 'ARCHSETUP_EXECUTION_COMPLETE' /var/log/archsetup-*.log 2>/dev/null && echo 0 || echo 1" \
+ # The installer runs detached with set -e off, so its real exit status
+ # is unavailable here; the completion marker only proves the script ran
+ # to its last line. Testinfra below is the actual pass/fail authority.
+ step "Checking for the archsetup completion marker..."
+ ARCHSETUP_COMPLETED=$(vm_exec "$ROOT_PASSWORD" \
+ "grep -q 'ARCHSETUP_EXECUTION_COMPLETE' /var/log/archsetup-*.log 2>/dev/null && echo yes || echo no" \
2>/dev/null)
- if [ "$ARCHSETUP_EXIT_CODE" = "0" ]; then
+ if [ "$ARCHSETUP_COMPLETED" = "yes" ]; then
success "ArchSetup completed successfully"
else
error "ArchSetup may have encountered errors (check logs)"
@@ -370,7 +372,7 @@ VM Configuration:
SSH: localhost:$SSH_PORT
Results:
- ArchSetup Exit Code: $ARCHSETUP_EXIT_CODE
+ ArchSetup Completed: $ARCHSETUP_COMPLETED (completion marker, not the installer's exit code)
Validation: $(if $TEST_PASSED; then echo "PASSED"; else echo "FAILED"; fi)
Validation Summary:
@@ -424,7 +426,7 @@ CLEANUP_DONE=1
# Final summary
section "Test Complete"
-if [ "$ARCHSETUP_EXIT_CODE" = "0" ] && $TEST_PASSED; then
+if [ "$ARCHSETUP_COMPLETED" = "yes" ] && $TEST_PASSED; then
success "TEST PASSED"
exit 0
else
diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py
index 468bc75..1538d6a 100644
--- a/scripts/testing/tests/test_desktop.py
+++ b/scripts/testing/tests/test_desktop.py
@@ -93,7 +93,13 @@ def test_hyprland_socket(host, hyprland_installed, compositor_running):
pytest.skip("Hyprland not installed")
if not compositor_running:
pytest.skip("Hyprland not running (headless) — socket check not applicable")
- assert host.run("test -S /tmp/hypr/*/.socket.sock").rc == 0
+ # `test -S` on a shell glob breaks at both edges: zero matches leaves the
+ # literal pattern (rc 1) and several instances pass multiple args (rc 2).
+ # find -type s proves exactly one live socket path exists.
+ sock = host.run(
+ "find /tmp/hypr -maxdepth 2 -name .socket.sock -type s | head -1"
+ ).stdout.strip()
+ assert sock, "no Hyprland socket found under /tmp/hypr"
@pytest.mark.attribution("archsetup")
diff --git a/tests/gallery-tokens/test_gen_tokens.py b/tests/gallery-tokens/test_gen_tokens.py
index b9fbb50..2968119 100644
--- a/tests/gallery-tokens/test_gen_tokens.py
+++ b/tests/gallery-tokens/test_gen_tokens.py
@@ -181,11 +181,15 @@ class ReplaceBetweenMarkers(unittest.TestCase):
def test_start_marker_on_final_line_without_newline(self):
# Defensive branch: no newline after the start marker. The guard must
# not let text[:si-of-newline+1] collapse to "" and wipe the prefix.
+ # This input cannot occur in the real HTML (markers always sit on
+ # their own newline-terminated lines), so the exact-string assert is a
+ # characterization: the prefix survives, and the reconstruction's
+ # duplicated start marker is pinned deliberately so any change to the
+ # branch surfaces here instead of passing unnoticed.
src = f"keep\n{self.START} {self.END}"
out = gt.replace_between_markers(src, self.START, self.END, "X")
- self.assertTrue(out.startswith("keep\n"))
- self.assertIn(self.END, out)
- self.assertIn("X", out)
+ self.assertEqual(
+ out, f"keep\n{self.START} X\n{self.START} {self.END}")
class RealTokensJson(unittest.TestCase):
diff --git a/tests/gallery-widgets/test-gallery-widget.el b/tests/gallery-widgets/test-gallery-widget.el
index 166cd59..c0d38b4 100644
--- a/tests/gallery-widgets/test-gallery-widget.el
+++ b/tests/gallery-widgets/test-gallery-widget.el
@@ -73,10 +73,12 @@
(let ((xml (test-gallery-widget--svg-string 42)))
;; arc path stroked in the wash token
(should (string-match-p (format "path[^>]*stroke=\"%s\"" (gallery-widget-token 'wash)) xml))
- ;; exactly three ticks
- (should (= 3 (cl-count-if (lambda (_) t)
- (split-string xml "class=\"tick\"" t)
- :start 1)))
+ ;; exactly three ticks, counted as direct occurrences (the earlier
+ ;; split-string arithmetic depended on omit-nulls edge behavior)
+ (should (= 3 (let ((n 0) (pos 0))
+ (while (string-match "class=\"tick\"" xml pos)
+ (setq n (1+ n) pos (match-end 0)))
+ n)))
;; needle + hub in the amber tokens
(should (string-match-p (format "class=\"needle\"[^>]*stroke=\"%s\""
(gallery-widget-token 'gold-hi))
@@ -110,5 +112,53 @@
"The readout shows a rounded integer percent, matching the web card."
(should (string-match-p ">67%<" (test-gallery-widget--svg-string 66.6))))
+(ert-deftest gallery-widget-gauge-out-of-range-readout-clamps ()
+ "Readout and needle agree at out-of-range values: both clamp.
+Regression: the needle clamped to the dial ends while the readout rendered
+the raw value, so a gauge fed 150 pinned the needle at +60 degrees under a
+\"150%\" label."
+ (let ((over (test-gallery-widget--svg-string 150)))
+ (should (string-match-p ">100%<" over))
+ (should-not (string-match-p ">150%<" over)))
+ (let ((under (test-gallery-widget--svg-string -5)))
+ (should (string-match-p ">0%<" under))
+ (should-not (string-match-p ">-5%<" under))))
+
+(ert-deftest gallery-widget-source-dir-load-and-fallback ()
+ "The token-file directory resolves from load context, else default-directory.
+Regression: `file-name-directory' received nil when the load form was re-evaluated
+outside a load and outside a file buffer."
+ (let ((load-file-name "/tmp/proto/gallery-widget.el"))
+ (should (equal (gallery-widget--source-dir) "/tmp/proto/")))
+ (with-temp-buffer
+ (let ((load-file-name nil)
+ (default-directory "/tmp/elsewhere/"))
+ (should (equal (gallery-widget--source-dir) "/tmp/elsewhere/")))))
+
+(ert-deftest gallery-widget-write-svg-writes-file-and-returns-path ()
+ "The output helper writes the SVG document and returns the path."
+ (let ((file (make-temp-file "gallery-widget-test-" nil ".svg")))
+ (unwind-protect
+ (let ((ret (gallery-widget-write-svg
+ (gallery-widget-needle-gauge 42) file)))
+ (should (equal ret file))
+ (should (file-exists-p file))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (should (string-match-p "\\`<svg" (buffer-string)))))
+ (delete-file file))))
+
+(ert-deftest gallery-widget-requires-cl-lib-at-load-time ()
+ "Loading the module alone brings in cl-lib, not just an autoload cookie.
+A cold byte-compile or a changed autoload would otherwise break
+`gallery-widget--node' with a void-function error on first call."
+ (with-temp-buffer
+ (let ((emacs (expand-file-name invocation-name invocation-directory))
+ (widget (expand-file-name "docs/prototypes/gallery-widget.el"
+ test-gallery-widget--root)))
+ (call-process emacs nil t nil "--batch" "-l" widget
+ "--eval" "(princ (if (featurep 'cl-lib) \"cl-lib:yes\" \"cl-lib:no\"))")
+ (should (string-match-p "cl-lib:yes" (buffer-string))))))
+
(provide 'test-gallery-widget)
;;; test-gallery-widget.el ends here
diff --git a/tests/installer-steps/test_ensure_nvme_early_module.py b/tests/installer-steps/test_ensure_nvme_early_module.py
new file mode 100644
index 0000000..8e3f036
--- /dev/null
+++ b/tests/installer-steps/test_ensure_nvme_early_module.py
@@ -0,0 +1,95 @@
+"""Test the nvme early-module step.
+
+Two bugs the extracted ensure_nvme_early_module fixes:
+
+1. The MODULES=(... nvme) edit was only compiled into the initramfs by a
+ mkinitcpio -P that ran behind `if ! is_zfs_root`, so on ZFS-root machines
+ the early-load hardening never landed. The step now rebuilds whenever it
+ changed the file, unconditionally.
+2. The already-present check grepped the whole file for "nvme", so a comment
+ or an unrelated token (nvme_tcp) anywhere in mkinitcpio.conf skipped the
+ edit. The check now looks for the nvme word on the MODULES line only.
+
+Method: sed-extract ensure_nvme_early_module from the real `archsetup`, point
+it at a temp mkinitcpio.conf, and fake has_nvme_drives/backup_system_file/
+mkinitcpio/display/error_warn.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_ensure_nvme_early_module
+"""
+
+import os
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(conf_body, has_nvme=True):
+ with tempfile.TemporaryDirectory() as d:
+ conf = os.path.join(d, "mkinitcpio.conf")
+ with open(conf, "w") as f:
+ f.write(conf_body)
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ backup_system_file() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ has_nvme_drives() {{ return {0 if has_nvme else 1}; }}
+ # stdout is redirected into $logfile at the call site, so the fake
+ # records its invocation in a side file instead.
+ mkinitcpio() {{ echo "MKINITCPIO $*" >> "{d}/mk.log"; }}
+ source <(sed -n '/^ensure_nvme_early_module() {{/,/^}}/p' "{ARCHSETUP}")
+ ensure_nvme_early_module "{conf}"
+ echo "RC=$?"
+ echo "CONF:[$(cat "{conf}")]"
+ [ -f "{d}/mk.log" ] && cat "{d}/mk.log"
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+class EnsureNvmeEarlyModule(unittest.TestCase):
+ def test_empty_modules_gets_nvme_and_rebuilds(self):
+ r = run("MODULES=()\nHOOKS=(base udev)\n")
+ self.assertIn("MODULES=(nvme)", r.stdout)
+ self.assertIn("MKINITCPIO -P", r.stdout,
+ "the initramfs must rebuild after the MODULES edit")
+
+ def test_populated_modules_appends_nvme_and_rebuilds(self):
+ r = run("MODULES=(btrfs)\n")
+ self.assertIn("MODULES=(btrfs nvme)", r.stdout)
+ self.assertIn("MKINITCPIO -P", r.stdout)
+
+ def test_nvme_already_present_no_edit_no_rebuild(self):
+ r = run("MODULES=(nvme)\n")
+ self.assertIn("MODULES=(nvme)", r.stdout)
+ self.assertNotIn("MODULES=(nvme nvme)", r.stdout)
+ self.assertNotIn("MKINITCPIO", r.stdout,
+ "an unchanged conf must not trigger a rebuild (resume idempotence)")
+
+ def test_nvme_mention_elsewhere_does_not_skip_the_edit(self):
+ # The old whole-file grep skipped the edit when any line mentioned
+ # nvme; a comment must not mask a missing MODULES entry.
+ r = run("# early nvme notes: nvme_load=YES\nMODULES=(btrfs)\n")
+ self.assertIn("MODULES=(btrfs nvme)", r.stdout)
+ self.assertIn("MKINITCPIO -P", r.stdout)
+
+ def test_nvme_tcp_module_does_not_count_as_nvme(self):
+ r = run("MODULES=(nvme_tcp)\n")
+ self.assertIn("MODULES=(nvme_tcp nvme)", r.stdout)
+
+ def test_no_nvme_drives_is_a_noop(self):
+ r = run("MODULES=()\n", has_nvme=False)
+ self.assertIn("RC=0", r.stdout)
+ self.assertIn("CONF:[MODULES=()", r.stdout)
+ self.assertNotIn("MKINITCPIO", r.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_grub_cmdline.py b/tests/installer-steps/test_grub_cmdline.py
new file mode 100644
index 0000000..8c0b5b0
--- /dev/null
+++ b/tests/installer-steps/test_grub_cmdline.py
@@ -0,0 +1,121 @@
+"""Test the GRUB cmdline merge.
+
+Bug (P2): configure_grub rewrote the whole GRUB_CMDLINE_LINUX_DEFAULT line
+with a fixed string. A base install that had set cryptdevice=/resume=/zfs=
+(or any other boot-critical token) lost it, and the following grub-mkconfig
+baked an unbootable config.
+
+The fix is a merge: every pre-existing token survives, archsetup's tokens are
+added, and where both set the same key archsetup's value wins. A safety
+assert refuses to write if any existing token's key would vanish.
+
+Method: sed-extract merge_grub_cmdline (pure) and update_grub_cmdline
+(file-level, run against temp grub files with a fake error_warn).
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_grub_cmdline
+"""
+
+import os
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+EXTRACT = (
+ "source <(sed -n '/^merge_grub_cmdline() {{/,/^}}/p;"
+ "/^update_grub_cmdline() {{/,/^}}/p' \"{a}\")"
+).format(a=ARCHSETUP)
+
+
+def run(body):
+ script = f"logfile=/dev/null\nerror_warn() {{ echo \"WARN: $1\"; return 1; }}\n{EXTRACT}\n{body}\n"
+ return subprocess.run(["bash", "-c", script],
+ capture_output=True, text=True, timeout=10)
+
+
+def merge(existing, desired):
+ r = run(f'merge_grub_cmdline "{existing}" "{desired}"')
+ return r.stdout.strip()
+
+
+class MergeGrubCmdline(unittest.TestCase):
+ DESIRED = "rw loglevel=2 quiet splash"
+
+ def test_empty_existing_yields_desired(self):
+ self.assertEqual(merge("", self.DESIRED), self.DESIRED)
+
+ def test_boot_critical_tokens_survive(self):
+ out = merge("cryptdevice=UUID=abc:root resume=/dev/nvme0n1p3 root=/dev/mapper/root",
+ self.DESIRED)
+ for tok in ("cryptdevice=UUID=abc:root", "resume=/dev/nvme0n1p3",
+ "root=/dev/mapper/root", "loglevel=2", "quiet", "splash"):
+ self.assertIn(tok, out.split(), f"{tok} missing from: {out}")
+
+ def test_same_key_desired_value_wins_once(self):
+ out = merge("loglevel=7 quiet", self.DESIRED).split()
+ self.assertIn("loglevel=2", out)
+ self.assertNotIn("loglevel=7", out)
+ self.assertEqual(out.count("loglevel=2"), 1)
+ self.assertEqual(out.count("quiet"), 1)
+
+ def test_zfs_token_survives(self):
+ out = merge("zfs=zroot/ROOT/default", self.DESIRED).split()
+ self.assertIn("zfs=zroot/ROOT/default", out)
+
+
+class UpdateGrubCmdline(unittest.TestCase):
+ def run_update(self, grub_body):
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, "grub")
+ with open(path, "w") as f:
+ f.write(grub_body)
+ r = run(f'update_grub_cmdline "{path}"; echo "RC=$?"')
+ with open(path) as f:
+ return r, f.read()
+
+ def line(self, content):
+ return [ln for ln in content.splitlines()
+ if ln.startswith('GRUB_CMDLINE_LINUX_DEFAULT=')]
+
+ def test_existing_tokens_preserved_in_file(self):
+ _, content = self.run_update(textwrap.dedent("""\
+ GRUB_TIMEOUT=5
+ GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3 cryptdevice=UUID=abc:root resume=/dev/sda2"
+ GRUB_DISABLE_RECOVERY=true
+ """))
+ lines = self.line(content)
+ self.assertEqual(len(lines), 1)
+ val = lines[0]
+ self.assertIn("cryptdevice=UUID=abc:root", val)
+ self.assertIn("resume=/dev/sda2", val)
+ self.assertIn("loglevel=2", val) # archsetup's value wins
+ self.assertNotIn("loglevel=3", val)
+ self.assertIn("quiet", val)
+ # other lines untouched
+ self.assertIn("GRUB_TIMEOUT=5", content)
+ self.assertIn("GRUB_DISABLE_RECOVERY=true", content)
+
+ def test_quoting_stays_a_single_pair(self):
+ _, content = self.run_update('GRUB_CMDLINE_LINUX_DEFAULT="quiet"\n')
+ val = self.line(content)[0]
+ self.assertEqual(val.count('"'), 2)
+ self.assertRegex(val, r'^GRUB_CMDLINE_LINUX_DEFAULT="[^"]*"$')
+
+ def test_commented_line_gets_active_line_appended(self):
+ _, content = self.run_update('#GRUB_CMDLINE_LINUX_DEFAULT="quiet"\n')
+ self.assertEqual(len(self.line(content)), 1)
+ self.assertIn('#GRUB_CMDLINE_LINUX_DEFAULT="quiet"', content)
+
+ def test_idempotent_on_rerun(self):
+ body = 'GRUB_CMDLINE_LINUX_DEFAULT="cryptdevice=UUID=abc:root"\n'
+ _, once = self.run_update(body)
+ _, twice = self.run_update(once)
+ self.assertEqual(once, twice)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_pacman_hook_order.py b/tests/installer-steps/test_pacman_hook_order.py
index 49acdbe..45058dd 100644
--- a/tests/installer-steps/test_pacman_hook_order.py
+++ b/tests/installer-steps/test_pacman_hook_order.py
@@ -1,26 +1,78 @@
-"""Regression tests for the pacman hook ordering safety invariant."""
+"""Regression tests for the pacman hook ordering safety invariant.
+
+pacman runs hooks in filename sort order, so the safety hooks archsetup
+installs (the pre-pacman ZFS snapshot and the live-update guard, both
+PreTransaction) must carry names that sort before mkinitcpio's stock
+60-mkinitcpio-remove.hook. Otherwise a blocked transaction can remove the
+current initramfs before the guard aborts, or after the snapshot window.
+
+The earlier version of this test asserted the ordering by comparing two string
+literals to each other, which is constant-true and never looked at the source.
+These tests extract the hook filenames the installer actually writes and
+compare those, so a rename in the source flows into the assertion.
+"""
import os
+import re
import unittest
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+MKINITCPIO_REMOVE = "60-mkinitcpio-remove.hook" # stock mkinitcpio hook name
+
+WRITE_RE = re.compile(r">\s*/etc/pacman\.d/hooks/(\S+\.hook)\b")
+
+
+def written_hooks(text):
+ """Hook filenames the source writes (redirections into the hooks dir)."""
+ names = []
+ for line in text.splitlines():
+ if "rm -f" in line:
+ continue
+ m = WRITE_RE.search(line)
+ if m:
+ names.append(m.group(1))
+ return names
+
class PacmanHookOrderTests(unittest.TestCase):
- def test_safety_hooks_precede_mkinitcpio_removal(self):
+ @classmethod
+ def setUpClass(cls):
with open(ARCHSETUP, encoding="utf-8") as source:
- text = source.read()
+ cls.text = source.read()
+ cls.hooks = written_hooks(cls.text)
+ def hook_named(self, suffix):
+ matches = [h for h in self.hooks if h.endswith(suffix)]
+ self.assertEqual(
+ len(matches), 1,
+ f"expected exactly one written hook ending {suffix}, got {matches}")
+ return matches[0]
+
+ def test_safety_hooks_sort_before_mkinitcpio_removal(self):
+ # The invariant pacman actually enforces: filename sort order.
+ for suffix in ("zfs-snapshot.hook", "hypr-live-update-guard.hook"):
+ name = self.hook_named(suffix)
+ self.assertLess(
+ name, MKINITCPIO_REMOVE,
+ f"{name} must sort before {MKINITCPIO_REMOVE} or the guard "
+ "runs after the initramfs removal")
+
+ def test_every_written_hook_has_an_ordering_prefix(self):
+ # Ordering is deliberate; a hook without a numeric prefix sorts by
+ # accident of its first letter.
+ self.assertTrue(self.hooks, "extraction found no written hooks")
+ for name in self.hooks:
+ self.assertRegex(name, r"^\d{2}-",
+ f"{name} lacks the two-digit ordering prefix")
+
+ def test_stale_unprefixed_hooks_are_cleaned_up(self):
+ # Migration from installs made before the numeric prefixes.
+ self.assertIn("rm -f /etc/pacman.d/hooks/zfs-snapshot.hook", self.text)
self.assertIn(
- "cat << 'EOF' > /etc/pacman.d/hooks/05-zfs-snapshot.hook", text)
- self.assertIn(
- "cat > /etc/pacman.d/hooks/10-hypr-live-update-guard.hook", text)
- self.assertLess("05-zfs-snapshot.hook", "60-mkinitcpio-remove.hook")
- self.assertLess("10-hypr-live-update-guard.hook", "60-mkinitcpio-remove.hook")
- self.assertIn("rm -f /etc/pacman.d/hooks/zfs-snapshot.hook", text)
- self.assertIn("rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook", text)
+ "rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook", self.text)
if __name__ == "__main__":
diff --git a/tests/net-scenarios/test_run_net_scenarios.py b/tests/net-scenarios/test_run_net_scenarios.py
new file mode 100644
index 0000000..1d92185
--- /dev/null
+++ b/tests/net-scenarios/test_run_net_scenarios.py
@@ -0,0 +1,102 @@
+"""Test run-net-scenarios.sh scenario accounting.
+
+Bug: the scenario_diagnose_expect else-branch printed a FAIL line but never
+forced a non-zero subshell exit, so a scenario whose only failure was the
+diagnose check counted as passed and the run printed "all scenarios passed"
+with exit 0. A net-doctor diagnosis regression would ship behind a green run.
+
+Method: run the real script against a temp scenario dir (NET_SCENARIO_DIR)
+with stub ssh/rsync/jq on PATH, driving one fake scenario whose check results
+are controlled per test. Assert on the script's exit code and summary line.
+
+Run from repo root:
+ python3 -m unittest tests.net-scenarios.test_run_net_scenarios
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+SCRIPT = os.path.join(REPO_ROOT, "scripts", "testing", "run-net-scenarios.sh")
+
+STUB = "#!/bin/sh\ncat >/dev/null 2>&1 || true\nexit 0\n"
+
+
+class RunNetScenarios(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="net-scen-test-")
+ self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
+ self.bindir = os.path.join(self.tmp, "bin")
+ os.mkdir(self.bindir)
+ for tool in ("ssh", "rsync", "jq"):
+ p = os.path.join(self.bindir, tool)
+ with open(p, "w") as f:
+ f.write(STUB)
+ os.chmod(p, 0o755)
+ self.scen_dir = os.path.join(self.tmp, "scenarios")
+ os.mkdir(self.scen_dir)
+
+ def write_scenario(self, *, brk=0, diagnose=0, fix=0, assert_rc=0,
+ with_diagnose=True):
+ diag_fn = (
+ f"scenario_diagnose_expect() {{ return {diagnose}; }}\n"
+ if with_diagnose else ""
+ )
+ body = textwrap.dedent(f"""\
+ SCENARIO_DESC="fake scenario for harness tests"
+ scenario_break() {{ return {brk}; }}
+ {diag_fn}scenario_fix() {{ return {fix}; }}
+ scenario_assert() {{ return {assert_rc}; }}
+ """)
+ with open(os.path.join(self.scen_dir, "fake-scenario.sh"), "w") as f:
+ f.write(body)
+
+ def run_script(self):
+ env = dict(os.environ)
+ env["PATH"] = self.bindir + os.pathsep + env["PATH"]
+ env["NET_SCENARIO_DIR"] = self.scen_dir
+ env["DOTFILES"] = self.tmp # rsync is stubbed; path just has to exist
+ return subprocess.run(
+ ["bash", SCRIPT, "--target", "root@fake-vm"],
+ capture_output=True, text=True, timeout=20, env=env,
+ )
+
+ def test_all_checks_pass_exits_zero(self):
+ self.write_scenario()
+ r = self.run_script()
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+ self.assertIn("all scenarios passed", r.stdout)
+
+ def test_diagnose_failure_alone_fails_the_run(self):
+ # The bug: break/fix/assert all pass, only the diagnose check fails.
+ self.write_scenario(diagnose=1)
+ r = self.run_script()
+ self.assertIn("diagnose did NOT name it", r.stdout)
+ self.assertNotEqual(r.returncode, 0,
+ "a diagnose regression must not exit green")
+ self.assertIn("1 scenario(s) failed", r.stdout)
+
+ def test_assert_failure_fails_the_run(self):
+ self.write_scenario(assert_rc=1)
+ r = self.run_script()
+ self.assertNotEqual(r.returncode, 0)
+ self.assertIn("NOT repaired", r.stdout)
+
+ def test_break_failure_fails_the_run(self):
+ self.write_scenario(brk=1)
+ r = self.run_script()
+ self.assertNotEqual(r.returncode, 0)
+ self.assertIn("break failed", r.stdout)
+
+ def test_scenario_without_diagnose_hook_still_passes(self):
+ self.write_scenario(with_diagnose=False)
+ r = self.run_script()
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/vm-framework/test_vm_utils.py b/tests/vm-framework/test_vm_utils.py
new file mode 100644
index 0000000..579955b
--- /dev/null
+++ b/tests/vm-framework/test_vm_utils.py
@@ -0,0 +1,130 @@
+"""Tests for the VM test framework's vm-utils helpers.
+
+Two robustness bugs under test:
+
+1. init_vm_paths suffixed DISK_PATH and OVMF_VARS by FS_PROFILE but left
+ PID_FILE, MONITOR_SOCK, and SERIAL_LOG unsuffixed, so a concurrent btrfs
+ and zfs run shared them -- the second run's stop/is-running logic read the
+ first run's PID and could kill the other VM. All runtime paths now carry
+ the profile suffix.
+2. kill_qemu sent kill -9 and deleted the PID file without waiting for the
+ process to die, so a force-kill fallback could run qemu-img snapshot
+ against a qcow2 the dying qemu still held locked. kill_qemu now waits for
+ the process to be dead (or reaped) before returning.
+
+Method: sed-extract init_vm_paths / kill_qemu / _cleanup_qemu_files from
+lib/vm-utils.sh and drive them with temp dirs and real background processes.
+
+Run from repo root:
+ python3 -m unittest tests.vm-framework.test_vm_utils
+"""
+
+import os
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+VM_UTILS = os.path.join(REPO_ROOT, "scripts", "testing", "lib", "vm-utils.sh")
+
+EXTRACT = (
+ "source <(sed -n '/^init_vm_paths() {{/,/^}}/p;"
+ "/^kill_qemu() {{/,/^}}/p;"
+ "/^_cleanup_qemu_files() {{/,/^}}/p' \"{lib}\")"
+).format(lib=VM_UTILS)
+
+
+def run(body):
+ script = f"fatal() {{ echo \"FATAL: $*\"; exit 1; }}\nwarn() {{ :; }}\n{EXTRACT}\n{body}\n"
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=30,
+ )
+
+
+class InitVmPaths(unittest.TestCase):
+ def paths_for(self, profile):
+ with tempfile.TemporaryDirectory() as d:
+ body = textwrap.dedent(f"""\
+ FS_PROFILE={profile}
+ init_vm_paths "{d}"
+ echo "DISK=$DISK_PATH"
+ echo "VARS=$OVMF_VARS"
+ echo "PID=$PID_FILE"
+ echo "MON=$MONITOR_SOCK"
+ echo "SER=$SERIAL_LOG"
+ """)
+ return run(body).stdout
+
+ def test_zfs_profile_suffixes_all_runtime_paths(self):
+ out = self.paths_for("zfs")
+ self.assertIn("archsetup-base-zfs.qcow2", out)
+ self.assertIn("OVMF_VARS-zfs.fd", out)
+ # The bug: these three shared one name across profiles.
+ self.assertIn("PID=", out)
+ self.assertRegex(out, r"PID=.*-zfs",
+ "PID_FILE must carry the profile suffix")
+ self.assertRegex(out, r"MON=.*-zfs",
+ "MONITOR_SOCK must carry the profile suffix")
+ self.assertRegex(out, r"SER=.*-zfs",
+ "SERIAL_LOG must carry the profile suffix")
+
+ def test_btrfs_profile_keeps_legacy_unsuffixed_names(self):
+ out = self.paths_for("btrfs")
+ self.assertIn("DISK=", out)
+ self.assertIn("archsetup-base.qcow2", out)
+ self.assertNotIn("-btrfs", out)
+
+ def test_invalid_profile_fatals(self):
+ out = self.paths_for("ext4")
+ self.assertIn("FATAL", out)
+
+
+class KillQemu(unittest.TestCase):
+ def test_process_is_dead_before_return(self):
+ # Contract pin for the snapshot-restore race: when kill_qemu returns,
+ # the process must be dead or reaped -- state Z or /proc entry gone --
+ # so a following qemu-img call can't hit a still-held qcow2 lock.
+ with tempfile.TemporaryDirectory() as d:
+ body = textwrap.dedent(f"""\
+ PID_FILE="{d}/qemu.pid"
+ MONITOR_SOCK="{d}/qemu-monitor.sock"
+ sleep 60 & victim=$!
+ echo "$victim" > "$PID_FILE"
+ kill_qemu
+ state=$(awk '{{print $3}}' "/proc/$victim/stat" 2>/dev/null || echo GONE)
+ echo "STATE=$state"
+ [ -f "$PID_FILE" ] && echo "PIDFILE=present" || echo "PIDFILE=removed"
+ """)
+ r = run(body)
+ self.assertRegex(r.stdout, r"STATE=(Z|GONE)",
+ f"process still live after kill_qemu: {r.stdout}")
+ self.assertIn("PIDFILE=removed", r.stdout)
+
+ def test_stale_pid_file_is_cleaned_quietly(self):
+ with tempfile.TemporaryDirectory() as d:
+ body = textwrap.dedent(f"""\
+ PID_FILE="{d}/qemu.pid"
+ MONITOR_SOCK="{d}/qemu-monitor.sock"
+ echo 99999999 > "$PID_FILE"
+ kill_qemu
+ echo "RC=$?"
+ [ -f "$PID_FILE" ] && echo "PIDFILE=present" || echo "PIDFILE=removed"
+ """)
+ r = run(body)
+ self.assertIn("RC=0", r.stdout)
+ self.assertIn("PIDFILE=removed", r.stdout)
+
+ def test_no_pid_file_is_a_noop(self):
+ with tempfile.TemporaryDirectory() as d:
+ body = textwrap.dedent(f"""\
+ PID_FILE="{d}/qemu.pid"
+ MONITOR_SOCK="{d}/qemu-monitor.sock"
+ kill_qemu
+ echo "RC=$?"
+ """)
+ self.assertIn("RC=0", run(body).stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/todo.org b/todo.org
index 7297b05..8e812c5 100644
--- a/todo.org
+++ b/todo.org
@@ -45,15 +45,6 @@ below):
input-side-spec.org (DRAFT, four decisions open).
* Archsetup Open Work
-** DONE [#A] Tracked WireGuard private keys in repo — public leak, resolved :bug:security:network:
-CLOSED: [2026-07-20 Mon]
-Confirmed a live public leak, not just at-risk: git.cjennings.net runs cgit (scan-path=/var/git), so archsetup.git was anonymously cloneable over https. An unauthenticated clone pulled the configs with intact PrivateKeys. Exposed 2026-07-05 (c7b7d16) to 2026-07-20. Regraded to P1/[#A] (public credential exposure, severity-alone carve-out) from the initial [#B].
-Scope was wider than first found: the current 3 configs (assets/wireguard-config/wg-*.conf) plus 7 older ones at the pre-reorg path assets/wireguard/ (switzerland x2, USCALA/USCASF/USDC/USGAAT/USNY) — 10 config files, all with real keys.
-Resolution: Craig expired all the Proton WireGuard configs (keys dead). Purged all 10 from every commit with git filter-repo, force-pushed main + v0.5, and ran git gc --prune=now on the server bare repo. Verified via anonymous clone: zero real-key blobs reachable, all old exposed commits gone. Stopped tracking plaintext (gitignore + README, out-of-band configs only).
-Follow-ups filed below: harden cgit exposure; installer no longer ships configs.
-** TODO [#B] Installer GRUB_CMDLINE overwrite drops boot params :bug:solo:
-Grading: Critical severity (unbootable) x some-users-sometimes (machines whose base install set a cryptdevice=/resume=/zfs= cmdline param) = P2 = [#B].
-archsetup:3054 rewrites the whole GRUB_CMDLINE_LINUX_DEFAULT line with a fixed string; nothing re-adds a pre-existing cryptdevice/resume/zfs token, so grub-mkconfig (3059) can bake an unbootable config. Fix: read the current value and append only the missing tokens; assert any pre-existing boot-critical token survives before grub-mkconfig. See [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (S3).
** TODO [#B] Audit cgit-published repos for secrets and privacy :bug:security:
Grading: security carve-out — cgit at git.cjennings.net serves every repo under scan-path=/var/git over unauthenticated https (any repo is anonymously cloneable). archsetup being public is by design (curl-install), but this means ANY secret in ANY /var/git repo is world-readable, and any repo meant to be private is not. Severity depends on what else lives there = P2 = [#B], raise if a private repo with secrets is found.
Not :solo: — needs Craig's decisions. Steps: list repos under /var/git; for each, decide intended public vs private; scan each for secrets (keys, tokens, credentials) the way this WireGuard leak was found; for any meant-to-be-private repo, actually restrict access (cgit repo.hide only hides from the index — a known repo name is still cloneable; use http auth or move it off the public scan-path); for public repos, confirm no secrets and add a pre-receive/CI secret scan. Check dotfiles (git.cjennings.net/dotfiles) specifically — it is also under /var/git. archsetup's own move is decided and tracked separately below.
@@ -67,6 +58,11 @@ Post-mortem for the 2026-07-15 velox no-kernel boot failure, from the archsetup/
- Sanoid-vs-actual dataset drift: configure_zfs_snapshots configures zroot/var/log + zroot/var/lib/pacman as separate datasets; velox's actual layout has neither separate (/var/log sits inside zroot/var). Reconcile.
- Confirm the pre-pacman snapshot hook is actually installed + firing on velox (it should be — it's what makes recovery possible).
+** TODO [#B] Hyprland layoutmsg crash — bad_variant_access (upstream) :bug:hyprland:
+Grading: Critical severity (SIGSEGV kills the whole desktop session; every GUI app's unsaved state lost) x rare edge case (twice in ~4.5 months: 2026-03-07 on v0.54.1, 2026-07-20 on v0.55.4) = P2 = [#B]. Upstream Hyprland bug, not this repo's code — the task tracks reporting it and picking up the fix.
+A layoutmsg mfact dispatch (layout-resize, mod+H/L) throws std::bad_variant_access inside Layout::CAlgorithm::layoutMsg, uncaught, SIGSEGV. Both crashes fired from the layout-resize mfact path (keycode 104 shrink today, 108 grow in March). Layout at crash was master and the identical mfact had worked seconds earlier; the pre-crash window held monocle<->master toggles, two window closes dropping focus to "[Window nullptr]", and togglefloating x2. Monocle is a registered v0.55 layout (log shows graceful "Unknown monocle layoutmsg" rejects), so the config is not at fault; related edges are guarded ("mfact -> no window") while this path misses its variant guard. Repo has no newer build (0.55.4-1 installed and repo).
+Evidence preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] (both crash reports + excerpts from the tmpfs session log, extracted before reboot loses it).
+Next: Craig posts the issue himself (2026-07-20 decision) — the voice-passed draft is [[file:working/hyprland-layoutmsg-crash/issue-draft.md][issue-draft.md]], with both crash reports and the log excerpts beside it for attaching. Watch the repo for a fixed release and close on confirmation. The layout-resize script guard was declined (a script can't observe the internal desync).
** TODO [#B] Assess a Hyprland left-drag window gesture :feature:hyprland:
Evaluate whether a global left-click drag can move ordinary windows without
breaking application selection, text interaction, or Wayland security
@@ -1461,46 +1457,6 @@ Parent spec: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026
** TODO [#C] WireGuard import is now config-less — decide feature fate :feature:network:
scripts/import-wireguard-configs.sh reads assets/wireguard-config/*.conf, but no configs ship in the repo anymore (removed as a public-leak fix; .gitignore blocks plaintext). Decide: remove the import feature entirely, or keep it and document dropping plaintext configs into the dir out-of-band at install time (they stay gitignored). If kept, confirm the script no-ops gracefully when the dir has no *.conf.
-** DONE [#C] Installer chpasswd unguarded — unloggable primary user :bug:solo:quick:
-CLOSED: [2026-07-20 Mon]
-Fixed (fa3135a): extracted set_user_password, which guards the chpasswd with error_fatal so a failure aborts loudly instead of silently leaving no password. Fake-chpasswd test pins the guard fires on failure and stays quiet on success.
-Grading: Major severity (fresh system's primary user can't log in) x rare edge case (chpasswd seldom fails) = P3 = [#C].
-archsetup:1168 runs =echo "$user:$pass" | chpasswd= with no guard, then unsets the password next line; set -e is off (line 21), so a silent failure leaves no password and no log entry. Fix: guard with error_fatal (report + "set it by hand: passwd $user") before unsetting. See findings doc (S2).
-** TODO [#C] Installer nvme early module never built into initramfs :bug:solo:
-Grading: Minor severity (module autoload still boots the system) x most-machines (all Craig's ZFS-root boxes) = P3 = [#C].
-archsetup:2910 writes MODULES=(nvme) but the only mkinitcpio -P in boot_ux runs =if ! is_zfs_root=, so on ZFS-root non-Framework machines the early-load hardening is never compiled in. Also archsetup:2918 greps the whole file for "nvme" (not the MODULES line). Fix: rebuild initramfs after the MODULES edit regardless of ZFS; scope the presence grep to =^MODULES=(=. See findings doc (S3).
-** DONE [#C] Installer disk-space pre-flight check is fragile :bug:solo:quick:
-CLOSED: [2026-07-20 Mon]
-Fixed in aef074f: extracted check_disk_space using df -P (wrap-safe) and a KB comparison (no truncation bias); non-numeric df output falls back to zero so a malformed read aborts loudly. TDD via tests/installer-steps/test_check_disk_space.py.
-Grading: Major severity (aborts a valid install) x some (df wraps long device names on a live ISO / device-mapper root) = P3 = [#C].
-archsetup:487 parses =df / | awk 'NR==2'=, which reads the device-name line (empty $4 -> 0 GB) when df wraps; archsetup:488 also integer-truncates the GB compare against the 20 GB floor. Fix: =df -P /= (single-line) or =df --output=avail=; compare in KB to avoid the rounding bias. See findings doc (S1).
-** DONE [#C] Installer run_step state + exit-code handling :bug:solo:
-CLOSED: [2026-07-20 Mon]
-Fixed in 6de55d2: run_step records the state marker whenever the step function returns (a return past error_fatal's exit means only a non-fatal warning is left), added local to run_step/show_status, and captured pacman's real exit in the refresh loop. TDD via tests/installer-steps/test_run_step.py.
-Grading: Major severity (resume re-runs steps and can abort on a survivable warning) x some (a step whose last action is a non-fatal failure) = P3 = [#C].
-archsetup:298 marks a step complete only when its function returns 0, but error_warn/run_task return 1, so a non-fatal-failing step never writes its marker and re-runs on resume. Also archsetup:1034 reports =$?= of the =false= test, not pacman's real exit code; and run_step locals (290/318) leak to global scope. Fix: step functions =return 0= explicitly (or gate run_step on a per-step error flag); capture the real exit code; add =local=. See findings doc (S1).
-** TODO [#C] cmail password decrypted world-readable before chmod :bug:security:solo:quick:cmail:
-Grading: security carve-out — brief local plaintext exposure of the mail password, requires a concurrent local shell during install; narrow window = low severity = P3 = [#C].
-scripts/cmail-setup-finish.sh:52 gpg-decrypts to ~/.config/.cmailpass at the process umask (often 0644), then chmod 600 on the next line. Fix: =(umask 077; gpg ... --output ...)= or decrypt to a mktemp 0600 file and mv into place (mirror the import-wireguard mktemp -d 0700 pattern). See findings doc (S4).
-** DONE [#C] Installer sudoers.pacnew blind copy risks lockout :bug:solo:quick:
-CLOSED: [2026-07-20 Mon]
-Fixed in c80e855: extracted replace_sudoers_pacnew, which runs visudo -cf on the pacnew and only copies a validated file (warns and keeps the working sudoers otherwise). TDD via tests/installer-steps/test_replace_sudoers_pacnew.py.
-Grading: Major severity (a malformed sudoers locks out privilege escalation) x rare edge case = P3 = [#C].
-archsetup:1146 does =[ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers= with no validation, right before the NOPASSWD rule at 1183. Fix: =visudo -cf /etc/sudoers.pacnew && cp ... || error_warn=. See findings doc (S2).
-** DONE [#C] WireGuard import leaves full-tunnel VPN live on failure :bug:solo:network:
-CLOSED: [2026-07-20 Mon]
-Fixed in 36daf76: the down now runs before the rename modify (targets the stable UUID), so a failed modify under set -e can't leave a live full-tunnel VPN. Added a connection-down case to fake-nmcli and two ordering tests.
-Grading: Major severity (all traffic silently routed through Proton until manual cleanup) x rare (nmcli modify failure) = P3 = [#C].
-scripts/import-wireguard-configs.sh:51-62 imports (which brings the 0.0.0.0/0 tunnel up), renames, then deactivates; under set -e a failed modify aborts before the down, leaving the tunnel live. Fix: bring the connection down right after parsing the UUID, before the rename. See findings doc (S4).
-** TODO [#C] net-scenarios diagnose failure exits green :bug:test:solo:
-Grading: Major severity (a net-doctor diagnosis regression is reported as a passing run — false green on a diagnostic tool) x rare edge case (only when a diagnosis regresses and this first-draft harness is relied on) = P3 = [#C].
-scripts/testing/run-net-scenarios.sh:103 — the scenario_diagnose_expect else-branch prints fail "...diagnose did NOT name it" but never forces a non-zero subshell exit, so ( ... ) || fails=... leaves fails unincremented and the script prints "all scenarios passed" + exit 0. Fix: exit 1 in that branch like the other two checks. See findings doc (S5).
-** TODO [#C] pacman-hook-order test is a tautology :test:solo:quick:
-Grading: Major severity (guards boot-critical hook ordering — a reorder that removes the current initramfs without a rebuild is unbootable, and this test would ship it green) x rare (hook order rarely changes) = P3 = [#C].
-tests/installer-steps/test_pacman_hook_order.py:20 — the two assertLess calls compare string literals ("05..." < "60..."), a constant ASCII fact always true regardless of file content; the ordering the test exists to protect is never measured. Only the assertIn presence checks do real work. Fix: assert on positions — text.index("05-zfs-snapshot.hook") < text.index("60-mkinitcpio-remove.hook") (and the guard hook). See findings doc (S6).
-** TODO [#C] Add inetutils to install base :feature:solo:quick:network:
-TRAMP's /ftp: method (ange-ftp) shells out to a command-line ftp client; Arch ships none by default. GNU inetutils provides =/usr/bin/ftp=. Craig's dirvish config has an FTP quick-access entry (phone FTP server), so it's a config dependency. Installed manually on ratio 2026-07-14; velox needs it once it boots. Add to the install base so future machines get it for free; verify via VM test. From .emacs.d handoff 2026-07-14-1751.
-
** TODO [#C] Waybar modules run together — need subtle separators :bug:dotfiles:waybar:
Craig misreads where one module ends and the next begins — the wind (weather) value runs straight into the date with no visual stop, so he reads the wind figure as the start of the date. Add a light, subtle separator or spacing between adjacent Waybar modules.
Grading: Minor severity (legibility, nothing broken) x frequent (every glance at the bar) = P3 = [#C].
@@ -1622,6 +1578,8 @@ Root cause was in =retry_install=: =last_exit_code=$?= ran AFTER =if eval ...; t
*** 2026-05-19 Tue @ 01:25:26 -0500 Verified the b9907c7 emacs-stow fix end-to-end
=make test= 21:44 → 22:29 (42 min), =test-results/20260518-214516/=. 52/0/5, =ArchSetup Exit Code: 0=. The third-branch path fired correctly — install log =archsetup-2026-05-18-21-45-46.log:14358-14365= shows =From https://git.cjennings.net/dotemacs= → =[new branch] main -> origin/main= → =Reset branch 'main'= → =branch 'main' set up to track 'origin/main'=. No exit-128, no =fatal: not a git repository=. Error Summary down to 7 (was 13 on 2026-05-16); the emacs entry is gone. AUR exit-0 logging triggered for 2 packages this run (mkinitcpio-firmware, tidaler) vs 6 on 2026-05-16 — same bug class, fewer triggers, still tracked under =[#B] AUR exit-0 logged as error=. Issue Attribution: 1 ARCHSETUP entry (Proton VPN Daemon failed — known VM-no-VPN-config artifact). Cleanup ran clean via the normal path.
+** TODO [#C] Osbot camera configuration :chore:
+Craig's roam capture 2026-07-20, routed via .emacs.d sentry inbox-zero as archsetup-owned device setup: "configure osbot camera." Scope to define at pickup (device model, what "configure" covers — kernel module, v4l settings, default framing).
** TODO [#C] Re-check python-lyricsgenius --skipinteg workaround :chore:solo:
:PROPERTIES:
:LAST_REVIEWED: 2026-07-09
@@ -1659,34 +1617,10 @@ The goal is a single place to edit each config, not two.
:END:
Once-yearly systematic inventory of known deficiencies and friction points in current toolset
-** DONE [#D] Installer resume-idempotency cluster :bug:solo:
-CLOSED: [2026-07-20 Mon]
-Fixed in 8917f2f: extracted crontab_append_once (dedup guard), zfs_scrub_timer_units (one timer per pool, warn on none instead of @.timer), and enable_user_service (wants-symlink; gamemode now uses it and syncthing folds into the shared helper). TDD via tests/installer-steps/test_idempotency_cluster.py.
-Grading: Minor severity x rare edge case (re-run after a mid-step failure) = P4 = [#D]. Group of small non-idempotent / wrong-target spots.
-crontab log-cleanup line duplicates on resume (archsetup:1713 — guard on absence); zfs scrub timer picks an arbitrary pool via =head -1= and yields =@.timer= when empty (archsetup:1857); gamemode enabled via =systemctl --user= which the script itself documents fails at install time (archsetup:2419 — use the manual wants-symlink like syncthing). See findings doc (S2, S3).
-** DONE [#D] Installer unguarded chmod/cp after non-fatal ops :bug:solo:quick:
-CLOSED: [2026-07-20 Mon]
-Fixed in dd41036: extracted install_executable (guarded cp + chmod +x) for the two zfs scripts; guarded the two hypr-live-update-guard chmods inline with error_warn. TDD via tests/installer-steps/test_install_executable.py.
-Grading: Minor severity x rare edge case (only when a preceding non-fatal cp/clone failed) = P4 = [#D].
-With set -e off, unguarded chmod/cp hit missing/partial files silently: hypr-live-update-guard chmods (archsetup:2108/2144), zfs-replicate cp (archsetup:1820) leaving a service with a dead ExecStart, zfs-pre-snapshot cp (archsetup:1943) leaving a broken pacman hook. Fix: wrap each in =(...) >> log 2>&1 || error_warn=. See findings doc (S2, S3).
-** DONE [#D] normalize-notify-sounds temp/atomicity can corrupt tracked file :bug:solo:quick:
-CLOSED: [2026-07-20 Mon]
-Fixed in a29769e: resolves the real target via readlink -f, stages the temp beside it, guards on a non-empty encode, and atomically mv's into place (preserving the stow symlink); an EXIT trap cleans a leaked temp. TDD via tests/normalize-notify/ with fake ffmpeg.
-Grading: Minor severity (corrupts a repo-tracked sound file, recoverable via git) x rare (ffmpeg failure/interrupt) = P4 = [#D].
-scripts/normalize-notify-sounds.sh:39-46 has no EXIT trap on the mktemp and does =cat "$tmp" > "$f"= (truncate-first) where $f is a stow symlink into the repo; a zero-byte/failed encode writes a corrupt file. Fix: EXIT trap; =[ -s "$tmp" ]= guard; write $f.tmp and overwrite on success. See findings doc (S4).
** TODO [#D] Installer + scripts refactor opportunities :refactor:
Grading: no behavior change; parking lot. 10 refactors remain from the sentry audit — duplicated GPU-modalias scan, triple hand-rolled retry loop, stow x4, display_server/window_manager dispatch dup, Maia ELO range x3, per-script log helpers, GRUB/snapper/fsck sed clusters, waybar-battery positional sed. Full list with line numbers in [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (High/Medium/Low tagged). Pull individual ones out as their own tasks when tackled. The system-mutation sed clusters (snapper/fsck/GRUB/waybar) want characterization coverage before any rewrite.
*** 2026-07-20 Mon @ 16:35:00 -0500 Extracted validate_yesno and the NVIDIA_MIN_DRIVER constant
In 67d0b6e: the four yes/no config-validation blocks collapse into validate_yesno (TDD), and the driver-floor literal 535 becomes NVIDIA_MIN_DRIVER. The safe, purely-testable slice; the remaining 10 (structural / system-mutation) stay parked above.
-** TODO [#D] VM test-framework robustness cluster :bug:test:solo:
-Grading: Minor severity x rare edge case (each fires only in a narrow test-harness path) = P4 = [#D]. Group of four small framework bugs from the S5 audit.
-scripts/testing/debug-vm.sh:49 hardcodes the btrfs base disk, ignoring the profile-correct DISK_PATH from init_vm_paths (FS_PROFILE=zfs boots the wrong base or fatals); lib/vm-utils.sh:284 kill_qemu -9's and deletes the PID file without waiting, so a force-kill restore races the dying qemu's qcow2 lock and silently leaves the base image dirty (fix: wait for the PID); lib/vm-utils.sh:69 leaves PID_FILE/MONITOR_SOCK/SERIAL_LOG un-suffixed so parallel btrfs+zfs runs collide (fix: suffix by FS_PROFILE like DISK_PATH); run-test.sh:287 (and run-test-baremetal.sh:234) reports a completion-marker grep as ARCHSETUP_EXIT_CODE, not the installer's real exit — misleading since the installer runs set -e off and can error then still write the marker (fix: rename + capture the true status). Testinfra remains the real pass/fail backstop. See findings doc (S5).
-** TODO [#D] Gallery-widget prototype elisp bugs :bug:design:solo:quick:
-Grading: Minor severity x rare edge case (out-of-range input / cold byte-compile / interactive re-eval) = P4 = [#D]. Prototype code, all three Minor.
-docs/prototypes/gallery-widget.el:139 renders the readout from the unclamped value while the needle clamps 0-100, so at value 150 the needle pins at +60 degrees but the text reads "150%" (fix: clamp once, format both from it); :69 calls cl-loop without (require 'cl-lib) — works only via the autoload cookie, bites on a cold byte-compile (fix: add the require); :29 computes its dir from (or load-file-name buffer-file-name), both nil on interactive re-eval outside a load/file buffer (fix: fall back to default-directory). See findings doc (S7).
-** TODO [#D] Audit test-quality cluster (Python + elisp) :test:solo:
-Grading: no runtime behavior change; test-suite quality. Group of five weak/missing tests from the S6/S7 audit.
-scripts/testing/tests/test_desktop.py:96 passes a shell glob to `test -S`, which breaks on zero or multiple sockets (masked today because the test always skips); tests/gallery-tokens/test_gen_tokens.py:181 asserts properties too weak to notice the marker output is garbled (impossible input, so low); tests/gallery-widgets/test-gallery-widget.el:77 counts ticks via split-string + cl-count-if :start 1 (a coincidence of split semantics, not a match count); :47 tests the needle-angle helper's clamp but never the rendered readout at an out-of-range value (exactly why the S7 readout/needle bug ships green — add a gauge-level boundary case); :159 leaves gallery-widget-write-svg uncovered (add a Normal write-to-temp case). See findings doc (S6, S7).
** TODO [#D] Test-framework + prototype refactor cluster :refactor:
Grading: no behavior change; parking lot. Refactors from the S5-S7 audit, distinct from the installer refactor rollup above.
scripts/testing/run-test.sh + run-test-baremetal.sh duplicate the run/poll/report skeleton and have drifted (VM uses setsid + copy helpers, baremetal uses nohup + hand-rolled sshpass scp) — extract the shared core so baremetal inherits the sturdier paths; run-maint-nspawn.sh:66 + run-maint-scenarios.sh:78 duplicate the transport-independent _scenario_var/_validate_scenario/run_scenario (a sourced lib/maint-scenario.sh); run-test.sh:251,265 uses two different mechanisms (pgrep vs ps|grep) for the same liveness check; docs/prototypes/gen_tokens.py:78 repeats the section-iteration skeleton across four emitters; gallery-widget.el:95,136 hardcodes SVG arc/hub path strings that duplicate the cx/cy/radius geometry (dial desyncs silently on a constant change); gallery-widget.el:72,84 leans on the private svg--append. See findings doc (S5, S6, S7).
@@ -1882,3 +1816,97 @@ CLOSED: [2026-07-19 Sun]
Shipped dotfiles 9105361: manage.wifi_radio -> _connect_best_saved activates the strongest in-range saved profile on enable; nothing in range falls back to NM autoconnect.
When enabling WiFi, automatically connect to the highest-priority available
saved network instead of requiring a panel selection first.
+** DONE [#A] Tracked WireGuard private keys in repo — public leak, resolved :bug:security:network:
+CLOSED: [2026-07-20 Mon]
+Confirmed a live public leak, not just at-risk: git.cjennings.net runs cgit (scan-path=/var/git), so archsetup.git was anonymously cloneable over https. An unauthenticated clone pulled the configs with intact PrivateKeys. Exposed 2026-07-05 (c7b7d16) to 2026-07-20. Regraded to P1/[#A] (public credential exposure, severity-alone carve-out) from the initial [#B].
+Scope was wider than first found: the current 3 configs (assets/wireguard-config/wg-*.conf) plus 7 older ones at the pre-reorg path assets/wireguard/ (switzerland x2, USCALA/USCASF/USDC/USGAAT/USNY) — 10 config files, all with real keys.
+Resolution: Craig expired all the Proton WireGuard configs (keys dead). Purged all 10 from every commit with git filter-repo, force-pushed main + v0.5, and ran git gc --prune=now on the server bare repo. Verified via anonymous clone: zero real-key blobs reachable, all old exposed commits gone. Stopped tracking plaintext (gitignore + README, out-of-band configs only).
+Follow-ups filed below: harden cgit exposure; installer no longer ships configs.
+** DONE [#C] Installer chpasswd unguarded — unloggable primary user :bug:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed (fa3135a): extracted set_user_password, which guards the chpasswd with error_fatal so a failure aborts loudly instead of silently leaving no password. Fake-chpasswd test pins the guard fires on failure and stays quiet on success.
+Grading: Major severity (fresh system's primary user can't log in) x rare edge case (chpasswd seldom fails) = P3 = [#C].
+archsetup:1168 runs =echo "$user:$pass" | chpasswd= with no guard, then unsets the password next line; set -e is off (line 21), so a silent failure leaves no password and no log entry. Fix: guard with error_fatal (report + "set it by hand: passwd $user") before unsetting. See findings doc (S2).
+** DONE [#C] Installer nvme early module never built into initramfs :bug:solo:
+CLOSED: [2026-07-20 Mon]
+Fixed in e0d22bd: extracted ensure_nvme_early_module, which rebuilds the initramfs whenever it changed the conf (regardless of ZFS root) and scopes the presence check to the MODULES line. TDD via tests/installer-steps/test_ensure_nvme_early_module.py.
+Grading: Minor severity (module autoload still boots the system) x most-machines (all Craig's ZFS-root boxes) = P3 = [#C].
+archsetup:2910 writes MODULES=(nvme) but the only mkinitcpio -P in boot_ux runs =if ! is_zfs_root=, so on ZFS-root non-Framework machines the early-load hardening is never compiled in. Also archsetup:2918 greps the whole file for "nvme" (not the MODULES line). Fix: rebuild initramfs after the MODULES edit regardless of ZFS; scope the presence grep to =^MODULES=(=. See findings doc (S3).
+** DONE [#C] Installer disk-space pre-flight check is fragile :bug:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed in aef074f: extracted check_disk_space using df -P (wrap-safe) and a KB comparison (no truncation bias); non-numeric df output falls back to zero so a malformed read aborts loudly. TDD via tests/installer-steps/test_check_disk_space.py.
+Grading: Major severity (aborts a valid install) x some (df wraps long device names on a live ISO / device-mapper root) = P3 = [#C].
+archsetup:487 parses =df / | awk 'NR==2'=, which reads the device-name line (empty $4 -> 0 GB) when df wraps; archsetup:488 also integer-truncates the GB compare against the 20 GB floor. Fix: =df -P /= (single-line) or =df --output=avail=; compare in KB to avoid the rounding bias. See findings doc (S1).
+** DONE [#C] Installer run_step state + exit-code handling :bug:solo:
+CLOSED: [2026-07-20 Mon]
+Fixed in 6de55d2: run_step records the state marker whenever the step function returns (a return past error_fatal's exit means only a non-fatal warning is left), added local to run_step/show_status, and captured pacman's real exit in the refresh loop. TDD via tests/installer-steps/test_run_step.py.
+Grading: Major severity (resume re-runs steps and can abort on a survivable warning) x some (a step whose last action is a non-fatal failure) = P3 = [#C].
+archsetup:298 marks a step complete only when its function returns 0, but error_warn/run_task return 1, so a non-fatal-failing step never writes its marker and re-runs on resume. Also archsetup:1034 reports =$?= of the =false= test, not pacman's real exit code; and run_step locals (290/318) leak to global scope. Fix: step functions =return 0= explicitly (or gate run_step on a per-step error flag); capture the real exit code; add =local=. See findings doc (S1).
+** DONE [#C] cmail password decrypted world-readable before chmod :bug:security:solo:quick:cmail:
+CLOSED: [2026-07-20 Mon]
+Already fixed in dffecf5 (before this session): decrypt_to_secure wraps the gpg decrypt in a 0077-umask subshell so the file is 0600 from creation, with tests/cmail/ verifying the umask at write time. The task was stale; verified green and closed.
+Grading: security carve-out — brief local plaintext exposure of the mail password, requires a concurrent local shell during install; narrow window = low severity = P3 = [#C].
+scripts/cmail-setup-finish.sh:52 gpg-decrypts to ~/.config/.cmailpass at the process umask (often 0644), then chmod 600 on the next line. Fix: =(umask 077; gpg ... --output ...)= or decrypt to a mktemp 0600 file and mv into place (mirror the import-wireguard mktemp -d 0700 pattern). See findings doc (S4).
+** DONE [#C] Installer sudoers.pacnew blind copy risks lockout :bug:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed in c80e855: extracted replace_sudoers_pacnew, which runs visudo -cf on the pacnew and only copies a validated file (warns and keeps the working sudoers otherwise). TDD via tests/installer-steps/test_replace_sudoers_pacnew.py.
+Grading: Major severity (a malformed sudoers locks out privilege escalation) x rare edge case = P3 = [#C].
+archsetup:1146 does =[ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers= with no validation, right before the NOPASSWD rule at 1183. Fix: =visudo -cf /etc/sudoers.pacnew && cp ... || error_warn=. See findings doc (S2).
+** DONE [#C] WireGuard import leaves full-tunnel VPN live on failure :bug:solo:network:
+CLOSED: [2026-07-20 Mon]
+Fixed in 36daf76: the down now runs before the rename modify (targets the stable UUID), so a failed modify under set -e can't leave a live full-tunnel VPN. Added a connection-down case to fake-nmcli and two ordering tests.
+Grading: Major severity (all traffic silently routed through Proton until manual cleanup) x rare (nmcli modify failure) = P3 = [#C].
+scripts/import-wireguard-configs.sh:51-62 imports (which brings the 0.0.0.0/0 tunnel up), renames, then deactivates; under set -e a failed modify aborts before the down, leaving the tunnel live. Fix: bring the connection down right after parsing the UUID, before the rename. See findings doc (S4).
+** DONE [#C] net-scenarios diagnose failure exits green :bug:test:solo:
+CLOSED: [2026-07-20 Mon]
+Fixed in cf211cd: a diagnose miss sets a per-scenario rc carried to the subshell exit, so the run fails honestly while still running fix + assert. New harness at tests/net-scenarios/ drives the real script with stubbed ssh/rsync/jq.
+Grading: Major severity (a net-doctor diagnosis regression is reported as a passing run — false green on a diagnostic tool) x rare edge case (only when a diagnosis regresses and this first-draft harness is relied on) = P3 = [#C].
+scripts/testing/run-net-scenarios.sh:103 — the scenario_diagnose_expect else-branch prints fail "...diagnose did NOT name it" but never forces a non-zero subshell exit, so ( ... ) || fails=... leaves fails unincremented and the script prints "all scenarios passed" + exit 0. Fix: exit 1 in that branch like the other two checks. See findings doc (S5).
+** DONE [#C] pacman-hook-order test is a tautology :test:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed in 1b7236b: the test now extracts the hook filenames the installer writes and compares them against the stock 60-mkinitcpio-remove name (pacman's filename ordering is the real invariant, not source position). Mutation-verified: a 05->70 rename fails the new compare where the old literal compare stayed true.
+Grading: Major severity (guards boot-critical hook ordering — a reorder that removes the current initramfs without a rebuild is unbootable, and this test would ship it green) x rare (hook order rarely changes) = P3 = [#C].
+tests/installer-steps/test_pacman_hook_order.py:20 — the two assertLess calls compare string literals ("05..." < "60..."), a constant ASCII fact always true regardless of file content; the ordering the test exists to protect is never measured. Only the assertIn presence checks do real work. Fix: assert on positions — text.index("05-zfs-snapshot.hook") < text.index("60-mkinitcpio-remove.hook") (and the guard hook). See findings doc (S6).
+** DONE [#C] Add inetutils to install base :feature:solo:quick:network:
+CLOSED: [2026-07-20 Mon]
+Already done in 1115543 (earlier today): inetutils sits in install_required_software, with tests/installer-steps/test_required_software.py pinning it (test_installs_inetutils_for_ftp, green). The task was stale; verified and closed. The next full VM run covers the install-path verification.
+Original context: TRAMP's /ftp: method needs =/usr/bin/ftp= (GNU inetutils); dirvish has an FTP quick-access entry. Installed manually on ratio 2026-07-14. From .emacs.d handoff 2026-07-14-1751.
+** DONE [#D] Installer resume-idempotency cluster :bug:solo:
+CLOSED: [2026-07-20 Mon]
+Fixed in 8917f2f: extracted crontab_append_once (dedup guard), zfs_scrub_timer_units (one timer per pool, warn on none instead of @.timer), and enable_user_service (wants-symlink; gamemode now uses it and syncthing folds into the shared helper). TDD via tests/installer-steps/test_idempotency_cluster.py.
+Grading: Minor severity x rare edge case (re-run after a mid-step failure) = P4 = [#D]. Group of small non-idempotent / wrong-target spots.
+crontab log-cleanup line duplicates on resume (archsetup:1713 — guard on absence); zfs scrub timer picks an arbitrary pool via =head -1= and yields =@.timer= when empty (archsetup:1857); gamemode enabled via =systemctl --user= which the script itself documents fails at install time (archsetup:2419 — use the manual wants-symlink like syncthing). See findings doc (S2, S3).
+** DONE [#D] Installer unguarded chmod/cp after non-fatal ops :bug:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed in dd41036: extracted install_executable (guarded cp + chmod +x) for the two zfs scripts; guarded the two hypr-live-update-guard chmods inline with error_warn. TDD via tests/installer-steps/test_install_executable.py.
+Grading: Minor severity x rare edge case (only when a preceding non-fatal cp/clone failed) = P4 = [#D].
+With set -e off, unguarded chmod/cp hit missing/partial files silently: hypr-live-update-guard chmods (archsetup:2108/2144), zfs-replicate cp (archsetup:1820) leaving a service with a dead ExecStart, zfs-pre-snapshot cp (archsetup:1943) leaving a broken pacman hook. Fix: wrap each in =(...) >> log 2>&1 || error_warn=. See findings doc (S2, S3).
+** DONE [#D] normalize-notify-sounds temp/atomicity can corrupt tracked file :bug:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed in a29769e: resolves the real target via readlink -f, stages the temp beside it, guards on a non-empty encode, and atomically mv's into place (preserving the stow symlink); an EXIT trap cleans a leaked temp. TDD via tests/normalize-notify/ with fake ffmpeg.
+Grading: Minor severity (corrupts a repo-tracked sound file, recoverable via git) x rare (ffmpeg failure/interrupt) = P4 = [#D].
+scripts/normalize-notify-sounds.sh:39-46 has no EXIT trap on the mktemp and does =cat "$tmp" > "$f"= (truncate-first) where $f is a stow symlink into the repo; a zero-byte/failed encode writes a corrupt file. Fix: EXIT trap; =[ -s "$tmp" ]= guard; write $f.tmp and overwrite on success. See findings doc (S4).
+** DONE [#D] VM test-framework robustness cluster :bug:test:solo:
+CLOSED: [2026-07-20 Mon]
+Fixed in 866d327: profile-suffixed PID/monitor/serial paths, kill_qemu reaps-or-polls to death before the snapshot restore, debug-vm uses DISK_PATH, and both runners report an honest ARCHSETUP_COMPLETED marker instead of a fake exit code. TDD via tests/vm-framework/test_vm_utils.py (suffix red->green; kill_qemu as a contract pin).
+Grading: Minor severity x rare edge case (each fires only in a narrow test-harness path) = P4 = [#D]. Group of four small framework bugs from the S5 audit.
+scripts/testing/debug-vm.sh:49 hardcodes the btrfs base disk, ignoring the profile-correct DISK_PATH from init_vm_paths (FS_PROFILE=zfs boots the wrong base or fatals); lib/vm-utils.sh:284 kill_qemu -9's and deletes the PID file without waiting, so a force-kill restore races the dying qemu's qcow2 lock and silently leaves the base image dirty (fix: wait for the PID); lib/vm-utils.sh:69 leaves PID_FILE/MONITOR_SOCK/SERIAL_LOG un-suffixed so parallel btrfs+zfs runs collide (fix: suffix by FS_PROFILE like DISK_PATH); run-test.sh:287 (and run-test-baremetal.sh:234) reports a completion-marker grep as ARCHSETUP_EXIT_CODE, not the installer's real exit — misleading since the installer runs set -e off and can error then still write the marker (fix: rename + capture the true status). Testinfra remains the real pass/fail backstop. See findings doc (S5).
+** DONE [#D] Gallery-widget prototype elisp bugs :bug:design:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed in 552736e: shared clamp feeds needle + readout (150 renders 100%), explicit cl-lib require, and gallery-widget--source-dir with a default-directory fallback. TDD: 3 new ERT tests (clamp red->green; the other two land as pins since svg.el transitively loads cl-lib).
+Grading: Minor severity x rare edge case (out-of-range input / cold byte-compile / interactive re-eval) = P4 = [#D]. Prototype code, all three Minor.
+docs/prototypes/gallery-widget.el:139 renders the readout from the unclamped value while the needle clamps 0-100, so at value 150 the needle pins at +60 degrees but the text reads "150%" (fix: clamp once, format both from it); :69 calls cl-loop without (require 'cl-lib) — works only via the autoload cookie, bites on a cold byte-compile (fix: add the require); :29 computes its dir from (or load-file-name buffer-file-name), both nil on interactive re-eval outside a load/file buffer (fix: fall back to default-directory). See findings doc (S7).
+** DONE [#D] Audit test-quality cluster (Python + elisp) :test:solo:
+CLOSED: [2026-07-20 Mon]
+Fixed in 179fbd5 (plus 552736e for the gauge-level clamp test): socket check via find -type s, gen_tokens degenerate case pinned exactly as characterization, tick count as direct occurrences, and write-svg covered. All five items dispositioned.
+Grading: no runtime behavior change; test-suite quality. Group of five weak/missing tests from the S6/S7 audit.
+scripts/testing/tests/test_desktop.py:96 passes a shell glob to `test -S`, which breaks on zero or multiple sockets (masked today because the test always skips); tests/gallery-tokens/test_gen_tokens.py:181 asserts properties too weak to notice the marker output is garbled (impossible input, so low); tests/gallery-widgets/test-gallery-widget.el:77 counts ticks via split-string + cl-count-if :start 1 (a coincidence of split semantics, not a match count); :47 tests the needle-angle helper's clamp but never the rendered readout at an out-of-range value (exactly why the S7 readout/needle bug ships green — add a gauge-level boundary case); :159 leaves gallery-widget-write-svg uncovered (add a Normal write-to-temp case). See findings doc (S6, S7).
+** DONE [#B] Installer GRUB_CMDLINE overwrite drops boot params :bug:solo:
+CLOSED: [2026-07-21 Tue]
+Fixed in f9da097: update_grub_cmdline merges the current value with archsetup's tokens (existing tokens survive, same-key conflicts resolve to archsetup's value) behind a refuse-to-write safety check, via awk + mv with a backup_system_file first. TDD via tests/installer-steps/test_grub_cmdline.py (8 cases incl. cryptdevice/resume/zfs survival and idempotence).
+Grading: Critical severity (unbootable) x some-users-sometimes (machines whose base install set a cryptdevice=/resume=/zfs= cmdline param) = P2 = [#B].
+archsetup:3054 rewrites the whole GRUB_CMDLINE_LINUX_DEFAULT line with a fixed string; nothing re-adds a pre-existing cryptdevice/resume/zfs token, so grub-mkconfig (3059) can bake an unbootable config. Fix: read the current value and append only the missing tokens; assert any pre-existing boot-critical token survives before grub-mkconfig. See [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (S3).
+** DONE [#C] Maint status wall copy buttons :feature:maint:dotfiles:
+CLOSED: [2026-07-21 Tue]
+Shipped in dotfiles 8bc79ba per Craig's calls (one global button, rendered text): COPY on the doctor row serializes every category band via the same card_spec the GUI renders, through panelkit clipboard. TDD tests/maint/test_status_copy.py, full dotfiles make test green, inbox note sent. Live check pending: open the maint panel, press COPY, paste.
+Craig's roam capture 2026-07-20, routed via .emacs.d sentry inbox-zero as archsetup-owned UI work. Dotfiles maint panel work; archsetup drives it end-to-end per the standing rule.
diff --git a/working/hyprland-layoutmsg-crash/crash-report-2026-03-07-pid1812.txt b/working/hyprland-layoutmsg-crash/crash-report-2026-03-07-pid1812.txt
new file mode 100644
index 0000000..38ba88d
--- /dev/null
+++ b/working/hyprland-layoutmsg-crash/crash-report-2026-03-07-pid1812.txt
@@ -0,0 +1,184 @@
+--------------------------------------------
+ Hyprland Crash Report
+--------------------------------------------
+All these computers...
+
+Hyprland received signal 11(SEGV)
+Version: 4b07770b9ef1cceb2e6f56d33538aaffb9186b9c
+Tag: v0.54.1
+Date: Tue Mar 3 21:06:41 2026
+Flags:
+
+System info:
+ System name: Linux
+ Node name: ratio
+ Release: 6.18.16-1-lts
+ Version: #1 SMP PREEMPT_DYNAMIC Wed, 04 Mar 2026 18:09:01 +0000
+
+GPU:
+ c3:00.0 Display controller [0380]: Advanced Micro Devices, Inc. [AMD/ATI] Strix Halo [Radeon Graphics / Radeon 8050S Graphics / Radeon 8060S Graphics] [1002:1586] (rev c1)
+
+
+os-release:
+ NAME="Arch Linux"
+ PRETTY_NAME="Arch Linux"
+ ID=arch
+ BUILD_ID=rolling
+ ANSI_COLOR="38;2;23;147;209"
+ HOME_URL="https://archlinux.org/"
+ DOCUMENTATION_URL="https://wiki.archlinux.org/"
+ SUPPORT_URL="https://bbs.archlinux.org/"
+ BUG_REPORT_URL="https://gitlab.archlinux.org/groups/archlinux/-/issues"
+ PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/"
+ LOGO=archlinux-logo
+
+Libraries:
+Hyprgraphics: built against 0.5.0, system has 0.5.0
+Hyprutils: built against 0.11.0, system has 0.11.0
+Hyprcursor: built against 0.1.13, system has 0.1.13
+Hyprlang: built against 0.6.8, system has 0.6.8
+Aquamarine: built against 0.10.0, system has 0.10.0
+
+Backtrace:
+ # | Hyprland(_Z12getBacktracev+0x61) [0x5610bf4fa961]
+ getBacktrace()
+ ??:?
+ #1 | Hyprland(_ZN13CrashReporter18createAndSaveCrashEi+0xcd6) [0x5610bf45b666]
+ CrashReporter::createAndSaveCrash(int)
+ ??:?
+ #2 | Hyprland(+0x27d31e) [0x5610bf3a931e]
+ std::__format::_Formatting_scanner<std::__format::_Sink_iter<char>, char>::_M_format_arg(unsigned long)
+ ??:?
+ #3 | /usr/lib/libc.so.6(+0x3e2d0) [0x7f7a8be4d2d0]
+ ??
+ ??:0
+ #4 | Hyprland(+0x1f62fe) [0x5610bf3222fe]
+ std::__throw_bad_variant_access(unsigned int)
+ ??:?
+ #5 | Hyprland(_ZN6Layout10CAlgorithm9layoutMsgB5cxx11ERKSt17basic_string_viewIcSt11char_traitsIcEE+0x85) [0x5610bf575df5]
+ Layout::CAlgorithm::layoutMsg[abi:cxx11](std::basic_string_view<char, std::char_traits<char> > const&)
+ ??:?
+ #6 | Hyprland(_ZN6Layout6CSpace9layoutMsgB5cxx11ERKSt17basic_string_viewIcSt11char_traitsIcEE+0x37) [0x5610bf57aa07]
+ Layout::CSpace::layoutMsg[abi:cxx11](std::basic_string_view<char, std::char_traits<char> > const&)
+ ??:?
+ #7 | Hyprland(_ZN6Layout14CLayoutManager9layoutMsgB5cxx11ERKSt17basic_string_viewIcSt11char_traitsIcEE+0xce) [0x5610bf57275e]
+ Layout::CLayoutManager::layoutMsg[abi:cxx11](std::basic_string_view<char, std::char_traits<char> > const&)
+ ??:?
+ #8 | Hyprland(_ZN15CKeybindManager9layoutmsgENSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE+0x4f) [0x5610bf5a1cbf]
+ CKeybindManager::layoutmsg(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)
+ ??:?
+ #9 | Hyprland(_ZNSt17_Function_handlerIF15SDispatchResultNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEPS7_E9_M_invokeERKSt9_Any_dataOS6_+0x63) [0x5610bf5ceed3]
+ std::_Function_handler<SDispatchResult (std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >), SDispatchResult (*)(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)>::_M_invoke(std::_Any_data const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&&)
+ ??:?
+ #1 | Hyprland(+0x306750) [0x5610bf432750]
+ CHyprCtl::CHyprCtl()
+ ??:?
+ #11 | Hyprland(_ZNSt17_Function_handlerIFNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE20eHyprCtlOutputFormatS5_EPS7_E9_M_invokeERKSt9_Any_dataOS6_OS5_+0x6b) [0x5610bf44f17b]
+ std::_Function_handler<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > (eHyprCtlOutputFormat, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >), std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > (*)(eHyprCtlOutputFormat, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)>::_M_invoke(std::_Any_data const&, eHyprCtlOutputFormat&&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&&)
+ ??:?
+ #12 | Hyprland(_ZN8CHyprCtl8getReplyENSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE+0x57e) [0x5610bf42d4de]
+ CHyprCtl::getReply(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)
+ ??:?
+ #13 | Hyprland(+0x308772) [0x5610bf434772]
+ CHyprCtl::CHyprCtl()
+ ??:?
+ #14 | /usr/lib/libwayland-server.so.0(wl_event_loop_dispatch+0x1d2) [0x7f7a8cafa182]
+ ??
+ ??:0
+ #15 | /usr/lib/libwayland-server.so.0(wl_display_run+0x37) [0x7f7a8cafc297]
+ ??
+ ??:0
+ #16 | Hyprland(_ZN17CEventLoopManager9enterLoopEv+0x2c1) [0x5610bf631811]
+ CEventLoopManager::enterLoop()
+ ??:?
+ #17 | Hyprland(main+0x1476) [0x5610bf32b756]
+ main
+ ??:?
+ #18 | /usr/lib/libc.so.6(+0x276c1) [0x7f7a8be366c1]
+ ??
+ ??:0
+ #19 | /usr/lib/libc.so.6(__libc_start_main+0x89) [0x7f7a8be367f9]
+ ??
+ ??:0
+ #2 | Hyprland(_start+0x25) [0x5610bf393d35]
+ _start
+ ??:?
+
+
+Log tail:
+DEBUG ]: Hyprctl: new connection from pid 6904
+DEBUG ]: Hyprctl: new connection from pid 6918
+DEBUG ]: Hyprctl: new connection from pid 6920
+DEBUG ]: Hyprctl: new connection from pid 6923
+DEBUG ]: CConfigManager: file /home/cjennings/.config/hypr/hyprland.conf modified, reloading
+DEBUG ]: Using config: /home/cjennings/.config/hypr/hyprland.conf
+DEBUG ]: ApplyConfigToKeyboard for "video-bus", hasconfig: 0
+DEBUG ]: Not applying config to keyboard, it did not change.
+DEBUG ]: ApplyConfigToKeyboard for "power-button", hasconfig: 0
+DEBUG ]: Not applying config to keyboard, it did not change.
+DEBUG ]: ApplyConfigToKeyboard for "keychron-q6-pro-keyboard", hasconfig: 0
+DEBUG ]: Not applying config to keyboard, it did not change.
+DEBUG ]: Applied config to mouse keychron-q6-pro-mouse, sens 0.00
+DEBUG ]: Applied config to mouse keychron-q6-pro-keyboard-1, sens 0.00
+WARN ]: No rule found for DP-4, trying to use the first.
+DEBUG ]: Applying monitor rule for DP-4
+DEBUG ]: Monitor DP-4: requested preferred, using preferred mode 3440x1440@59.97Hz
+DEBUG ]: output DP-4 succeeded basic test on format DRM_FORMAT_XRGB8888
+DEBUG from aquamarine ]: drm: Modesetting DP-4 with 3440x1440@59.97Hz
+DEBUG ]: Monitor DP-4 data dump: res 3440x1440@59.97Hz, scale 1.00, transform 0, pos 0x0, 10b 0
+DEBUG ]: [CXDGOutputProtocol] updating all xdg_output heads
+WARN ]: FIXME: color management protocol is enabled and outputs changed, check preferred image description changes
+DEBUG ]: [CXDGOutputProtocol] updating all xdg_output heads
+WARN ]: No rule found for DP-4, trying to use the first.
+DEBUG ]: [CXDGOutputProtocol] updating all xdg_output heads
+DEBUG ]: arrangeMonitors: 1 to arrange
+DEBUG ]: arrangeMonitors: DP-4 auto [0, 0]
+DEBUG ]: arrangeMonitors: DP-4 xwayland [0, 0]
+DEBUG ]: [CXDGOutputProtocol] updating all xdg_output heads
+DEBUG ]: [CXDGOutputProtocol] updating all xdg_output heads
+DEBUG from aquamarine ]: drm: Cursor buffer imported into KMS with id 170
+ERR ]: CConfigWatcher: got an event for wd 1 which we don't have?!
+DEBUG ]: Hyprctl: new connection from pid 2089
+DEBUG ]: Hyprctl: new connection from pid 1894
+DEBUG ]: Hyprctl: new connection from pid 2089
+DEBUG ]: Hyprctl: new connection from pid 2089
+DEBUG ]: Hyprctl: new connection from pid 2089
+DEBUG ]: Hyprctl: new connection from pid 2089
+DEBUG ]: Hyprctl: new connection from pid 2089
+DEBUG ]: Hyprctl: new connection from pid 2089
+DEBUG ]: Hyprctl: new connection from pid 2089
+DEBUG ]: Hyprctl: new connection from pid 2089
+DEBUG ]: Hyprctl: new connection from pid 2089
+DEBUG ]: Hyprctl: new connection from pid 2089
+DEBUG ]: Hyprctl: new connection from pid 2089
+DEBUG ]: Hyprctl: new connection from pid 2089
+DEBUG ]: [CGammaControl] setGamma for DP-4
+DEBUG ]: [CGammaControl] setting to monitor DP-4
+DEBUG ]: Hyprctl: new connection from pid 6946
+DEBUG ]: Hyprctl: new connection from pid 6948
+DEBUG ]: Hyprctl: new connection from pid 6951
+DEBUG ]: Hyprctl: new connection from pid 6959
+DEBUG ]: Hyprctl: new connection from pid 6961
+DEBUG ]: Hyprctl: new connection from pid 6964
+DEBUG ]: Hyprctl: new connection from pid 6978
+DEBUG ]: Hyprctl: new connection from pid 6980
+DEBUG ]: Hyprctl: new connection from pid 6983
+DEBUG ]: Hyprctl: new connection from pid 7006
+DEBUG ]: Hyprctl: new connection from pid 7008
+DEBUG ]: Hyprctl: new connection from pid 7011
+DEBUG ]: Hyprctl: new connection from pid 7016
+DEBUG ]: Hyprctl: new connection from pid 7018
+DEBUG ]: Hyprctl: new connection from pid 7021
+DEBUG ]: [CGammaControl] setGamma for DP-4
+DEBUG ]: [CGammaControl] setting to monitor DP-4
+DEBUG ]: Hyprctl: new connection from pid 7033
+DEBUG ]: Hyprctl: new connection from pid 7035
+DEBUG ]: Hyprctl: new connection from pid 7038
+DEBUG ]: Keybind triggered, calling dispatcher (64, , 108, exec)
+DEBUG ]: Executing layout-resize grow
+DEBUG ]: Process Created with pid 7052
+DEBUG ]: Keybind triggered, calling dispatcher (64, , 108, exec)
+DEBUG ]: Executing hyprlock
+DEBUG ]: Process Created with pid 7053
+DEBUG ]: Hyprctl: new connection from pid 7055
+DEBUG ]: Hyprctl: new connection from pid 7057 \ No newline at end of file
diff --git a/working/hyprland-layoutmsg-crash/crash-report-2026-07-20-pid249443.txt b/working/hyprland-layoutmsg-crash/crash-report-2026-07-20-pid249443.txt
new file mode 100644
index 0000000..86b8cb0
--- /dev/null
+++ b/working/hyprland-layoutmsg-crash/crash-report-2026-07-20-pid249443.txt
@@ -0,0 +1,166 @@
+--------------------------------------------
+ Hyprland Crash Report
+--------------------------------------------
+Vaxry is going to be upset.
+
+Hyprland received signal 11(SEGV)
+Version: a0136d8c04687bb36eb8a28eb9d1ff92aea99704
+Tag: v0.55.4
+Date: Thu Jun 11 17:10:04 2026
+Flags:
+
+System info:
+ System name: Linux
+ Node name: ratio
+ Release: 6.18.25-1-lts-strix
+ Version: #1 SMP PREEMPT_DYNAMIC Thu, 30 Apr 2026 05:02:04 +0000
+
+GPU:
+ c3:00.0 Display controller [0380]: Advanced Micro Devices, Inc. [AMD/ATI] Strix Halo [Radeon Graphics / Radeon 8050S Graphics / Radeon 8060S Graphics] [1002:1586] (rev c1)
+
+
+os-release:
+ NAME="Arch Linux"
+ PRETTY_NAME="Arch Linux"
+ ID=arch
+ BUILD_ID=rolling
+ ANSI_COLOR="38;2;23;147;209"
+ HOME_URL="https://archlinux.org/"
+ DOCUMENTATION_URL="https://wiki.archlinux.org/"
+ SUPPORT_URL="https://bbs.archlinux.org/"
+ BUG_REPORT_URL="https://gitlab.archlinux.org/groups/archlinux/-/issues"
+ PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/"
+ LOGO=archlinux-logo
+
+Libraries:
+Hyprgraphics: built against 0.5.1, system has 0.5.1
+Hyprutils: built against 0.13.1, system has 0.13.1
+Hyprcursor: built against 0.1.13, system has 0.1.13
+Hyprlang: built against 0.6.8, system has 0.6.8
+Aquamarine: built against 0.12.0, system has 0.12.1
+
+Backtrace:
+ # | Hyprland(_Z12getBacktracev+0x73) [0x555c822eb7f3]
+ getBacktrace()
+ ??:?
+ #1 | Hyprland(_ZN13CrashReporter18createAndSaveCrashEi+0xc8c) [0x555c822328dc]
+ CrashReporter::createAndSaveCrash(int)
+ ??:?
+ #2 | Hyprland(+0x3bfade) [0x555c820f3ade]
+ void std::println<>(_IO_FILE*, std::basic_format_string<char>)
+ ??:?
+ #3 | /usr/lib/libc.so.6(+0x3e270) [0x7f58a9c3e270]
+ ??
+ ??:0
+ #4 | Hyprland(+0x1ce31e) [0x555c81f0231e]
+ std::__throw_bad_variant_access(unsigned int)
+ ??:?
+ #5 | Hyprland(_ZN6Layout10CAlgorithm9layoutMsgERKSt17basic_string_viewIcSt11char_traitsIcEE+0x85) [0x555c823859e5]
+ Layout::CAlgorithm::layoutMsg(std::basic_string_view<char, std::char_traits<char> > const&)
+ ??:?
+ #6 | Hyprland(_ZN6Layout6CSpace9layoutMsgERKSt17basic_string_viewIcSt11char_traitsIcEE+0x37) [0x555c823bf5c7]
+ Layout::CSpace::layoutMsg(std::basic_string_view<char, std::char_traits<char> > const&)
+ ??:?
+ #7 | Hyprland(_ZN6Layout14CLayoutManager9layoutMsgERKSt17basic_string_viewIcSt11char_traitsIcEE+0xcc) [0x555c8238193c]
+ Layout::CLayoutManager::layoutMsg(std::basic_string_view<char, std::char_traits<char> > const&)
+ ??:?
+ #8 | Hyprland(_ZN6Config7Actions13layoutMessageERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE+0x6c) [0x555c8285cd5c]
+ Config::Actions::layoutMessage(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)
+ ??:?
+ #9 | Hyprland(+0xad8837) [0x555c8280c837]
+ Config::Legacy::CDispatcherTranslator::run(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)
+ ??:?
+ #1 | Hyprland(_ZNSt17_Function_handlerIF15SDispatchResultRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEPS9_E9_M_invokeERKSt9_Any_dataS8_+0x25) [0x555c82819fb5]
+ std::_Function_handler<SDispatchResult (std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&), SDispatchResult (*)(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)>::_M_invoke(std::_Any_data const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)
+ ??:?
+ #11 | Hyprland(_ZN6Config6Legacy21CDispatcherTranslator3runERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES9_+0x256) [0x555c8280bc06]
+ Config::Legacy::CDispatcherTranslator::run(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)
+ ??:?
+ #12 | Hyprland(+0x6b8419) [0x555c823ec419]
+ void Log::CLogger::log<int>(Hyprutils::CLI::eLogLevel, std::basic_format_string<char, std::type_identity<int>::type>, int&&)
+ ??:?
+ #13 | Hyprland(+0x4d67e0) [0x555c8220a7e0]
+ CHyprCtl::CHyprCtl()
+ ??:?
+ #14 | Hyprland(_ZNSt17_Function_handlerIFNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE20eHyprCtlOutputFormatS5_EPS7_E9_M_invokeERKSt9_Any_dataOS6_OS5_+0x75) [0x555c82226d95]
+ std::_Function_handler<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > (eHyprCtlOutputFormat, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >), std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > (*)(eHyprCtlOutputFormat, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)>::_M_invoke(std::_Any_data const&, eHyprCtlOutputFormat&&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&&)
+ ??:?
+ #15 | Hyprland(_ZN8CHyprCtl8getReplyENSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE+0x3d6) [0x555c821ff826]
+ CHyprCtl::getReply(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)
+ ??:?
+ #16 | Hyprland(+0x4d7415) [0x555c8220b415]
+ CHyprCtl::CHyprCtl()
+ ??:?
+ #17 | /usr/lib/libwayland-server.so.0(wl_event_loop_dispatch+0x1d2) [0x7f58ab7a33d2]
+ ??
+ ??:0
+ #18 | /usr/lib/libwayland-server.so.0(wl_display_run+0x37) [0x7f58ab7a5567]
+ ??
+ ??:0
+ #19 | Hyprland(_ZN17CEventLoopManager9enterLoopEv+0x2c0) [0x555c82460620]
+ CEventLoopManager::enterLoop()
+ ??:?
+ #2 | Hyprland(main+0x1815) [0x555c81fac6d5]
+ main
+ ??:?
+ #21 | /usr/lib/libc.so.6(+0x27741) [0x7f58a9c27741]
+ ??
+ ??:0
+ #22 | /usr/lib/libc.so.6(__libc_start_main+0x89) [0x7f58a9c27879]
+ ??
+ ??:0
+ #23 | Hyprland(_start+0x25) [0x555c820dda75]
+ _start
+ ??:?
+
+
+Log tail:
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: Hyprctl: new connection from pid 1591853
+DEBUG ]: Hyprctl: new connection from pid 1591855
+DEBUG ]: Hyprctl: new connection from pid 1591858
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: Keybind triggered, calling dispatcher (64, , 104, exec)
+DEBUG ]: [executor] Executing layout-resize shrink
+DEBUG ]: [executor] Process created with pid 1591862
+DEBUG ]: Hyprctl: new connection from pid 1591864
+DEBUG ]: Hyprctl: new connection from pid 1591866 \ No newline at end of file
diff --git a/working/hyprland-layoutmsg-crash/issue-draft.md b/working/hyprland-layoutmsg-crash/issue-draft.md
new file mode 100644
index 0000000..57e7462
--- /dev/null
+++ b/working/hyprland-layoutmsg-crash/issue-draft.md
@@ -0,0 +1,43 @@
+crash: uncaught std::bad_variant_access in Layout::CAlgorithm::layoutMsg on `layoutmsg mfact` (v0.55.4, also v0.54.1)
+
+### Hyprland Version
+
+v0.55.4, commit a0136d8c04687bb36eb8a28eb9d1ff92aea99704 (Arch package 0.55.4-1). The same crash hit v0.54.1 (commit 4b07770b) on 2026-03-07. Both crash reports are attached.
+
+### Bug or Regression?
+
+It's a bug: the same backtrace appears on v0.54.1 and v0.55.4.
+
+### Description
+
+Hyprland segfaults when a `hyprctl dispatch layoutmsg mfact -0.05` arrives. The backtrace shows an uncaught `std::bad_variant_access` in the layout message path:
+
+```
+std::__throw_bad_variant_access(unsigned int)
+Layout::CAlgorithm::layoutMsg(std::basic_string_view<...> const&)
+Layout::CSpace::layoutMsg(...)
+Layout::CLayoutManager::layoutMsg(...)
+Config::Actions::layoutMessage(...)
+```
+
+The active layout was master, and the identical mfact dispatch had worked seconds earlier in the same session. Related edge cases reply gracefully instead of crashing, so it looks like one mfact path is missing a variant guard:
+
+- `layoutmsg mfact` with no focused window replies "mfact -> no window"
+- master-only messages sent while in monocle reply "Unknown monocle layoutmsg: swapwithmaster"
+
+### How to reproduce
+
+I don't have a deterministic reproducer. Both crashes came from a resize keybind that dispatches `layoutmsg mfact -0.05` (or `0.05`). What the session log shows in the minutes before today's crash:
+
+1. `general:layout` toggled between master and monocle several times during the session (long-lived session, 9 days).
+2. Two windows closed shortly before the crash. The log shows "On closed window, new focused candidate is [Window nullptr]".
+3. `togglefloating` dispatched twice.
+4. The mfact keybind fired and Hyprland segfaulted.
+
+My guess from the outside: per-workspace layout state (CSpace) ends up holding a different variant alternative than the one the active layout name implies. The mfact handler then does an unchecked `std::get`.
+
+### Crash reports
+
+Attached: today's report (v0.55.4) and the 2026-03-07 report (v0.54.1) with the identical backtrace through `Layout::CAlgorithm::layoutMsg`.
+
+System: Arch Linux, kernel 6.18.25-lts. GPU: AMD Strix Halo (Radeon 8060S). No plugins loaded.
diff --git a/working/hyprland-layoutmsg-crash/log-excerpts-2026-07-20-session.txt b/working/hyprland-layoutmsg-crash/log-excerpts-2026-07-20-session.txt
new file mode 100644
index 0000000..af2aad6
--- /dev/null
+++ b/working/hyprland-layoutmsg-crash/log-excerpts-2026-07-20-session.txt
@@ -0,0 +1,63 @@
+Log excerpts from the crashed Hyprland session (instance _1783789239_, ran 2026-07-11 12:00 -> 2026-07-20 16:26:52 CDT).
+Source: /run/user/1000/hypr/.../hyprland.log (tmpfs; extracted before reboot). Line numbers from the original 11,868,557-line file.
+
+== environment ==
+hyprland 0.55.4-1
+
+== proof monocle is a registered layout (graceful unknown-msg handling) ==
+198330:DEBUG ]: Hyprctl: dispatcher layoutmsg : mfact -0.05 -> Unknown monocle layoutmsg: mfact
+254456:DEBUG ]: Hyprctl: dispatcher layoutmsg : swapwithmaster -> Unknown monocle layoutmsg: swapwithmaster
+254458:DEBUG ]: Hyprctl: dispatcher layoutmsg : focusmaster master -> Unknown monocle layoutmsg: focusmaster
+254976:DEBUG ]: Hyprctl: dispatcher layoutmsg : swapwithmaster -> Unknown monocle layoutmsg: swapwithmaster
+254978:DEBUG ]: Hyprctl: dispatcher layoutmsg : focusmaster master -> Unknown monocle layoutmsg: focusmaster
+
+== guarded no-window mfact earlier in the session (line 10988160) ==
+DEBUG ]: Hyprctl: new connection from pid 3038571
+DEBUG ]: Hyprctl: new connection from pid 3038573
+DEBUG ]: Hyprctl: dispatcher layoutmsg : mfact 0.05 -> no window
+DEBUG ]: Hyprctl: new connection from pid 3038612
+DEBUG ]: Hyprctl: new connection from pid 3038614
+
+== last monocle->master toggling (lines ~11599073, 11753369) ==
+9964283:DEBUG ]: Hyprctl: keyword general:layout : monocle
+9968343:DEBUG ]: Hyprctl: keyword general:layout : master
+11599073:DEBUG ]: Hyprctl: keyword general:layout : monocle
+11753369:DEBUG ]: Hyprctl: keyword general:layout : master
+
+== last layout switch + immediately-working mfacts (lines 11753360-11753520) ==
+DEBUG ]: Keybind triggered, calling dispatcher (65, , 116, exec)
+DEBUG ]: [executor] Executing hyprctl keyword general:layout master && hyprctl keyword master:orientation left
+DEBUG ]: [executor] Process created with pid 1359114
+DEBUG ]: Hyprctl: keyword general:layout : master
+DEBUG ]: Hyprctl: keyword master:orientation : left
+DEBUG ]: [CWLCompositorResource] New wl_region with id 76 at 555c95821500
+DEBUG ]: [CWLCompositorResource] New wl_region with id 85 at 555c95c25820
+DEBUG ]: [executor] Executing layout-resize grow
+DEBUG ]: Hyprctl: dispatcher layoutmsg : mfact 0.05
+DEBUG ]: [executor] Executing layout-resize grow
+DEBUG ]: Hyprctl: dispatcher layoutmsg : mfact 0.05
+DEBUG ]: [executor] Executing layout-resize grow
+DEBUG ]: Hyprctl: dispatcher layoutmsg : mfact 0.05
+
+== pre-crash sequence: window closes, focus -> nullptr, togglefloating (lines ~11754750-11755050) ==
+DEBUG ]: [CGammaControl] setGamma for DP-4
+DEBUG ]: [CGammaControl] setting to monitor DP-4
+DEBUG ]: [CGammaControl] setGamma for DP-4
+DEBUG ]: [CGammaControl] setting to monitor DP-4
+
+== final 15 lines on disk (ring-buffer tail with the fatal keybind is in the crash report) ==
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254
+DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 \ No newline at end of file