diff options
| author | Craig Jennings <c@cjennings.net> | 2026-08-25 12:58:26 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-08-25 12:58:26 -0600 |
| commit | 77447d02dcc3d68abfbf6e097f8dc0b0f7d443ac (patch) | |
| tree | a522bb9275a7f842b25cfb747d819c352564bba3 | |
| parent | 03d044d573022eb156ee1b34f7f8df7a278d116a (diff) | |
| download | archsetup-77447d02dcc3d68abfbf6e097f8dc0b0f7d443ac.tar.gz archsetup-77447d02dcc3d68abfbf6e097f8dc0b0f7d443ac.zip | |
docs: draft the guarded-upgrade spec and file the open velox items
The topgrade freshness warning never clears because the stamp is written only when topgrade exits 0, and the live-update guard plus any failing ecosystem step makes that exit almost unreachable. The spec (DRAFT) weighs four ways to close that. Its open decisions are state versus recency, an armed boot-time oneshot, an arch-only boot run and a persistent one-shot arm flag.
Four tasks filed:
- the spec's decisions, review and decomposition
- ratio pulling .emacs.d before its Emacs 31.1 upgrade
- the stale lock-screen clock after a real sleep
- the Signal tray icon waybar can't read. Electron 43 registers it under a well-known bus name and answers property reads only there. Downgrading is closed off by the database schema.
The Lua-port task got its date and the sleep task carries the two Aug 23 hibernate failures. Wrap hygiene moved 42 aged resolved subtrees into the task archive.
| -rw-r--r-- | archive/task-archive.org | 632 | ||||
| -rw-r--r-- | docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org | 165 | ||||
| -rw-r--r-- | todo.org | 756 |
3 files changed, 921 insertions, 632 deletions
diff --git a/archive/task-archive.org b/archive/task-archive.org index 6c3eb1a..1dc69e1 100644 --- a/archive/task-archive.org +++ b/archive/task-archive.org @@ -1987,3 +1987,635 @@ Remaining work (re-graded C 2026-07-02 — the first-launch risk and the Makefil - Waypaper canonical decision (Craig): RESOLVED 2026-07-14 — dark-lion.jpg is canonical (dotfiles fea3e93), repo config.ini updated off the that-one-up-there.jpg placeholder. - Ratio check: RESOLVED 2026-07-14 — ratio's =~/.config/calibre= is a directory symlink into the repo (stow folded the dir), so the first-launch gap never existed there. - When velox is back online: run =make conflicts= / =make reset= there so its old conflict copy clears against the updated repo. (velox carries a separate boot-recovery task; check once it's reachable.) +** DONE [#A] Hyprlock lockout: AMD-iGPU DPMS invalidates the lock, session wedges :bug:hyprland:installer:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =a9391c9= + dotfiles =3046c9c=, both pushed; applied live to ratio and velox. Reboot ratio to activate the root fix (=amdgpu.runpm=0=); the watchdog covers until then. + +WHAT HAPPENED. Ratio's screen idle-locked, then wedged: hyprlock gone, the compositor still holding the ext-session-lock, no password prompt, recoverable only from a console. Recovered live with =hyprctl dispatch exec hyprlock= (=allow_session_lock_restore=true= was already set, so a replacement client adopted the dead lock). + +ROOT CAUSE (evidence, not the first guess). My first read was "hyprlock crashed on its screenshot buffer" — WRONG. Coredumps are captured here (two telega SIGSEGVs the same afternoon) and there is NO hyprlock coredump, so it did not segfault; memory was fine, so not OOM. The hyprland log shows the real chain: =Modesetting DP-4= / =Restoring crtc 86= (a display modeset) → =color management protocol is enabled and outputs changed= → =SessionLock.cpp:50 SessionLockSurface object remains but surface is being destroyed=. A display power cycle tore down the lock surface. Online research confirms it's a documented AMD-integrated-Radeon issue (hyprlock#953, Hyprland#5822): the GPU resources the lock client holds become invalid when the display powers down and back up. Ratio is a Strix Halo Radeon 8060S — exactly that hardware, and its cmdline already carried =amdgpu.dcdebugmask=0x10= + =no_vpe_idle_pg=1= display workarounds, a history of the same fragility. + +THE FIX, four layers, research-validated: +1. Root cause: =amdgpu.runpm=0= on the kernel cmdline (AMD only, added in =update_grub_cmdline= behind =detect_gpu_vendors=). Keeps GPU runtime PM from invalidating the resources on a display cycle. Live in ratio's grub.cfg; effective next boot. +2. Separate crash cause: =configure_hyprlock_pam= writes a complete =/etc/pam.d/hyprlock= (auth/account/session). The package default is =auth include login= only, so pam_end() crashes on uninitialised handles. Applied live to both machines. +3. Recovery net: the =screen-lock= watchdog (dotfiles) relaunches hyprlock on a non-zero exit; hypridle's =lock_cmd= routes through it. Independently the same shape as the community's watchdog layer. +4. NOT done, deliberately: the =dpms off= listener stays in the committed hypridle — =runpm=0= makes it safe on AMD, and it's wanted on Intel/velox for idle display-off. Ratio's test rail already removed it as a local choice. + +REVERTED a wrong turn: I'd first built a screenshot-to-file change (grim the desktop, point hyprlock at the file) on the theory the live screencopy buffer crashed. The research showed the cause is GPU runtime PM, not the background source, so I dropped it and reverted hyprlock.conf to =path = screenshot=. + +PROCESS NOTE — I hit the pathspec-commit trap AGAIN (the one the =Two agent sessions sharing one repo= VERIFY documents). After surgically staging only the =lock_cmd= line via =git update-index=, I ran =git commit <path> -m ...=, which commits the WORKING TREE of that path, not the index — so it committed ratio's test rail (dpms-off removed, timeout 450) with a message claiming dpms-off stays. Caught it before push, =git reset --soft=, re-verified. The rule: after =update-index=, commit with =git commit= (no pathspec), never =git commit <path>=. + +Tests: archsetup 372 (test_grub_cmdline AMD-runpm cases + test_hyprlock_pam, both call sites in CALL_SITES); dotfiles 3687 incl. tests/screen-lock. Each guard proven by deletion. + +Grading: Critical severity (full session lockout, console-only recovery) x rare edge case (needs an idle lock plus a display modeset on the AMD iGPU) = P2 = [#B by the matrix]. Raised to [#A] here because it stranded a live machine and the root fix needs a reboot to arm — worth Craig seeing at the top until he reboots ratio. +** DONE [#B] Adversarial review of the sentry run — six fixes reworked :bug:test:tooling:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Craig asked for a skeptical review of every sentry change. Eight agents covered all 23 code commits, each told to disbelieve by default and to answer three questions per commit: does the problem exist and is it reachable, is the fix correct or is there a better one, would each test fail with the fix reverted. Every finding below was re-verified by hand before acting on it. + +SIX COMMITS NEEDED WORK, now fixed: archsetup =1207ca5= (wipedisk), =96e12b5= (firmware trim), =560e1dd= (autologin), =3c2155d= (initramfs tabs); dotfiles =ec7229b= (tunnel import), =a81aa0e= (thumbnail sweep), =56807e5= (three residual guards), =c90ee34= (event-log isolation). Both suites green: archsetup 341, dotfiles 3687 on both gates. + +THE ONE THAT MATTERED MOST. =wipedisk= ran =blkdiscard -f= BEFORE the busy check. =-f= disables the exclusive open util-linux has used since 2.36, so on the exact case the round-11 commit reasoned about — the user picked the wrong disk — it discarded a live filesystem and only then let sgdisk fail, printing "could not clear the partition table ... run this again". Data gone, user told nothing happened. The ordering predates the sentry commit, but round 11 wrote reasoning about the busy-disk case into the comment and error text while leaving the discard first, which made the misreport worse in the one direction that costs something. Dropping =-f= makes the kernel's own O_EXCL the gate. + +THREE PATTERNS WORTH MORE THAN THE INDIVIDUAL FIXES: + +1. CALL SITES WENT UNTESTED IN FIVE SUITES. Every helper had thorough tests; not one proved it was called. Deleting the call left everything green — including the guard on a =pacman -Rdd= of twelve firmware packages, whose removal would have run the trim on ratio. Closed with =CALL_SITES= in =test_orchestrators= (nine pairs, static) and a wiring assertion in the settings suite. Static on purpose: the behavioural harness runs un-stubbed bodies for real, which is fine for an orchestrator and not for a leaf that removes packages. + +2. A NEW OUTCOME VALUE NEEDS EVERY CONSUMER WALKED, EVERY TIME. Done for the portal enum in round 3, skipped for the tunnel-import one in round 4 — where =import_configs= folded a disarm failure into "none imported (N failed)", the opposite of what happened, in the multi-select flow the GUI actually uses. + +3. MY FIXTURES TWICE CLAIMED A FIDELITY THEY DID NOT HAVE. The wipedisk fixture used this machine's real disk names, so five of six tests passed with the seam removed. The mkplaylist fake does a full =cat > /dev/null= drain while its docstring says it "drains stdin exactly when the real one would" — which is what let the wrong failure mode survive. + +AND ONE FINDING WAS DISPROVED OUTRIGHT: round 1's =a57c443= claimed ffmpeg drains the read loop so only the first track is processed. Measured under strace and driven end to end with real ffmpeg (three runs of three, four 120s mp3s), the loop never truncates. The hazard is real and =-nostdin= is right; the symptom was reasoned from shellcheck SC2095 and never run. Corrected in =a30741a=, along with the OpenVPN autoconnect claim and the "four consumers" undercount. + +ALL THREE NOW CLOSED, in dotfiles =c7cb40d= (pushed). =_restore_dot='s =noop= split into =already-on= and =not-managed=, so the step stops claiming a restore that never happened. =_disable_dot= checks its restart as well as its move, since moving the drop-in aside does nothing until resolved reloads. + +The thumbnail one could not be built as described, and that is worth recording. The cache name is a SHA-1 of realpath plus mtime, so no filename says which source it came from; per-source sweeping would mean changing the key format and invalidating every cached thumbnail. Bounding the growth gets the same result for less: a deferred sweep now trims to a 500-file ceiling, oldest first, because eviction is safe exactly where sweeping is not (an evicted thumbnail is rebuilt on the next warm pass, costing one decode and never a file). WHEN A FIX CANNOT BE BUILT AS SPECIFIED, SAY SO AND SOLVE THE ACTUAL HAZARD — the hazard here was unbounded growth, not imprecise attribution. +** DONE [#D] Repair tiers call an unverifiable service restart a failed one :bug:network:bluetooth:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =041d6b9= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). 7 new tests across =tests/bt/test_bt.py= and =tests/net/test_net.py=; dotfiles suite 3665 -> 3672, =make test= exit 0 on both gates. Each of the three guards proven a real gate by deleting it and watching the suite go red. + +Found in the 2026-07-24 sentry bug-hunt, round 14, on the cross-package =repair.py= diff that rounds 5-13 had left unspent. + +=cmd.service_active= is tri-state in both the net and bt packages, and its docstring says so outright: True, False, or None when systemctl itself can't answer (absent binary, or a timeout). Six callers. Three rule on it correctly — =bt/doctor._service_step= branches on None with "systemctl unavailable — can't check the service", and =net/diag= compares =is False= at both its call sites. Three tested it with plain truthiness: + +- =bt/repair.py= =repair_service_restart= +- =net/repair.py= =_service_restart= (the nm-restart and resolved-restart tiers) +- =net/repair.py= =repair_unmask_nm= + +So an unanswerable systemctl was reported as "bluetooth.service is still not active" / "NetworkManager still isn't running after a restart" — a statement about the service made on no evidence at all. Each then pointed the user at =journalctl -u <unit>=, which is the same systemd client stack that had just failed to answer. That last part is round 10's read again: an error message advertising a remedy it cannot honour. + +All three now report =warn= on None, with evidence naming the verification rather than the service, and a next action of checking systemd is reachable and re-running the doctor. Control flow is unchanged: =warn= was already a status both packages emit, both CLIs already exit non-zero on anything but =pass=, and =net/doctor= only inspects a repair step's status for the =dns-test= tier — every consumer was checked before the change, not after. (An adversarial re-review counted twelve, not four; all twelve handle =warn= correctly, so the conclusion held while the claim understated the work.) + +THE SEAM FOR THE TESTS, worth reusing: both suites already carry an exec-failure harness that plants a non-executable file on an emptied PATH, which is exactly what makes =cmd.run= return None. So the None case is reachable through the real code path with no mocking at all. Each test class asserts that premise first (=service_active= really is None in the sandbox) rather than assuming it. + +Grading: Minor severity (the claim is wrong but errs pessimistic — it says a repair failed when it may have worked, rather than falsely reassuring; nothing is damaged) x rare edge case = P4 = [#D]. Fixed rather than filed because the change is three branches and it completes a class — leaving two of three sites collapsed is the failure mode the round-6 =c2eb3e1= commit exists to remember. + +NOT PART OF THIS CLASS, checked and left alone: =settings/toggles.dim_state= is the only other genuine True/False/None helper in the tree, and both its callers pass the value through to the viewmodel rather than collapsing it. Every other "or None" in the packages is two-state (a value or nothing), where falsy handling is correct. +** DONE [#B] Firmware trim gated on a DMI field that never carries the vendor :bug:tooling:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =2e228f7= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_framework_firmware_trim.py=, 12 tests carrying the real DMI strings off both daily drivers. Each of the three conditions proven load-bearing by deleting it and watching the suite go red, and the old gate proven wrong by restoring it (4 failures). + +Found in the 2026-07-24 sentry bug-hunt, round 13, reading archsetup's remaining state-mutating steps. =trim_firmware= gated on =grep -qi "framework" /sys/class/dmi/id/product_name= and no Framework machine has "framework" in =product_name= — it lives in =sys_vendor=. Read live: velox is =Framework= / ="Laptop (13th Gen Intel Core)"=, ratio is =Framework= / ="Desktop (AMD Ryzen AI Max 300 Series)"=. The gate returns false on both, so the step has been a silent no-op on the exact hardware it was written for. velox IS trimmed today (=linux-firmware-{atheros,intel,realtek,whence}= and nothing else) but not by this code path. + +THE REPAIR IS WHERE THE DANGER IS, which is why this is worth reading twice. Swapping =product_name= for =sys_vendor= is the obvious one-word fix and it is wrong: ratio is a Framework Desktop, and =trim_firmware= runs =pacman -Rdd linux-firmware-amdgpu=, which takes the firmware its Ryzen AI Max iGPU needs to bring up a display. Today only the =grep -qi intel /proc/cpuinfo= second gate stands between ratio and that. So =is_framework_intel_laptop= wants three DMI facts — vendor Framework, and a model naming both Laptop and Intel — and the cpuinfo read stays as an independent second gate rather than the only one. + +Verified live after the change: velox TRIM=yes, ratio TRIM=no, where the old gate said no to both. + +Grading: Minor severity (the trim never happens; nothing breaks, the machine just carries ~550MB it was meant to shed) x every user, every time (every Framework Intel install, which is the whole population the step targets) = P2 = [#B]. The AMD-firmware removal is not graded separately because it never shipped — it is the hazard the fix is shaped to avoid. +** DONE [#B] Fresh install leaves the dotfiles repo permanently dirty :bug:tooling:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =c3b3617= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_mark_volatile_configs.py=, 8 tests against a fixture git repo with =sudo= stubbed on PATH. Every guard proven a real gate by deletion. A note went to =~/.dotfiles/inbox/= because =skip-volatile= now has an outside caller. + +Found in the 2026-07-24 sentry bug-hunt, round 13, diffing archsetup's =stow_dotfiles= against the dotfiles Makefile's =stow= target — two implementations of one operation, which is round 5's read applied across repos rather than across packages. + +The Makefile's =stow= target ends with =$(MAKE) skip-volatile=, setting git's skip-worktree bit on the four configs their apps rewrite in place (=btop=, =qalculate=, =calibre=, =waypaper=; the list is =volatile-configs=). archsetup stows inline with raw =stow= calls and never ran that step. So a machine archsetup installed goes dirty the first time one of those apps writes its config, and every later =git pull --ff-only= trips over paths the user never edited. Confirmed by grep: archsetup contains no =skip-volatile=, no =volatile=, and no =make stow= — yet both daily drivers carry the bits, so they came from a hand-run =make stow=, not the installer. ratio in fact carries seven, three more than =volatile-configs= lists, which is evidence the churn is real and ongoing. + +The fix calls the dotfiles target rather than copying its logic, so the volatile list stays single-source. Two details that are load-bearing: it runs *after* =git restore .= so the bit lands on a pristine tree, and it runs as the user, because root writing =.git/index= leaves it root-owned and the user's next git command then cannot update the index at all. A checkout with no Makefile is a quiet no-op — nothing to delegate to is not an error. + +DELIBERATELY NOT DONE: replacing the whole inline stow with =make -C "$dotfiles_dir" stow "$desktop_env"=. The Makefile stows =--target=$(HOME)=, which during an install is root's home, and it carries interactive conflict handling; archsetup stows =--target=/home/$username --adopt= as root on purpose. =skip-volatile= is the one target with no such coupling — it works on the repo through =git -C= and never reads HOME. + +Grading: Minor severity (a repo that reads dirty forever and pulls that need a stash; the workaround is one command) x every user, every time (every fresh install that stows dotfiles) = P2 = [#B]. +** DONE [#C] Unattended install blocks on an interactive prompt :bug:tooling:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =cbcb53f= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_configure_autologin.py= (11) and =tests/installer-steps/test_select_locale.py= (11). Every guard proven a real gate by breaking it and watching the suite go red: dropping the autologin unattended branch fails 1 (on the leftover-stdin assertion, which is the real gate — the drop-in still gets written because the read swallows the sentinel and treats it as "yes"); dropping the locale unattended branch fails 1; breaking either precedence rule fails 2. + +Found in the 2026-07-24 sentry bug-hunt, round 12, continuing through archsetup's own installer. Two members of one class, which is the point: round 10 fixed the third member and left these. + +THE CLASS: an advisory prompt — one that carries its own default — still reading stdin under =--config-file=, the documented unattended mode. Round 10 ruled on it for =nvidia_preflight='s rc-10 prompt. Two sites never got the ruling. + +1. =configure_autologin=. When =enable_autologin= is unset (=AUTOLOGIN= is optional, and =archsetup.conf.example= line 31 ships it commented out) and the root is encrypted, it prompted =Enable automatic console login for $username? [Y/n]= on a bare =read=. It runs from =configure_encrypted_autologin=, inside =boot_ux=, the last entry in =STEPS= — so an unattended install of an encrypted machine works for 40-60 minutes and then sits at a prompt nobody is watching. Under =curl | bash= it is worse: stdin is the script itself, so the read eats a line of source. + +2. =select_locale= (extracted from =preflight_checks= by this commit). The =Choice [1]:= menu fired whenever =/etc/locale.conf= carried no =LANG== and =LOCALE= was unset — also commented out in the example config. archsetup does not require an archangel install, and =configure_build_environment='s own "no LANG=" branch is proof it expects that state. + +Both now take the prompt's own default under =--config-file= and print an =[OK] ... (unattended, --config-file)= line saying so. An explicit =AUTOLOGIN=yes/no= or =LOCALE== still wins; the default only answers a question nobody can. + +WHAT MADE THEM TESTABLE, which is round 10's read (d) applied again: =configure_autologin= hardcoded =/etc/systemd/system/getty@tty1.service.d= and =select_locale= hardcoded =/etc/locale.conf=, so neither could run against a fixture — while their siblings =replace_sudoers_pacnew= and =ensure_nvme_early_module= both take a defaulted path argument for exactly that reason. Both now do. Zero shellcheck delta against HEAD; =make test-unit= 276 -> 298, exit 0. + +Grading: Major severity (unattended installation, a documented feature, does not complete; recoverable by pressing a key, no data loss) x some users, sometimes (needs unattended mode plus an omitted key) = P3 = [#C]. + +THE PROMPTS DELIBERATELY LEFT ALONE, because the class is "prompts with a default", not "all prompts": username (line 636) and password (648/650) have no default to take — there is no sane fallback for either, and =archsetup.conf.example= documents both as "If not set, you will be prompted". They also fire in =preflight_checks=, in the first second of the run, where a blocked prompt is visible rather than silent. The "Enter locale" sub-prompt is reachable only from menu choice 9, which unattended never picks. +** DONE [#C] wipedisk says "Disk erased." when it erased nothing :bug:tooling:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/wipedisk/test_wipedisk.py=, 6 tests running the real script against a fixture device directory with fake blkdiscard/sgdisk on PATH. All four guards proven real by deleting each and watching the suite go red. + +Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 30 lines, no tests, and the most destructive script in the repo. Not installed by the installer; it is run by hand from the checkout, which is why the frequency axis stays low. + +Three defects, all of which make the script's final word untrue: + +1. =sgdisk --zap-all= had its result discarded, and "Disk erased." printed unconditionally. sgdisk refuses a busy device — a mounted filesystem or a live md/LVM/ZFS holder — which is exactly what a user hits after picking the wrong disk. So the tool announced an erase it had not performed and exited 0. + +2. "Disk erased." overstates what the tool does even on success. =sgdisk --zap-all= destroys partition tables, not data, and =blkdiscard -f ... || true= deliberately tolerates a device that cannot discard. On a disk without discard support the script cleared the partition table and left every byte readable, while telling the user the disk was erased. That is the one path where the wrong belief has a privacy consequence — someone trusting the message before disposing of a drive. + +3. The prompt says "Select the disk id to use" and then listed every entry in =/dev/disk/by-id=. On this machine that is 18 entries of which 12 are =-partN= partitions (verified by listing it). The menu promised disks and offered partitions. + +Fix: whole disks only (globbed rather than =ls | grep=, so a name with whitespace cannot split into two menu entries); the zap's result is checked and a failure exits 1 naming the busy-device cause; the closing message reports what actually happened, and when discard was unsupported it says the data is still recoverable and points at =nvme format= / =hdparm= for a disposal-grade wipe. + +Grading: Major severity (the tool reports an outcome it did not achieve; in the disposal case that is a data-exposure consequence) × rare edge case (a hand-run helper the installer does not install, and defect 1 additionally needs sgdisk to fail) = P3 = [#C]. + +Worth recording about the tests rather than the code: two of the six passed against the unmodified script for the wrong reason. Without the =WIPEDISK_BY_ID= override the script read the real =/dev/disk/by-id=, so the harness was driving a menu of this machine's actual disks (harmless — the fake blkdiscard/sgdisk shadowed the real ones on PATH — but it was not testing the fixture). And =test_empty_by_id_directory= was not a gate at first: with the guard deleted the empty select menu still falls through to the confirm prompt, reads EOF and declines, so exit code and call log alone pass either way. It now asserts the message. +** DONE [#B] zfs-replicate reports success when every backup failed :bug:backup:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). Diagnostics moved to stderr; the loop counts failures and exits 1 when any dataset failed. New =tests/zfs-replicate/test_zfs_replicate.py=, 9 tests driving the real script with a fake syncoid and a fake ping on PATH (the =tests/zfs-pre-snapshot/fake-zfs= pattern). Both fixes proven real gates by reverting them: dropping the counter fails 3, putting =error()= back on stdout fails 1. + +Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 73 lines with no test file, installed by =configure_zfs_snapshots= as =/usr/local/bin/zfs-replicate= and run by =zfs-replicate.service=, a =Type=oneshot= on a nightly timer. Its exit code and its journal output are the only signals anyone ever sees. + +Two defects, both verified by running the script rather than argued: + +1. The full-replication loop caught each =syncoid= failure, warned, carried on, then printed "Replication complete." and exited 0 regardless. Driven with a fake syncoid failing all four datasets: four =[WARN] Failed= lines, then "Replication complete.", exit code 0. systemd records =Result=success=. A backup that has not run for months is indistinguishable from a working one — and the whole point of the tool is to have a copy when the primary is gone. + +2. =determine_host= runs inside a command substitution (=TRUENAS_HOST=$(determine_host)=) and its =error()= wrote to stdout. On an unreachable TrueNAS the message was captured into =TRUENAS_HOST= and discarded, and =set -e= then killed the script. Driven with both hosts unreachable: exit 1 and completely empty output. A nightly service failing with nothing in the journal to say why. + +Same class as three bugs already fixed this session — =_restore_dot= claiming "DNS-over-TLS restored" without checking, =portal_restore_watch= discarding its outcome, =import_config= returning ok on an unchecked modify. A mutating operation that reports a success it did not get. + +Grading: Critical severity (a backup system that reports success while backing nothing up; the failure surfaces only when the backup is needed — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs a ZFS root, a reachable TrueNAS, and the user enabling the timer by hand — archsetup deliberately does not enable it, and =findmnt -n -o FSTYPE /= on this machine says btrfs, so it is latent here) = P2 = [#B]. + +Left alone: =BACKUP_PATH="backups" # TODO: Configure actual path= is still an unresolved TODO in the destination, and single-dataset mode relies on =set -e= to propagate a syncoid failure rather than reporting it. Neither is a defect in the sense above; the TODO is Craig's call. +** DONE [#D] Wireless regdom is silently unset for a three-letter-language locale :bug:installer:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =249bb93=. =locale_country= matches the =_CC= group instead of counting characters, and =set_wireless_regdom= verifies the substitution landed rather than trusting sed's exit code. 16 tests. +=configure_networking= derives the wireless regulatory domain by fixed offset: =wireless_region="${current_lang:3:2}"=, with a comment reading "extract country code (positions 3-4)". That is correct only for a two-letter language code. + +=validate_config= accepts =^[a-z]{2,3}(_[A-Z]{2})?...=, so a three-letter language is a legal =LOCALE=, and glibc ships 75 of them (=agr_PE=, =ast_ES=, =ber_DZ=, =ayc_PE=, ...). Verified by running the expansion: =ber_DZ.UTF-8= yields =_D=, =ayc_PE.UTF-8= yields =_P=, =C= yields the empty string, =POSIX= yields =IX=. + +The sed that follows only uncomments an existing =#WIRELESS_REGDOM="XX"= line in =/etc/conf.d/wireless-regdom= (176 of them, owned by wireless-regdb). A garbage region matches nothing, sed exits 0, and the =|| error_warn= never fires — so the regdom is never set and nothing says so. The task line does print the garbage region ("configuring wireless regulatory domain (_D)"), so it is visible in the log rather than fully silent. + +Confirmed the mechanism itself works for the normal case: line 168 of this machine's =/etc/conf.d/wireless-regdom= reads =WIRELESS_REGDOM="US"= uncommented, which is archsetup's own edit. + +Grading: Minor severity (WiFi falls back to the conservative "00" regdomain — fewer channels and lower tx power, but WiFi works) × rare edge case (one of 75 three-letter-language locales, or a =LOCALE= with no country) = P4 = [#D]. + +Fix when it comes up: derive the country from the =_CC= group by pattern rather than by offset, and warn when it cannot be derived or when the sed changed nothing. Worth doing together with the sibling gap — nothing in the installer verifies that a =sed -i= uncomment actually matched, so a distro reshuffling one of these config files would fail the same silent way. All 22 =sed -i= sites share that stance, so it is a uniform design choice rather than an odd one out. +** DONE [#B] Initramfs hook swap can leave a LUKS machine unbootable :bug:installer:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The swap moved into =switch_udev_hook_to_systemd=, which declines when =hooks_need_busybox_init= sees a standalone =encrypt= token, and the caller now rebuilds the initramfs only when the conf actually changed. New =tests/installer-steps/test_switch_udev_hook.py=, 10 tests; both guards proven real by breaking them (removing the refusal: 4 failures; loosening the token match to a bare =encrypt= substring: 1 failure). + +Found in the 2026-07-24 sentry bug-hunt, round 10. =configure_initramfs_hook= ran =sed -i '/^HOOKS=/ s/\budev\b/systemd/'= on any non-ZFS root, then =mkinitcpio -P=. Its only guard was =is_zfs_root=. + +Why that breaks a LUKS machine, verified against the installed mkinitcpio rather than argued: +- =/usr/lib/initcpio/install/systemd= line 70 is =add_symlink /init usr/lib/systemd/systemd=, so the systemd hook replaces the busybox init outright. +- =/usr/lib/initcpio/hooks/encrypt= is an =#!/usr/bin/ash= script whose entire body is a =run_hook()= function — the busybox init's mechanism. Under systemd init nothing calls it. +- =mkinitcpio= carries no conflict check for the pairing (grepped; nothing), so the rebuild succeeds and archsetup reports success. +- This machine's own =/etc/mkinitcpio.conf= documents the two valid pairings as separate examples: =udev= + =encrypt= (line 45) and =systemd= + =sd-encrypt= (line 51). The sed converted half of the first pairing and produced neither. + +Effect: on a LUKS root using the standard busybox =encrypt= hook, archsetup rewrites HOOKS to =systemd= while leaving =encrypt= behind, rebuilds the initramfs, and exits cleanly. At the next boot the root is never unlocked. The machine needs live media and manual mkinitcpio surgery to recover. + +The sibling asymmetry: =is_encrypted_root()= already exists in this script and =configure_autologin= uses it to branch on exactly this condition. The initramfs step consulted neither it nor HOOKS. =merge_grub_cmdline='s own comment names =cryptdevice== as a boot-critical parameter to preserve — and =cryptdevice== is read only by the =encrypt= hook, so archsetup explicitly anticipates the configuration that another of its steps then breaks. + +Grading: Critical severity (the machine will not boot and recovery needs external media — graded on the harm once in the failure state, not on how rarely it is entered) × some users, sometimes (LUKS-encrypted non-ZFS root using the busybox =encrypt= hook; deterministic for those machines, absent everywhere else) = P2 = [#B]. + +Deliberately not attempted: migrating =encrypt= to =sd-encrypt=. That means rewriting the kernel cmdline from =cryptdevice== to =rd.luks.name== against the volume's UUID, which is a real migration and not a mechanical edit. Refusing the cosmetic swap keeps a working machine working, which is the right trade against quieter fsck output. +** DONE [#D] keymap and consolefont hooks are inert under the systemd initramfs :bug:installer:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =249bb93=. The swap rewrites both to =sd-vconsole=, collapsing them into one entry and never duplicating an existing one. The open question is answered: this machine is KEYMAP=us with no encrypt hook, but the function runs on LUKS machines where a non-US layout at the passphrase prompt is exactly what sd-vconsole restores. 7 tests. +Same class as the =encrypt= bug above, but cosmetic rather than boot-critical, so it was filed rather than bundled into that fix. + +Enumerating the busybox-only hooks on this machine (every hook under =/usr/lib/initcpio/hooks/= defining =run_hook=/=run_earlyhook=/=run_latehook=) gives: btrfs, consolefont, encrypt, grub-btrfs-overlayfs, keymap, memdisk, resume, sleep, udev, usr. All go inert once =/init= is systemd. Of those, =encrypt= is the only boot-critical one — =resume= is handled natively by systemd's hibernate-resume generator, and =btrfs= by udev rules (this machine runs =btrfs= alongside =systemd= and boots fine). + +=keymap= and =consolefont= are the live leftovers. Run =grep '^HOOKS=' /etc/mkinitcpio.conf= on this machine: the line carries =systemd= plus =keymap consolefont= and no =udev=, so archsetup's swap has already run here and both hooks are installed into the image and never executed. The systemd equivalent is the single =sd-vconsole= hook, which is what the distro's own systemd example on line 51 of =/etc/mkinitcpio.conf= uses. + +Effect: the early-boot console keeps the default font and keymap until =systemd-vconsole-setup= runs in the real root. =add_nvme_early_module= sets =FONT=ter-132n= in =/etc/vconsole.conf= expecting it to apply at that stage, so the configured font is briefly not what archsetup asked for. + +Grading: Cosmetic severity (a few seconds of default console font on a machine that boots normally) × some users, sometimes = P4 = [#D]. + +Fix when it comes up: have =switch_udev_hook_to_systemd= also rewrite =keymap consolefont= to =sd-vconsole= when it performs the swap, and add the fixture cases to =tests/installer-steps/test_switch_udev_hook.py=. Worth confirming first whether a non-US keymap is ever needed at the initramfs prompt on a machine that reaches this path. +** DONE [#B] NVIDIA Wayland preflight blocks dwm and headless installs :bug:installer:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The NVIDIA block moved out of =preflight_checks= into a new =nvidia_preflight= function that returns early unless =desktop_env= is =hyprland= and archsetup is the one installing drivers. New =tests/nvidia-preflight/test_nvidia_preflight_gate.py=, 11 tests; each of the three guards was proven a real gate by deleting it and watching the suite go red (3, 1, and 1 failures respectively). + +Found in the 2026-07-24 sentry bug-hunt, round 10, reading archsetup's own installer. =preflight_checks= called =nvidia_preflight_report= unconditionally and exited 1 on rc 11 (repo driver below the 535 Wayland floor, or =pacman -Si nvidia-utils= unable to answer). The check is Wayland-specific — every line it prints names Wayland/Hyprland — but it ran before any =desktop_env= branch and consulted neither =desktop_env= nor =skip_gpu_drivers=. + +Effect, proven empirically rather than argued (three scenarios driven against the extracted block): =DESKTOP_ENV=dwm= plus =--no-gpu-drivers= on an NVIDIA machine with an old repo driver aborts the install; so does =DESKTOP_ENV=none=. Neither install ever runs a compositor, and =--no-gpu-drivers= means the user installs the driver themselves. Worse, the abort's own fix hint reads "install with DESKTOP_ENV=dwm (X11) instead" — the one remedy it prints is the one it refuses to honor, so the user has no working workaround short of editing the script. + +The sibling asymmetry that makes it an oversight rather than a decision: =install_gpu_drivers= returns early on =skip_gpu_drivers=, and =display_server= / =window_manager= both branch on =desktop_env= with a =none= arm that skips outright. The preflight gate applied neither ruling. + +Second defect at the same site, fixed in the same commit: the rc-10 path (card detected, driver fine) prompts with a bare =read=. =--config-file= is documented as "unattended installation", and =aur_install= already rules that a prompt not covered by =--noconfirm= "blocks forever waiting for input" on a headless install. The rc-10 prompt is advisory, so it now answers itself with its own =[Y/n]= default when a config file was supplied. rc 11 stays a hard stop either way. + +Grading: Critical severity (archsetup cannot be run at all on that machine, and the printed workaround does not work — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs an NVIDIA card, a repo driver below the floor or an unsynced pacman db, and a non-hyprland =desktop_env=; hyprland is the default and Craig's own machines are AMD and Intel) = P2 = [#B]. + +Noted, not fixed: =display_server= and =window_manager= both point their unknown-value hint at a =--desktop-env= flag that the argument parser does not implement. Both arms are unreachable today (=validate_config= rejects a bad =DESKTOP_ENV=, and without a config file the value is always the default), so it is a stale string rather than a live defect. +** DONE [#B] mkplaylist retags only the first file :bug:music:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =a57c443= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). =ffmpeg -nostdin= on the conversion call. New =tests/mkplaylist= suite, 12 tests; removing the flag turns the suite red (verified by reverting: 5 failures, green on restore). NOTE: the fake ffmpeg does a full =cat > /dev/null= drain, which the real one does not do — so the suite gates the flag's presence, not the production failure mode. The docstring claiming the fake "drains stdin exactly when the real one would" is false and should be corrected. +Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2095). =common/.local/bin/mkplaylist=: =generate_music_m3u= pipes the file list into =tag_music_file= (line 130), which consumes it with =while IFS= read -r file=. Inside that loop, =ffmpeg -i "$file" -vn -c:a flac "$outputfile"= (line 46) reads stdin by default for its interactive keyboard controls, so it consumes bytes the loop is relying on. + +CORRECTION (2026-07-24, from an adversarial re-review): the failure mode stated above — "the loop sees EOF and exits after the first file" — is WRONG, and this task originally asserted it. Measured under strace, ffmpeg polls fd 0 and reads roughly one byte per half-second of transcode wall time; flac encoding runs about 2000x realtime, so a ten-minute mp3 converts in ~0.28s and yields zero or one stolen byte, never a drain. Driven end to end with real ffmpeg against four 120s mp3s, three runs of three: all four were converted and retagged every time. The loop never truncated. + +What is real is the hazard, not the observed symptom: one stolen byte mangles a path, which makes mid3v2/metaflac fail and =set -e= abort the run loudly. =-nostdin= is still the right fix and the commit still stands. The original finding came from shellcheck SC2095 plus reasoning, and was never run — which is exactly what "verify before filing" exists to prevent. + +Effect: on a directory of non-flac audio, only the first file is converted and retagged. Files 2..N are silently skipped — no error, no output, and the playlist itself still generates (a separate =find=), so nothing signals that the retagging stopped. + +Grading: Major severity (the retagging feature is broken past the first file, and it fails silently) × most users frequently (the script exists to batch-process a directory, so more than one non-flac file is the normal case) = P2 = [#B]. + +Fix: =ffmpeg -nostdin= (or =< /dev/null= on the call). Verifiable with a fake =ffmpeg= on PATH asserting it is invoked once per input file. +** DONE [#C] timezone-change prints command-not-found instead of its help :bug:tooling:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review), together with the Portugal-zone defect below. New =tests/timezone-change= suite, 12 tests. +Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2288). =common/.local/bin/timezone-change=, default =*)= case (lines 63-67): =echo= sits alone on its own line, so the following quoted string runs as a *command* rather than as its argument. + +#+begin_src sh +*) + echo + "Invalid option chosen." + echo + "Some valid options are: eastern, central, pacific, rome, london, st_lucia, italy, france, spain ." + ;; +#+end_src + +The user gets two blank lines and two =command not found= errors; the list of valid options never prints. The timezone is correctly left unchanged, so this is an output defect only. + +Grading: Minor severity (wrong output on an error path, nothing corrupted) × some users sometimes (only on an unrecognized option) = P3 = [#C]. + +Fix: fold each string into its =echo=. Verifiable by running the script with a bogus argument and asserting the option list appears on stdout. +** DONE [#C] Thumbnail sweep wipes the whole cache when a wallpaper source is unreadable :bug:settings:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =0bd8c67= (committed locally, deliberately NOT pushed — held for Craig's morning review). 8 new tests. +Found in the 2026-07-24 sentry bug-hunt, reviewing the orphan sweep shipped the night before (dotfiles =e752a16=). =os.walk= stays silent about a directory it cannot enter, so =wallpaper.scan_sources= returns =[]= for a source that is missing, renamed, or permission-denied — the same answer it gives for a gallery the user emptied on purpose. =settings/cli.py= tick then hands that empty list to =thumbstore.sweep_orphans=, =live_names= comes back empty, and every cache-shaped file is classified an orphan. + +Proven empirically rather than reasoned: seeding three well-formed thumbnails plus a stray README, then sweeping against a nonexistent source directory, deleted all three (the README survived, so the cache-name regex guard works — it just doesn't help here). + +Effect once entered: the entire persistent thumbnail cache is deleted, so the next wallpaper-view open pays the cold-decode cost the cache was built to remove (measured at 3.7s for a viewport of Craig's largest 8, which is what tripped the compositor's kill prompt), and the tick needs roughly ten idle beats — about twenty minutes — to rewarm at =WARM_PER_BEAT= 8. + +Grading: Major severity (grading the being-in-it, per the don't-double-count-rarity rule: the cache is gone, the original freeze returns, and recovery is unattended and slow) × rare edge case (both configured sources — =~/videos/wallpaper= and =~/pictures/wallpaper= — are local directories, so this needs one deleted, renamed, or made unreadable while a beat fires; a removable or network source would hit it routinely) = P3 = [#C]. + +Fixed in this session: new =wallpaper.sources_available(sources)= tells "readable and empty" apart from "could not read", and =sweep_orphans= grew a =sources_ok= parameter that declines to sweep when it is False. Deferring a sweep costs only some stale files; sweeping wrongly costs the whole cache. +** DONE [#C] timezone-change sets a nonexistent zone for Portugal :bug:tooling:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review). =Europe/Lisbon=. The suite also pins the general invariant: every zone the script can emit must exist in tzdata, so a future bad entry fails at test time rather than in Craig's hands. +Found in the 2026-07-24 sentry bug-hunt, validating every zone the script sets against =/usr/share/zoneinfo=. =common/.local/bin/timezone-change= line 39 maps =portugal= / =lisbon= to =Europe/Portugal=, which is not a tzdata identifier — the real one is =Europe/Lisbon= (a bare =Portugal= legacy alias also exists at the top level, but not under =Europe/=). =timedatectl set-timezone "Europe/Portugal"= fails, so the timezone is never changed. + +The other 17 zones the script sets all resolve correctly, so this is the single bad entry. + +Grading: Major severity (the option is wholly broken — the zone is not set and the command errors) × rare edge case (one option of eighteen, hit only when actually switching to Portugal) = P3 = [#C]. + +Fix: =Europe/Lisbon=. Verifiable by asserting the argument handed to a fake =timedatectl=, plus a suite-wide check that every zone the script names exists in the tzdata database. +** DONE [#C] settings-project stop() can SIGTERM an unrelated process :bug:settings:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 2, reviewing =settings/src/settings/project.py=. =stop()= read a pid out of =$XDG_RUNTIME_DIR/settings-project.pid= and SIGTERMed it with no check that the pid still belonged to the projection. A projection that dies without running =stop()= (crash, OOM, a failed =execvpe= on the clock path — that last one was already noted as tolerated residue) leaves the file behind, so once the kernel wraps its pid counter that pid can name something else entirely, and the next =start= or =stop= kills it. + +This is a hazard the codebase had already ruled on elsewhere and simply hadn't applied here: =maint/src/maint/doctor.py= revalidates =/proc/<pid>/comm= against the expected name before its KILL remedy fires, explicitly to refuse recycled pids. + +Grading: Major severity (grading the being-in-it — an arbitrary user process takes a SIGTERM, and an editor with unsaved work is a plausible victim) × rare edge case (needs an unclean exit *and* pid reuse; =pid_max= here is 4194304, so wrap-around takes a very long time) = P3 = [#C]. + +Fixed as dotfiles =722994e= (committed locally, deliberately NOT pushed — held for Craig's morning review). The pidfile now records the process start time from =/proc/<pid>/stat= next to the pid, and =stop()= fires only when the recorded value still matches the live process. Start time is the right token rather than =comm=: it is mode-independent (the clock channel execs into =python3=, so comm changes while comm-matching would have needed per-mode knowledge) and it is exec-stable, verified directly — pid and start time were identical either side of an =execvpe=. A recycled pid cannot reproduce it. Legacy bare-pid pidfiles keep the old unconditional behavior so the upgrade never strands a live projection. +** DONE [#C] wtimer alarms fire an hour off on the eve of a DST change :bug:timer:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 3, reading =timer/src/timer/engine.py=. =parse_alarm= resolves a bare wall-clock time ("07:00") to its next occurrence: it builds today's instant, and when that is already past it rolled forward with =epoch += 86400=. A DST day is 23 or 25 hours long, so a fixed 86400 lands on the wrong wall time whenever tomorrow crosses a transition. + +Reproduced against America/Chicago and the two 2026 US transitions. Asking for =07:00= at 08:00 on Sat 2026-03-07 (spring forward that Sunday) gave 08:00 Sunday — an hour late. Asking for =07:00= at 08:00 on Sat 2026-10-31 (fall back that Sunday) gave 06:00 Sunday — an hour early. + +The recurring path was never affected, which is what makes this an oversight rather than a design choice: =next_alarm= walks candidate days and rebuilds =datetime(y, m, d, hh, mm)= per day, so it is already DST-correct. Only the one-shot rollover took the shortcut. Both were pinned by the new tests. + +Grading: Major severity (grading the being-in-it — an alarm that fires an hour off has wholly failed at the one thing an alarm does, and the fall-back direction wakes you early while the spring-forward direction lets you oversleep) × rare edge case (two nights a year, and only when the requested wall time has already passed today) = P3 = [#C]. + +Fixed as dotfiles =9b6c2c9= (committed locally, deliberately NOT pushed — held for Craig's morning review). The rollover now rebuilds the local time on tomorrow's calendar date, the same construction =next_alarm= uses. Eight tests pin =TZ=America/Chicago= (saved and restored around each case), covering both transitions, the twelve-hour form, an ordinary-day control, a DST eve where the requested time is still ahead, and two characterization cases asserting the recurring path stays DST-safe. +** DONE [#B] net portal-restore claims encrypted DNS is back without checking :bug:net:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 3, reading =net/src/net/repair.py=. A captive-portal login moves the DNS-over-TLS drop-in aside so plain DNS can reach the venue's login page, and =_restore_dot()= moves it back afterwards. It fired both privileged steps — the =mv= and the =systemctl restart systemd-resolved= — and returned ="restored"= without reading either result. =repair_portal_restore()= then rendered a pass step reading "DNS-over-TLS restored". + +So a declined or failed =sudo -n mv= left DNS-over-TLS off while the tool told the user it was back on. The same for a resolved restart that fails: the drop-in is on disk but the running resolver is still serving plain DNS. + +The asymmetry is what makes it an oversight rather than a decision. The sibling =_disable_dot()=, twenty lines up, checks its own move with =_ok()= and returns False rather than claiming a success it did not get. The restore half simply never got the same treatment, and it is the half where the failure is silent — the disable path's failure is visible immediately because the portal page won't load. + +Grading: graded on severity alone under the privacy carve-out. DNS queries continue in cleartext to the venue resolver on an untrusted network, and the affirmative "restored" message is what removes the user's reason to check. Bounded by =net diagnose='s =encrypted-dns= step, which exists precisely to catch a portal run that never restored, so the exposure ends at the next diagnose rather than persisting unseen forever. Major severity = P2 = [#B]. + +Fixed as dotfiles =018c0c5= (committed locally, deliberately NOT pushed — held for Craig's morning review). Both privileged steps are now checked, with two new outcomes: ="failed"= when the move back fails (encrypted DNS still off, rendered as a fail step) and ="unapplied"= when the drop-in is back but resolved would not restart (rendered as a warn step). Each names the command to run by hand. Four tests cover both failures at the =_restore_dot()= and step levels, mirroring the existing declined-move test on the disable side. +** DONE [#B] the portal restore watcher fails silently, so DNS stays in the clear :bug:net:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 4, reading the rest of =net/src/net/repair.py= after the round-3 fix above. =portal_restore_watch()= polls until the link comes back online, calls =_restore_dot()=, and discards the outcome entirely. + +Three things compound into a silent failure. The watcher is spawned detached with =stdin=, =stdout=, and =stderr= all on =/dev/null=, so nothing it could print reaches anyone. It runs outside the =repair()= dispatch, so unlike every other mutating tier it never wrote an event-log line either. And =repair_portal_login= tells the user "encrypted DNS restores itself once you're online", which is precisely what removes their reason to check. A ="failed"=, ="unapplied"=, or ="ambiguous"= restore therefore left the machine on plain DNS on a venue network with no signal at any level. + +This is the round-3 finding one layer out, and the asymmetry is the tell: =018c0c5= taught =repair_portal_restore()= — the *manual fallback* — to stop claiming a success it did not get, while the *automatic* path, the one that actually runs in the normal flow, kept dropping the same result on the floor. Fixing the fallback and leaving the primary silent is a worse split than the original bug. + +Grading: graded on severity alone under the privacy carve-out, exactly as the round-3 sibling. Same exposure (cleartext DNS to an untrusted venue resolver), same bound (=net diagnose='s =encrypted-dns= step catches the stranded state), and the same affirmative promise removing the reason to look. Major severity = P2 = [#B]. + +Fixed as dotfiles =601c5b4= (committed locally, deliberately NOT pushed — held for Craig's morning review). The watcher now returns the outcome, appends a =portal-restore-watch= event with it, and fires a persistent =notify security= alert on each of the three failing outcomes, each naming the command to run by hand. A clean restore stays silent. Five tests: one per failing outcome, one pinning the silence on a clean restore, and one on the event-log line. The whole =TestPortalLogin= class now shadows =notify= with a logging fake, so no future watcher test can fire a real desktop notification mid-suite. +** DONE [#D] dns-override failure path says "reverted" without checking :bug:net:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =2cf3fb3=. The revert is checked; a declined one now says 1.1.1.1 is still set and names =resolvectl revert <iface>=. +Found in the 2026-07-24 sentry bug-hunt, round 3, sweeping for siblings of the portal-restore finding above. =net/src/net/repair.py=, =repair_dns_override()= failure path: when the 1.1.1.1 override doesn't restore resolution, it calls =priv.run("dns-revert", iface)=, discards the result, and returns evidence reading "override didn't restore resolution — reverted". A failed revert leaves 1.1.1.1 set on the link while the step says it was removed. + +Same defect class as the portal-restore bug, three hundred lines up in the same file, and it survived the sweep only because the consequence is much smaller. Every other mutating repair in this file verifies by re-measuring afterwards rather than by reading an exit code, which is the stronger pattern and is why the sweep otherwise came back dry. + +Grading: Minor severity (a stale per-link override sends DNS to Cloudflare instead of the venue resolver, it dies on the next reconnect, and =net diagnose='s =dns-override-present= step exists specifically to catch it) × rare edge case (needs the override to fail *and* the revert to fail) = P4 = [#D]. + +Fix: the same idiom the portal-restore fix now uses. Wrap the revert in =_ok()= and drop the "— reverted" claim (or say the revert failed and name =resolvectl revert <iface>=) when it returns False. The existing =RepairHarness= makes the privileged call fail with =NET_SUDO="false"=, so the test is a near-copy of =test_restore_reports_failure_when_the_move_back_is_declined=. +** DONE [#B] a timezone-less Date header crashes the whole net diagnose run :bug:net:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/diag.py=. =_clock_skew_s()= fetches the probe server's =Date= header with =curl -sI=, parses it with =parsedate_to_datetime=, and subtracts it from a timezone-aware =datetime.now(timezone.utc)=. RFC 5322 allows a =Date= to carry =-0000=, which means UTC while explicitly claiming no local zone, and a =Date= with no zone at all parses leniently as well. Both come back *naive*, and subtracting a naive datetime from an aware one raises =TypeError=. + +The =try= wraps only the =parsedate_to_datetime= call, so the =TypeError= from the line below it is uncaught. It escapes =_clock_skew_s=, escapes =_steps_egress_edges=, and takes down the entire =diagnose()= run — no report, no steps, a Python traceback. =net doctor= runs diagnose first, so the panel's doctor button dies with it. + +Verified against Python 3.14.6 before writing the fix: =parsedate_to_datetime("Thu, 01 Jan 2020 00:00:00 -0000")= returns =tzinfo=None=, and the subtraction raises. The zoneless form behaves the same. Only the =GMT= form (which the well-behaved probe host sends) comes back aware, which is why this never showed up in normal use. + +What makes it more than a curiosity is *when* the code runs. =_steps_egress_edges= fires only after the http-probe has already failed, so the server answering that =HEAD= is frequently a captive portal's interception appliance rather than the real probe host — and a minimal embedded HTTP stack is exactly the kind that emits a non-GMT =Date=. The one path guaranteed to be talking to a non-standard server is the one that can't survive a non-standard header. + +Grading: Major severity (grading the being-in-it — the diagnostic tool produces no report at all, and =net doctor= goes with it, on precisely the broken network it exists to diagnose) × rare edge case (needs a failing probe *and* a portal appliance that omits a numeric offset) = P2 = [#B]. + +Fixed as dotfiles =8933500= (committed locally, deliberately NOT pushed — held for Craig's morning review). A naive parse is now read as UTC, which is what =-0000= means. Two tests, and the second is the one that matters: it drives a *current* =-0000= timestamp and asserts no clock row, so a lazy "catch =TypeError= and return None" fix would fail it while the correct reading passes. +** DONE [#C] a tunnel import that can't be disarmed still reports success :bug:net:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =import_config()= imports a WireGuard or OpenVPN config as an NM profile, then fires =nmcli connection modify <uuid> connection.id <name> connection.autoconnect no= — and discarded the result, returning =ok=True= regardless. + +That modify is the whole safety of the feature, and the module's own docstring says so: =nmcli connection import= *auto-activates* the profile it creates, "which nobody asked for by picking a file", so "every import here ends with the profile deactivated and autoconnect off". A failed modify inverts that. For WireGuard — a device-type connection — autoconnect stays on, so the tunnel re-arms itself at the next boot and takes the default route with it, and the profile keeps the transient staged interface name (=wgpvpn=) while the envelope reports the config's real name, so the panel names a profile that isn't there. + +CORRECTION (2026-07-24, from an adversarial re-review): the blanket claim originally written here — that a failed disarm re-arms the tunnel at boot — is wrong for OpenVPN. =man 5 nm-settings-nmcli= states autoconnect is not implemented for VPN profiles, and an OpenVPN import is an NM VPN profile, so the modify is near-cosmetic on that half. The bug is real and security-relevant for WireGuard, which is the primary case; the severity as stated overreached to cover both. + +The asymmetry, again the tell: =_nmcli_import()=, twenty lines up in the same file, checks its own =returncode= and raises rather than return a UUID it did not get. The modify below it never got the same treatment. + +Grading: Major severity (grading the being-in-it — a full-tunnel VPN the user never asked to connect arms on every boot and carries all their egress, it persists across reboots rather than self-healing, and the affirmative "imported X" is what removes the reason to check) × rare edge case (needs the modify to fail after the import succeeded) = P3 = [#C]. + +Fixed as dotfiles =e0d4d8a= (committed locally, deliberately NOT pushed — held for Craig's morning review). New =_disarm()= returns whether the modify took. On failure the profile is still deactivated first — the import already brought it up, and the verdict shouldn't decide whether it keeps running — and then a =disarm-failed= envelope names the UUID and the exact command to finish the job. Three tests: the failing verdict, =import_configs= counting it as failed rather than imported, and a characterization test pinning that the deactivate still runs on the failure path. +** DONE [#C] a binary that can't be exec'd crashes the panels instead of degrading :bug:net:bluetooth:audio:maint:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 5, comparing the four panel packages' subprocess wrappers against each other. + +Every wrapper in the panels states the same contract: an unusable tool becomes a degraded result, never an exception. =cmd.run= returns None; =nmcli.run=, =btctl.run= and =pactl.run= raise their own domain error, which every caller already guards on; =speedtest.run_speedtest= returns an error envelope. All of them caught only =FileNotFoundError=, so they kept the contract for a tool that is *absent* and broke it for a tool that is *present but unusable*. + +Verified against Python 3.14.6 rather than argued. =subprocess.run= raises =PermissionError= for a file without its execute bit, =OSError= (ENOEXEC, "Exec format error") for an executable file that is neither a binary nor a script with a shebang, =NotADirectoryError= when a path component is a plain file, and =OSError= when a fork is refused under memory or PID pressure. None of the four is =FileNotFoundError=, so each escapes the guard: waybar's net/bt/audio modules die rather than dimming, and a maint probe takes the whole envelope with it — in exactly the machine state maint exists to report on. + +The asymmetry, and this codebase had already ruled on it three separate times: =net/iw.py='s =signal_dbm= and =settings/spawn.py='s =detached= both catch =(OSError, subprocess.TimeoutExpired)=, and =audio/cmd.py='s doctor-tier =probe()= enumerates =FileNotFoundError=, =NotADirectoryError= and =PermissionError= as "absent" under a docstring promising it never raises. Its sibling =run()=, twenty lines up in the same file, kept the narrow catch — as did all five copies of =run()= and all three tool wrappers. =audio/status.py='s docstring records that this same class already bit once ("the bar's audio module died rather than dimming"); that fix widened the guard's *scope* and left its *exception set* alone. + +Grading: Major severity (grading the being-in-it — the status surface is dead while the condition holds, and for maint the tool that reports the fault is the one that dies of it; no data loss, and it clears when the tool or the pressure does) × rare edge case (needs a binary with wrong permissions, a lost shebang, or a fork refused under pressure) = P3 = [#C]. + +Fixed as dotfiles =44fdae1= (committed locally, deliberately NOT pushed — held for Craig's morning review). Widened to =OSError= across net, bt, audio, maint and panelkit — five =cmd.run= helpers, the three tool wrappers, =probe._curl= and =speedtest.run_speedtest=. The domain-error wrappers keep their "<tool> not found" message for a genuinely absent binary and add a second arm naming the errno for an unusable one, so the report can still tell the two apart. 28 tests, one class per package, driving all three exec failures against real files on a temp PATH; each was watched failing against unmodified production code first (27 red). Audio's class carries a characterization case pinning =cmd.probe='s existing behavior, so the sibling that got this right can't regress into the one that didn't. +** DONE [#C] a failed pty-backed spawn strands both ends of the pty :bug:net:bluetooth:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 6, auditing the =subprocess.Popen= sites the round-5 fix didn't reach. + +Two spawns open a pty before launching and catch only =FileNotFoundError= around the =Popen=: =bt/pairing.py='s =pair_interactive= (bluetoothctl under a pty so the passkey agent is interactive) and =net/speedtest.py='s =run_speedtest_stream= (speedtest-go under a pty because it buffers everything to exit when piped). Both are the same exec-failure class as =44fdae1= — a binary present but not executable raises =PermissionError=, a lost shebang raises =OSError= — and neither is =FileNotFoundError=. + +What makes these worse than the =run= wrappers is where the cleanup lives. =os.close(master)= and =os.close(slave)= sit *inside* the =FileNotFoundError= arm, so an escaping =OSError= skips them: every failed attempt strands two descriptors. Both call sites are buttons in a long-lived panel process — the pairing flow and the console's SPEED key — and a user who gets no feedback presses again, so the leak accumulates under exactly the conditions that caused it. + +Grading: Major severity (grading the being-in-it — a descriptor leak in a process meant to run for days, on a path the user retries, plus the exception escaping a documented "(ok, detail)" / error-envelope contract) × rare edge case (needs an unusable bluetoothctl or speedtest-go) = P3 = [#C]. + +Fixed as dotfiles =c2eb3e1= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm on each closes both ends and returns the module's own failure shape, naming the errno. Four tests: two pin the return contract, two count =/proc/self/fd= across three attempts — the fd count is what actually fails against unmodified code, and it was watched failing before the fix. + +The wider sweep this came from is recorded so it isn't repeated: every =except FileNotFoundError= in production was enumerated. The other exec sites were already correct (=maint/gui.py= x3, =net/kick.py=, =timer/engine.py= x2, =timer/gui.py=, =net/repair.py= x2, =audio/peak.py= all catch =OSError=), and the remaining hits are file-open catches, not exec. =clock/__main__.py='s =toggle()= has no guard at all but spawns =sys.executable=, which is by definition runnable; not filed. +** DONE [#C] one impatient client kills the clock panel's toggle listener for good :bug:clock:waybar:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 6, sweeping every acquired resource (pty, socket, mkstemp, tempdir) for cleanup that isn't in a =finally=. + +=clock/src/clock/app.py='s =_listen()= guards =accept()= with =except OSError: return= and leaves the request body — =recv=, =runtime_log=, =sendall= — outside any guard. =send_toggle()= in =__main__.py= gives the panel 0.25s to acknowledge, then closes. An ack later than that hits a dead peer and raises =BrokenPipeError=, which escapes the =while= loop and ends the listener thread. + +Verified empirically, not argued: a client that connects, sends, and gives up after 250ms makes the server's =sendall= raise =BrokenPipeError= (errno 32) and the listener thread exits. + +What makes it Major rather than a nuisance is that it neither self-heals nor announces itself. The socket file stays bound, so every later =clock toggle= still *connects* — then stalls the full 250ms, gets no reply, and falls through to spawning =clock serve=. GTK's single-instance forwarding turns that into =do_activate= on the running service, and =do_activate= calls =show_clock()=, not =toggle()=. So from the first bad client onward, clicking the waybar time module opens the panel every time and never closes it; the only ways out are the right-click dismiss inside the panel or restarting the service. Nothing logs it. + +Grading: Major severity (grading the being-in-it — the toggle is one-way from then on, it persists for the life of the service, and there is no signal it happened) × rare edge case (needs a reply to miss the 250ms budget: a busy main loop mid-redraw, a slow runtime-log write, or an interrupted =clock toggle=) = P3 = [#C]. + +Fixed as dotfiles =7c02614= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm around the request body scopes a dead peer to its own request, mirroring the guard =accept()= already had. =GLib.idle_add= runs before the ack, so the user's click still takes effect — only the acknowledgement is lost. New =tests/clock/test_socket.py=, 3 tests driving the real =_listen= against a stand-in owner (it touches only =self._socket= and =self.toggle=, so no Gtk.Application is needed). The gate is the second toggle after an impatient first: it times out on unmodified code because no listener is left. The other two pin what the fix must preserve — the toggle fires even when the ack can't be delivered, and an unknown command is still answered without toggling. + +Left alone deliberately: =do_activate= calling =show_clock()= rather than =toggle()=. Changing it would alter what a cold =clock toggle= does on first launch, which is a design call for Craig rather than part of this defect. Worth raising if he ever wants the spawn path to toggle too. +** DONE [#B] fuzzel breaks the pinentry protocol loop on every passphrase :bug:security:gpg:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 7 — from the live journal rather than from reading. Grepping this boot for tracebacks turned up four instances of =pinentry-fuzzel: line 36: read: 0: read error: Resource temporarily unavailable=, and every one sits 4-7 seconds after a =GETPIN= (the time it takes to type a passphrase). The =BYE= handler's log line never appears once. + +=hyprland/.local/bin/pinentry-fuzzel= speaks the Assuan pinentry protocol on a pipe gpg-agent keeps open, reading one command per iteration of =while read cmd rest=. The =GETPIN= arm shells out to fuzzel, which *inherits that pipe as its stdin*. fuzzel runs an event loop over its own input, so it sets =O_NONBLOCK= on fd 0 — and =--dmenu= would read the pipe as menu items besides. The flag lands on the shared open file description and outlives fuzzel, so the shell's next =read= fails with =EAGAIN= and the loop ends mid-protocol. + +Grading: Minor severity (the passphrase is delivered *before* the break, so decrypts still succeed and nothing is corrupted — what's lost is everything after: =BYE= is never acknowledged, and gpg-agent's same-connection retry after a wrong passphrase, =SETERROR= then =GETPIN= again, can't be served; that retry is what the script's "reenter" label exists for, and it has never once been reachable) × every user, every time (four for four in the journal, and the test reproduces it deterministically) = P2 = [#B]. + +Fixed as dotfiles =e727dcd= (committed locally, deliberately NOT pushed — held for Craig's morning review). =< /dev/null= on the fuzzel call, so the non-blocking flag lands somewhere harmless; =--lines 0= was already there, so no menu input was ever wanted. =ENABLE_LOGGING= became env-overridable as a test seam — the script logs through an absolute =/usr/bin/logger= that PATH can't shadow, so without it every test run would write ten lines into the real journal. + +New =tests/pinentry-fuzzel/=, 8 tests driving the real script over a live pipe the way gpg-agent does. The fake fuzzel sets =O_NONBLOCK= on whatever fd 0 it is handed, exactly as the real one does, which is what makes them a gate rather than a restatement of the fix. Four fail against unmodified code — one reproducing the journal's message verbatim — and one records the fd fuzzel was given, pinning the cause rather than the symptom. + +THE CALIBRATION NOTE, and it is about my own earlier sweep. This is the same shape as round 1's =a57c443= (ffmpeg draining the pipe a =while read= loop was consuming). Round 1 swept both repos for siblings of that bug and came back empty — because it searched for the *mechanism* (a child that drains stdin) rather than the *shape* (a child that inherits stdin at all inside a read loop). Two different mechanisms, one shape, and the narrower search missed a live daily-use instance. Scope a class sweep by shape, not by the mechanism of the first instance found. +** DONE [#C] a truncated webcam record strands every camera off :bug:settings:privacy:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt, round 8, sweeping production for non-atomic file writes. + +=settings/src/settings/webcam.py='s =_record()= wrote =~/.local/state/settings/webcam.json= with a plain truncate-in-place =open(path, "w")=. That record is the only route back on, and the module docstring says so: deauthorizing a camera removes its video4linux nodes, so =usb_devices()= returns nothing afterward and =_recorded()= becomes the sole source of the paths to re-authorize. A write that truncated and then failed left an empty file; =_recorded()= caught the resulting =JSONDecodeError= and returned =[]=; =_known_devices()= then had nothing; and =set_power(True)= returned None without re-authorizing anything. Every camera stranded off, with no way back through the panel until a replug or a reboot. + +The asymmetry, seventh instance of this read: six other state writers in the tree already write through a temp file and a rename — =maint/cache=, =net/cache=, =audio/ptt=, =timer/engine=, =settings/store=, =maint/curation=. The one whose loss is most expensive was the one that didn't. + +Grading: Major severity (grading the being-in-it — the privacy switch becomes one-way, the panel offers no route back, and the user has to know to replug the camera or write sysfs by hand; bounded by the fact that a reboot re-enumerates USB and restores authorized=1) × rare edge case (needs a crash or ENOSPC inside a microsecond-wide write window) = P3 = [#C]. + +Fixed as dotfiles =8b40b79= (committed locally, deliberately NOT pushed — held for Craig's morning review). =_record= now mirrors =store.save=: =mkstemp= in the target directory, write, =os.replace=, unlink the temp on any failure. Four tests; the gate is a =_record= whose =json.dump= raises, after which the previous record must still be readable — it isn't on the old code. The other three pin what the fix must preserve: no temp-file residue, the =_recorded()= round trip, and the end-to-end power-off/power-on with the class symlinks removed, which is the scenario the record exists for. + +HOW IT WAS FOUND, and it confirms round 7's lesson twice over. Round 4 ran an atomic-write sweep and reported "nine sites, six unique-per-writer, three sharing a fixed =.tmp=" — it enumerated the writers that *were* atomic and compared their temp-file naming, and never asked which state writers aren't atomic at all. Same narrowing that made round 1's stdin sweep miss the pinentry bug: the sweep was scoped to a property of the instances already found rather than to the shape of the hazard. +** DONE [#D] a failed wallpaper apply reports "nothing to apply" :bug:settings:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =2cf3fb3=. The decision moved to =gui.wallpaper_apply_toast=, a module-level pure helper, because the callback lives inside a GTK widget where no test can reach it. 5 tests. +Found in the 2026-07-24 sentry bug-hunt, round 7, sweeping the settings panel's worker callbacks. + +=settings/gui.py='s =_async= passes an exception through as the *result* rather than as a separate error argument, so every =done= callback has to test =isinstance(res, Exception)=. Five do — =_mx_pin=, =_mx_letter=, =_after_matrix=, =_set_pointer=, the drum/dial/gallery/refresh callbacks. =_wp_apply= is the one that doesn't: + +#+begin_src python +def _wp_apply(self, note="Wallpaper set"): + self._async(lambda: panel.wallpaper_apply(self.state), + lambda ok: self._toast( + note if ok is True else "nothing to apply", + good=ok is True)) +#+end_src + +=panel.wallpaper_apply= calls =store.save=, which can raise =OSError= (disk full, a permissions change on the config dir). The exception then arrives as =ok=, =ok is True= is False, and the toast reads "nothing to apply" — describing a no-op when the apply actually failed. The toast is at least marked =good=False= (red), so the user gets a negative signal; what's lost is the reason, which every sibling callback surfaces via =str(res)=. + +Grading: Minor severity (wrong text on an error path, correctly marked as a failure, nothing corrupted) × rare edge case (needs =store.save= or =wallpaper.apply= to raise rather than return False) = P4 = [#D]. + +Fix: give it the same =isinstance(res, Exception)= arm its five siblings have — toast =str(res)= on an exception, keep the current two-way message otherwise. One callback, three lines. +** DONE [#D] two manage.py nmcli reads sit outside their own error conversion :bug:net:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =2cf3fb3=. =_key_mgmt= converts both nmcli exceptions to "", which both call sites already treat as neither wpa-eap nor sae. 2 tests, including one driving =_classify_up_failure= end to end. +Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =nmcli.run()= raises =NmcliTimeout= on timeout and =NmcliError= on a missing binary, and every mutation in this module is written to convert both into a result envelope. Two calls escape that conversion because they run through =_key_mgmt()=, which wraps =nmcli.get_value= and catches nothing: + +- =edit()= line 243 calls =_key_mgmt(uuid)= for the enterprise-profile refusal *before* its own =try=, while the next four lines catch exactly those two exceptions around =nmcli.run=. +- =_classify_up_failure()= calls it on =up()='s failure path, so a slow =connection show= turns a classifiable activation failure into an exception. + +Consequence is a leaked exception where the caller expected an envelope. The panel absorbs it — =gui.bg()= catches =Exception= and renders =str(e)= — so there it degrades to a worse message rather than a crash. =net edit= from the CLI has no such catch and prints a traceback. + +Grading: Minor severity (the operation fails either way; what's lost is the classified message, and only the CLI path shows a traceback) × rare edge case (=connection show= has a 2s timeout and nmcli's presence is already established by the time either site runs) = P4 = [#D]. + +Fix: give =_key_mgmt= the same conversion its callers use — catch =(nmcli.NmcliError, nmcli.NmcliTimeout)= and return "", which both call sites already handle correctly (neither "wpa-eap" nor "sae"). One =try= in one helper covers both sites. +** DONE [#D] three atomic writers share one fixed .tmp name :bug:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed as dotfiles =2cf3fb3=. All three carry =.tmp.$(getpid)=, matching the six writers that already did. 6 tests across audio and maint. +Found in the 2026-07-24 sentry bug-hunt, round 4, sweeping both repos for the temp-file half of the atomic-write idiom. The tree writes state atomically in nine places, and six of them make the temp path unique per writer: =net/cache.py= and =timer/engine.py= both use =f"{path}.tmp.{os.getpid()}"=, and =settings/store.py=, =settings/idle.py=, =bt/repair.py=, =net/probe.py= all use =tempfile.mkstemp=/=NamedTemporaryFile=. Three use a bare =path + ".tmp"=: + +- =audio/src/audio/ptt.py= =write_state= (the lead carried over from round 3's Next Steps) +- =maint/src/maint/cache.py= =put= +- =maint/src/maint/curation.py= =_write_user= + +=os.replace= makes the *rename* atomic, but a shared temp name is not: two writers open the same path, the second truncates under the first, and the file that gets renamed into place is a blend of both. The loser's own =os.replace= then raises =FileNotFoundError=, because the winner already renamed the name out from under it. + +Real concurrent-writer pairs exist for two of the three. =maint/cache.py= =updates_repo= is written by =maint-net-scan.timer= hourly and again by =doctor._fresh_pending()= at UPDATE fire time. =audio/ptt.py= has three writers by design (the CLI toggle bound to a key, the waybar right-click, and the GTK panel) — its module docstring says so. =curation.py= is written by panel key presses and CLI verbs. + +Grading: Minor severity (every reader degrades rather than crashes — =cache.get= catches =ValueError= and reports no data, =read_state= reads a torn file as disarmed, and both recover on the next write; the sharpest edge is the loser's =FileNotFoundError= aborting the rest of =scan_net=, which the next hourly run repairs) × rare edge case (the write window is a millisecond or two, and the overlapping writers are an hourly timer against a human keypress) = P4 = [#D]. + +Fix: give all three the =f"{path}.tmp.{os.getpid()}"= form the two careful siblings already use. It is three one-line changes and needs no new abstraction. Note this closes the torn-file half only — the read-modify-write in =ptt.toggle_plan= and =curation.set_preference= can still lose an update between two writers, which wants a lock rather than a temp-name change and should stay a separate decision. +** DONE [#C] dmenuexitmenu word-splits its menu so no entry matches :bug:dwm:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2128). =dwm/.local/bin/dmenuexitmenu= line 4 expands the menu unquoted: =choice=$(echo -e $menuitems | dmenu ...)=. Word-splitting collapses the runs of spaces the labels carry, so dmenu shows =Lock= where the =case= arm expects =Lock = (two spaces) and =Logout = where the arm expects a trailing space. No arm matches, so choosing an entry does nothing at all. + +CORRECTION (2026-07-24): THE BUG AS FILED DOES NOT EXIST. Ran it. =echo= rejoins the words split off the unquoted expansion with single spaces, and no label carries two spaces, so quoted and unquoted produce byte-identical output — verified against the exact literals from git rather than a retyped copy. Every =case= arm matches and every menu action works. + +What is real is latent. An unquoted expansion collapses a double space and glob-expands a =*=; the second was demonstrated turning a label into a directory listing. No current label triggers either. + +Hardened anyway in dotfiles =2cf3fb3= as robustness, not as a bug fix: the expansion is quoted and the bogus one-element array is now a plain string. Output confirmed unchanged byte-for-byte. New =tests/dmenuexitmenu/= (10 tests) pins the working behaviour, and shellcheck on the file drops from three findings to one. + +SECOND SENTRY FILING DISPROVED BY RUNNING IT, after =a57c443= (mkplaylist). Both came from a shellcheck hit plus reasoning, neither was executed. A static-analysis finding says a construct is unsafe, not that it currently misbehaves, and both filings treated the first as the second. +** DONE [#C] Timer module hero hierarchy :feature:waybar:timer:quick:solo: +CLOSED: [2026-07-24 Fri] +From the roam inbox (Craig, claimed 2026-07-22). Which display ("hero") wins the waybar timer module when several timer modes run simultaneously: pomodoro wins over everything (the user is actively working; it's likely their main focus). The rest rank in chronological order of when they would ring. Worked example: with a just-started 15-min timer, a 1-hr timer at 10 minutes left, a pomodoro, and an alarm ringing in 12 minutes — show the pomodoro; when it completes, the 1-hr timer (rings first), then the alarm, then the 15-min timer. Feeds the timer-panel spec (docs/specs/2026-07-02-timer-panel-spec.org). + +Shipped as dotfiles =9eedb39=. Pomodoro wins the hero, then soonest-to-ring, in both selectors (=engine.select_primary= for the bar, =panel.primary_id= for the GTK hero). Craig's worked example is a test. FLAGGED FOR CRAIG: the two selectors diverge on a *ringing* alarm (the bar excludes it, the panel gives it the hero) and I left that as-is rather than reverse a deliberate choice. Whether to unify them is your call. +** DONE [#C] Timer module: drop RING message, persistent notifications :bug:waybar:timer:quick:solo: +CLOSED: [2026-07-24 Fri] +From the roam inbox (Craig, claimed 2026-07-22). Remove the RING message from the timer module display; verify all timer and alarm notifications are persistent; the icon returns to normal once the notification has fired. Rationale: keeps timers and pomodoros from interfering with one another's displays (pairs with the hero-hierarchy task above). + +Shipped as dotfiles =9eedb39=. The tooltip no longer prints RING or a (ringing) suffix; a fired alarm shows its clock time and its persistent notification carries the alert. Verified the timer and alarm completion notes already set persist=True. +** DONE [#C] PTT icon outline removal :bug:waybar:quick:solo: +CLOSED: [2026-07-24 Fri] +From the roam inbox (Craig, claimed 2026-07-22): the waybar PTT icon should not have an outline. Cosmetic × every-glance = P3 = [#C]. + +Shipped as dotfiles =e63c0cf= (live style.css + dupre theme source). Removed the amber/green text-shadow glow from the armed/talk states, the only outline-like effect on the icon. FLAGGED FOR CRAIG: this is my read of "outline" (the glow). If you meant the glyph shape itself, it's a one-line revert. Confirm live by pressing PTT. +** DONE [#C] Video wallpapers don't fit the desktop :bug:dotfiles:solo: +CLOSED: [2026-07-24 Fri] +From the roam inbox (Craig, claimed 2026-07-23): videos don't fit the desktop in desktop-settings. The video channel drives mpvpaper (=settings/src/settings/wallpaper.py=); mpvpaper passes options through to mpv, so the fit is a =--panscan=/=--video-unscaled=/keepaspect question rather than a layout one. Reproduce with a video whose aspect differs from the output, pick the mode that fills without distorting (cover, matching how the image channels behave), and cover it in the wallpaper tests. Minor severity × whenever the video channel is selected = P3 = [#C]. + +Shipped as dotfiles =04d1489=. =set_video= now passes =panscan=1.0=, so mpvpaper fills the output and crops the overflow instead of letterboxing; keepaspect stays on so nothing stretches. Tested against the mpvpaper arg log. +** DONE [#C] World-clock wallpaper arrangement :feature:dotfiles: +CLOSED: [2026-07-24 Fri] +Shipped 2026-07-24 as dotfiles =6afbe09=, iterated live with Craig. The grid of boxed mini-clocks became a centered vertical clock line: cities down a spine, west (Honolulu) top to east (Wellington) bottom, labels alternating both sides, no boxes. Each shows city / time (12h) / day+date / timezone region name ("US Central"). Day/night dimming + amber home carried over, title dropped, cursor restored over the desktop. Prototypes archived in archsetup 40216e7. The face is parameterized (=?layout=vertical|horizontal=, =?hour12=1|0=) so the panel pickers below can drive it. +** DONE [#C] Floating layout — should we? :feature:hyprland: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +From the roam inbox (Craig, claimed 2026-07-23): consider whether Hyprland should offer a floating layout — how it would work, the benefits, and the complexity. A brainstorm/spike, not a build: the deliverable is an assessment Craig reads and decides on, not a shipped layout. Not :solo:. When picked up, run it as a brainstorm — how a floating mode coexists with the current tiling binds (toggle keybind, per-workspace vs global, window-rule interactions), what it buys over the existing =togglefloating=, and the config/muscle-memory cost — then bring Craig the recommendation. + +CONCRETE PROPOSAL from a second roam item (Craig, 2026-07-24 via work) — "floating mode as the easiest mode": +- Can't select floating until at least one window is displayed. +- Entering floating freezes each window's position and floats it exactly where it is. +- During floating, drag windows with mod+mouse-drag. +- Exiting floating switches to tiling or monocle and lets that layout take over. +Craig's note: "simple, could be useful for different reasons." This is the design the brainstorm should evaluate first — assess feasibility against Hyprland's actual float/tile transitions (does freezing current geometry survive the tiling↔floating switch, does re-tiling on exit reflow cleanly) before recommending. + +ASSESSED, dotfiles =8cf4728=: =docs/2026-07-24-floating-layout-assessment.org=. Verdict: buildable and worth building on a capture-then-restore of window geometry (=hyprctl clients -j= gives at/size), which is a real gesture plain =togglefloating= can't express. Craig's four-rule proposal is folded in and each rule assessed. One taste call flagged (exit to previous layout vs always monocle). Ready to file a build task on Craig's go. +** DONE [#C] World clock wallpaper: bold the city names :feature:dotfiles:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +From the roam inbox (Craig, 2026-07-24 via work): bold the city names on the world-clock wallpaper face (=settings/faces/world.html=, shipped =6afbe09=). Cosmetic × every glance at the world face = P3 = [#C]. Solo — a CSS weight change, screenshot-verifiable — but it's a visual call, so build it and show the render rather than close off a green suite. Pairs with the open world-face picker task. + +Shipped as dotfiles =e63c0cf=. =.lbl .city= is now =font-weight:700=. Rendered offscreen and confirmed the bold reads well over the time/zone lines; home city stays amber. Comparison render was on ws5 for Craig. +** DONE [#C] Floating clock toggles on control+mod+c :feature:dotfiles:hyprland:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +From the roam inbox (Craig, 2026-07-24 via work): a control+mod+c keychord should toggle the floating clock, the same as clicking the time waybar module. + +This answers the design question the round-6 clock-toggle fix deliberately left open (see the =clock toggle listener= DONE task above): =do_activate= calls =show_clock()= rather than =toggle()=, and the note there flagged "worth raising if he ever wants the spawn path to toggle too." He does. Build: a hyprland keybind bound to =clock toggle=, and confirm the toggle path (not show-only) fires whether the service is cold or warm. Solo — buildable and locally verifiable. + +Shipped as dotfiles =e73a70e=. =bind = $mod CONTROL, C, exec, clock-panel toggle= reuses the exact command the time module's click runs, so it toggles identically. Registered clean on reload. Live keypress is Craig's to confirm. +** DONE [#C] Calculator scratchpad won't toggle closed on mod+x :bug:hyprland:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +From the roam inbox (Craig, 2026-07-24 via work): =mod+x= opens the calculator scratchpad but doesn't close it — Craig has to kill the window by hand. A second =mod+x= should toggle it shut. Almost certainly a =togglespecialworkspace= vs plain =exec= binding in the hyprland config, or a scratchpad window-rule mismatch. Minor severity (a workaround exists: kill the window) × every time the calc scratchpad is used = P3 = [#C]. Solo — a keybind/window-rule fix, locally verifiable. + +Shipped as dotfiles =e73a70e=. New =calc-toggle= script (mirrors fuzzel-toggle: pgrep -x, pkill or launch), and =mod+X= now points at it, so a second press closes the calculator. 3 tests in tests/calc-toggle. +** DONE [#C] Saving and recalling window configurations :feature:hyprland: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +From the roam inbox (Craig, 2026-07-24 via work), a research idea: Craig wants to save a specific window+app arrangement and have it reappear on demand. What has to be known and built to make that happen — is there prior art (another WM or OS that does session/layout save-restore), what information do those need (app identity, geometry, workspace, launch command), and what are their rules. Explore how far Hyprland can get (hyprctl clients + dispatch, exec rules, window rules by class/title), document thoroughly, and review with Craig next time. Not :solo: — the deliverable is an assessment he reads and decides on, and it may spawn a build task once the shape is clear. Offer to file the build separately if part of it turns out urgent. + +RESEARCHED, dotfiles =8cf4728=: =docs/2026-07-24-window-config-save-recall-assessment.org=. Prior art surveyed (i3/sway =append_layout= swallow, KDE window rules, macOS Moom). Three tiers from cheapest: (1) reposition open windows — buildable + testable now; (2) relaunch + place by class rule; (3) full swallow-by-title, which hits the same-class ambiguity every tool hands back to the user. Recommends shipping tier 1; tiers 2-3 need Craig's call on how much manual disambiguation he'll accept. diff --git a/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org b/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org new file mode 100644 index 0000000..c2461be --- /dev/null +++ b/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org @@ -0,0 +1,165 @@ +#+TITLE: Guarded-Upgrade Completion — keeping topgrade freshness honest +#+AUTHOR: Craig Jennings +#+DATE: 2026-08-25 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* DRAFT Guarded-upgrade completion +:PROPERTIES: +:ID: 81cdfd72-db96-43d3-aa03-779878c99f3e +:END: +- [2026-08-25 Tue @ 06:39:42 -0600] drafted. Grounded in a live read of the maint engine, the pacman hooks, and the boot path on velox, not memory. The topgrade-freshness diagnosis that motivates it is in this session's log. + +* Metadata + +| Status | draft | +|----------+-------------------------------------------------------------| +| Owner | Craig Jennings | +|----------+-------------------------------------------------------------| +| Reviewer | Craig Jennings | +|----------+-------------------------------------------------------------| +| Related | maint =topgrade_age= metric; =hypr-live-update-guard= hook | + +* Summary + +The waybar maintenance module shows topgrade freshness as permanently stale. The cause is a real one: on a machine running Hyprland, a full =topgrade= almost never exits 0, because its system step upgrades GPU/compositor libraries that the =hypr-live-update-guard= pacman hook correctly refuses to swap under a live session. The freshness stamp is gated on topgrade's exit code, so a correct, protective refusal reads as "you never run updates." This spec designs a safe path to actually complete a guarded upgrade, and makes that completion record the freshness stamp, so the metric tracks the true state of the system. + +* Problem / Context + +The metric reads one cache key, =topgrade_run= (=~/.local/state/maint/topgrade_run.json=). Absent, the probe (=maint/src/maint/probes/updates.py:114=) returns WARN, "no topgrade run recorded". Two writers stamp it: the =topgrade= PATH wrapper (=~/.dotfiles/hyprland/.local/bin/topgrade=) on =rc -eq 0=, and the panel's TOPGRADE lever (=doctor.py=), which returns before the stamp on any non-zero exit. The read path is sound (a sandboxed =maint stamp topgrade= writes the file and =maint status= then reads freshness 0); the file is simply never written. + +It is never written because topgrade rarely exits 0 on this machine, and the reason is specific rather than flaky. =/etc/pacman.d/hooks/10-hypr-live-update-guard.hook= is a =PreTransaction=/=AbortOnFail= hook that, when Hyprland is running and an upgrade changes the on-disk version of a GPU/compositor library, prints a BLOCKED banner and exits 1 — aborting the whole transaction before any file is swapped. Its trigger set is =mesa=, =mesa-*=, =wayland=, =libdrm=, =libglvnd=, =hyprland=, =aquamarine=, =hyprutils=, =hyprgraphics=, =vulkan-radeon=, =vulkan-intel=, =vulkan-mesa-layers=, =nvidia-utils=, =lib32-nvidia-utils=, =xorg-xwayland=. The guard exists for a proven failure: replacing those libraries under a live compositor makes the next GPU call hit a now-deleted mapping and SIGABRT, taking every Wayland client down (hit on ratio 2026-06-07). + +So when any of those libraries has an update pending — a frequent event — topgrade's =system= step (it runs =yay=) aborts non-zero, topgrade returns non-zero, and neither writer stamps. The observed case: on 2026-08-24 topgrade ran at 17:49, hit the guard on =mesa= (26.1.7 → 26.2.1), and failed; the upgrade was then finished by hand with the guard's sentinel override, entirely outside the wrapper, so nothing stamped. The metric has read stale ever since. + +Two framings of the fix are in tension, and choosing between them is the spec's central decision. Either the metric means "how recently did you run the sweep" (recency), so the stamp should decouple from topgrade's exit; or it means "is the system up to date" (state), so staying stale while a guarded upgrade is deferred is *correct* and the only real defect is that safely completing that upgrade doesn't stamp. This spec takes the state framing (see Decisions). + +* Goals and Non-Goals + +** Goals +- A safe, low-friction way to apply a guarded (GPU/compositor-library) upgrade, with Hyprland not live at swap time. +- That completion records the =topgrade_run= freshness stamp, so the metric clears when the system is genuinely current. +- A boot-time upgrade path that can never lock the machine out of its session, however it fails. +- The installer owns the durable pieces so a rebuilt machine has them without hand-setup. + +** Non-Goals +- Weakening or bypassing the =hypr-live-update-guard= hook. It stays exactly as strict; this builds *around* it, not through it. +- Making the full topgrade ecosystem sweep (git repos, vim, npm, ...) run at boot. Those never need a stopped compositor and are out of the boot path. +- Solving the live-kernel-upgrade hazard (running-kernel modules vanish until reboot). Related, not guarded by this hook, and deferred. +- A general offline-update system for all of pacman. Scope is the guarded-library case. + +** Scope tiers +- v1: stamp the safe-completion path; an armed, bounded, non-blocking boot-time =topgrade --only system= unit installed by archsetup; an arming affordance in maint that fires when guarded libs are pending. +- Out of scope: full-sweep-at-boot; kernel-reboot coordination; touching the guard's policy. +- vNext: fold the same arm-and-reboot pattern into a live-kernel-upgrade prompt (log to =todo.org=). + +* Design + +The shape follows one principle: the only part of topgrade that needs a stopped compositor is its =system= step when a guarded library is pending. Everything else runs fine live and rarely fails. So the safe path is small and targeted — apply the guarded system upgrade with Hyprland down, once, and stamp it — while the ordinary full sweep stays a normal live =topgrade= run. + +Three pieces, at two altitudes. + +*For the user.* When the maintenance panel sees a guarded library in the pending set (the exact condition that fails a live topgrade), it offers a new action: "apply on reboot." Choosing it arms a persistent flag and offers to reboot. On the next boot, before the autologin shell starts Hyprland, the guarded upgrade runs in the console — the guard passes freely because nothing is live — the stamp is written, the flag is cleared, and boot continues into the session. No second reboot: the libraries are already current before anything maps them. If anything goes wrong, the machine still boots into Hyprland and the panel still shows the pending upgrade, so you are never worse off than before arming. + +*For the implementer.* A persistent arm flag (a file on a non-tmpfs path, e.g. =/var/lib/archsetup/apply-upgrade-on-boot=, so it survives the reboot the =/run= guard sentinel cannot). A system oneshot, =archsetup-boot-upgrade.service=, =ConditionPathExists= on the flag, ordered =Before=getty@tty1.service= so it completes before autologin execs Hyprland — this ordering is mandatory, because a parallel run would let Hyprland start mid-swap and reintroduce the exact crash the guard prevents. The unit is bounded (=TimeoutStartSec=) and best-effort: its failure or timeout must not fail any target the session needs, so boot proceeds past it regardless. Its =ExecStart= runs, as the user: =informant read= (clear the news hook that would otherwise abort the transaction), then =topgrade --only system= (or the equivalent =yay -Syu=), then =maint stamp topgrade= on success, then removes the flag unconditionally (a one-shot arm — a failed attempt disarms rather than retrying every boot). =sudo= works unattended (=%cjennings NOPASSWD: ALL=), so no password prompt wedges it. + +The stamp also needs to happen when the upgrade is completed by other safe means — the by-hand sentinel-override path, or a =maint= command that does the same thing. The cleanest single home for the stamp is a small =maint apply-upgrade= (or a flag in the existing lever) that performs the guarded system upgrade and stamps on success, which both the boot unit and an interactive TTY run call. That keeps one code path that "completes a guarded upgrade and records it," rather than three writers that can drift. + +* Alternatives Considered + +** A. Decouple the stamp from topgrade's exit code (stamp on any real run) +- Good, because it is a one-line change to the wrapper and needs no boot machinery. +- Bad, because it throws away honest signal: a topgrade that was blocked from applying a real upgrade would read as "fresh," so the metric stops meaning "up to date." On this machine the blocked case is the common case, so the metric would be fresh precisely when an upgrade is outstanding. +- Neutral, because the failed steps still surface elsewhere (pending-updates count), so freshness would become redundant rather than wrong. + +** B. Run the full topgrade live with the guard overridden, then reboot +- Good, because it needs no new unit — arm the sentinel, run, reboot. +- Bad, because the dangerous window is the whole rest of the run: mesa swaps early, then topgrade spends minutes on other ecosystems while the live compositor is one new GL context (a new window, the wallpaper daemon) away from SIGABRT. topgrade's own reboot-at-end is that window, not a fix for it. +- Neutral, because it would stamp naturally on success — if it survived. + +** C. Manual TTY ritual only (log out, run topgrade at the console, reboot), plus stamp +- Good, because it is the safest path and needs almost no code — just make the completion stamp. +- Bad, because it is all manual, every guarded-upgrade day; the friction is why it won't happen consistently, which is how the metric got stale in the first place. +- Neutral, because it is exactly what the boot unit automates, so it is really "v1 minus the automation." + +** D. Boot-time armed oneshot, arch-only (this spec) +- Good, because the risky swap happens with nothing live, the run is one bounded transaction with a tiny prompt surface, it stamps on success, and a failure degrades to "boots normally, try again." +- Bad, because it puts a unit on the boot critical path, which must be bounded and non-fatal with care, and it is the most to build. +- Neutral, because it composes with C: the same =maint apply-upgrade= path serves both an interactive TTY run and the boot unit. + +* Decisions [/] + +** TODO Metric means state, not recency +- Owner / by-when: Craig / at spec-review +- Context: the stamp gate can mean "ran the sweep" or "system is current." The whole fix differs by which. +- Decision: We will keep the state meaning. Freshness stays stale while a guarded upgrade is genuinely un-applied, and the fix is to make *safe completion* stamp — not to loosen the gate. +- Consequences: easier — the metric stays trustworthy as an is-current signal, and Alternative A is off the table. Harder — completion now needs a real safe path (the rest of this spec) rather than a one-line wrapper change. + +** TODO Primary mechanism is the armed boot-time oneshot (Alternative D) +- Owner / by-when: Craig / at spec-review +- Context: D vs the manual-only C vs the risky live-and-reboot B. +- Decision: We will build D, and structure it so the same completion path also serves an interactive TTY run (C as the manual fallback). B is rejected on the live-swap risk. +- Consequences: easier — one gesture (arm + reboot) applies guarded upgrades safely and keeps the metric honest. Harder — a boot-critical-path unit needs the bounded, non-fatal, ordered-before-autologin wiring exactly right, or it risks boot delay. + +** TODO Boot run is arch-only (=topgrade --only system=), not the full sweep +- Owner / by-when: Craig / at spec-review +- Context: the only step needing a stopped compositor is the guarded system upgrade; the rest run fine live and are what usually fail. +- Decision: We will run only the system step at boot. The full topgrade sweep stays a normal live run. +- Consequences: easier — the boot path is fast (one transaction), has a tiny interactive-prompt surface, and rarely fails. Harder — freshness then reflects the system step specifically; if we want the sweep's other steps to also gate freshness, that is a separate signal. + +** TODO Arm flag lives on a persistent path and is one-shot +- Owner / by-when: Craig / at spec-review +- Context: the guard's =/run= sentinel is tmpfs and cleared on reboot, so it cannot carry an intent across the reboot. A boot that retries forever on failure is its own outage. +- Decision: We will use a persistent flag (=/var/lib/archsetup/=) that the boot unit removes unconditionally at the end of its attempt — success or failure disarms. +- Consequences: easier — the intent survives exactly one reboot and a failed attempt never wedges subsequent boots. Harder — a failed attempt needs re-arming, which is correct (a human decides to try again) but is a manual step. + +* Implementation phases + +** Phase 1 — Stamp the safe-completion path +A single =maint= entry point (=maint apply-upgrade=, or a documented flag on the existing lever) that runs =informant read= then the guarded system upgrade with Hyprland assumed down, and calls =maint stamp topgrade= on a zero exit. Usable immediately from a TTY. Leaves the tree working; no boot machinery yet. Tests: stamps on success, does not stamp on failure, refuses/aborts cleanly if Hyprland is live. + +** Phase 2 — The armed boot-time unit (archsetup) +=archsetup-boot-upgrade.service= installed by the installer: =ConditionPathExists= the arm flag, =Before=getty@tty1.service=, =TimeoutStartSec= bounded, non-fatal to every session target, =ExecStart= calls the Phase-1 path as the user and removes the flag unconditionally. Installer step + unit file + the =/var/lib/archsetup/= flag directory. Tests in =tests/installer-steps/= for the install step; a documented manual boot test (arm, reboot, observe) in =todo.org= under Manual testing and validation. + +** Phase 3 — The arming affordance (dotfiles maint) +The panel/CLI detects a guarded library in the pending set (the same trigger list the hook uses) and offers "apply on reboot": writes the arm flag and offers to reboot. Tests: the offer appears only when a guarded lib is pending, writes the flag, and is idempotent. + +** Phase 4 — Docs, rollout, and both daily drivers +Document the flow (arm → reboot → console upgrade → session). Roll the unit to velox and ratio (installer already covers a rebuild; existing machines need the one-time install). Confirm the ratio path matches. + +* Acceptance criteria +- [ ] With a guarded library pending, arming and rebooting applies it in the console before Hyprland starts, and =maint status= then reads a fresh =topgrade_age=. +- [ ] A boot-upgrade failure (a failed step, a timeout, an aborted transaction) never blocks the session: the machine boots into Hyprland, the flag is cleared, and the panel still shows the pending work. +- [ ] Unread Arch news does not wedge the boot run (=informant read= precedes the transaction). +- [ ] A guarded upgrade completed from a TTY via the Phase-1 path stamps freshness identically to the boot unit. +- [ ] The =hypr-live-update-guard= hook is unchanged and still blocks a live guarded swap. + +* Readiness dimensions +Answer each, or write "N/A because…". +- Data model & ownership: the arm flag (=/var/lib/archsetup/=, installer-owned) and the =topgrade_run= cache key (maint-owned). No user-authored data. +- Errors, empty states & failure: the boot unit is best-effort and self-disarming; every failure path lands in "boot normally, metric stays stale, re-arm to retry." Named, non-silent. +- Security & privacy: relies on the existing =%cjennings NOPASSWD: ALL=; the unit runs the upgrade as the user via sudo, adds no new privilege. Note the NOPASSWD breadth as a pre-existing fact, not introduced here. +- Observability: the boot run's output is on the console; its systemd unit status and journal record success/failure; the panel reflects the cleared or still-pending state after boot. +- Performance & scale: one pacman/yay transaction at boot; bounded by =TimeoutStartSec=. Negligible boot-time cost when the flag is absent (=ConditionPathExists= skips the unit). +- Reuse & lost opportunities: reuses the guard's trigger list (single source of truth for "which libs are dangerous"), =informant=, =maint stamp=, and topgrade's own =--only system=. No reinvention. +- Architecture fit & weak points: integration points are the pacman hook set, getty autologin ordering, and the maint cache. Weak point: the =Before=getty@tty1= ordering is load-bearing for safety; a parallel run reintroduces the live-swap crash. Mitigated by making the ordering explicit and tested-by-inspection. +- Config surface: the arm flag path and the timeout. Defaults safe (absent flag = no-op). +- Documentation plan: a short "reboot to apply guarded upgrades" note in the maint docs; the installer step self-documents in-comment. +- Dev tooling: installer-step pytest for Phase 2; maint unit tests for Phases 1 and 3; a manual boot test in =todo.org=. +- Rollout, compatibility & rollback: additive; removing the unit and flag reverts fully. Existing machines need a one-time install; a rebuild gets it from the installer. Rollback leaves the guard and manual TTY path intact. +- External APIs & deps: topgrade =--only system=, =informant read=, =yay=, =maint stamp= — all verified present on velox this session. No external service. + +* Risks, Rabbit Holes, and Drawbacks +- Boot critical path: the unit sits ahead of autologin, so a hang would delay boot. Mitigated by =TimeoutStartSec= and non-fatal wiring; worst case is a bounded delay, then a normal session. +- Interactive prompts under no stdin: =yay=/pacman can still prompt (provider choice, replace, AUR review) even with =assume_yes=. The =--only system= scope and =--noconfirm=-style flags shrink this to near zero, but a prompt with no stdin fails the run (benign) — needs a genuinely non-interactive invocation, verified in Phase 2. +- Partial ecosystem state: N/A for the hazard — the system step is a single atomic pacman transaction, so there is no half-swapped GPU library. +- Adjacent but out of scope: a live kernel upgrade is not guarded by this hook yet removes running-kernel modules until reboot. Flagged as vNext, not solved here. + +* Testing / Verification / Rollout +Phase-1 and Phase-3 logic under the maint fake harness; Phase-2 install under =tests/installer-steps/=. The one thing no unit test can cover — that an armed reboot actually applies the upgrade pre-session and stamps — is a scripted manual test in =todo.org= (arm with a guarded lib pending, reboot, confirm the console run, the fresh metric, and a normal session). Roll to velox first, then ratio. + +* Review and iteration history +** 2026-08-25 Tue @ 06:39:42 -0600 — Craig Jennings — author +- What: initial draft. +- Why: the topgrade-freshness metric reads permanently stale because the guard blocks the arch step; designing a safe completion path rather than loosening the gate. +- Artifacts: this session's log; =hypr-live-update-guard= hook; maint =topgrade_age= probe. @@ -45,6 +45,79 @@ below): input-side-spec.org (DRAFT, four decisions open). * Archsetup Open Work +** TODO [#A] Topgrade guarded-upgrade spec — decisions, review, decomposition :feature:maint:dotfiles: +SCHEDULED: <2026-08-25 Tue> +:PROPERTIES: +:CREATED: [2026-08-25 Tue] +:LAST_REVIEWED: 2026-08-25 +:SPEC_ID: 81cdfd72-db96-43d3-aa03-779878c99f3e +:END: +The waybar maint module's "topgrade freshness" warning never clears: the stamp +is written only when topgrade exits 0, and the =hypr-live-update-guard= +PreTransaction hook (mesa, wayland, hyprland, vulkan-*, nvidia-utils, +xorg-xwayland under a live Hyprland) plus any failing ecosystem step makes that +exit almost unreachable. Diagnosed 2026-08-24/25; the fix is specced, not +hacked, because it spans two repos and the design is contested. + +Spec: [[file:docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org][2026-08-25-topgrade-guarded-upgrade-spec.org]] (DRAFT). + +Four open decisions, all mine to make before the spec can move: +1. Freshness means *state* (a guarded upgrade still un-applied stays stale), + not recency (any run stamps). +2. Primary mechanism is alternative D: an armed boot-time oneshot ordered + before =getty@tty1= (no display manager to order against). +3. The boot run is arch-only (=topgrade --only system=), not the full sweep. +4. The arm flag lives on a persistent path and is one-shot. + +Then: flip the decisions DONE, run spec-review (DRAFT → READY), run +spec-response to decompose the four phases into build tasks here, file the +vNext =[#D]= kernel-reboot item, and commit the spec. + +** TODO [#A] Ratio: pull .emacs.d before upgrading Emacs to 31.1 :chore:ratio:emacs: +SCHEDULED: <2026-08-25 Tue> +:PROPERTIES: +:CREATED: [2026-08-25 Tue] +:LAST_REVIEWED: 2026-08-25 +:END: +Emacs 31.1's warnings.el defers daemon-startup warnings into a closure holding +the =*Warnings*= buffer; the config's dashboard-only sweep killed that buffer, +so the first client frame of every fresh 31.1 daemon failed on Wayland and +emacsclient silently fell back to =$DISPLAY= (XWayland, pgtk warning dialog). +Fixed in =.emacs.d= commit =63831060= (2026-08-25, velox verified live: +=GdkWaylandDisplay=). Ratio is still on 30.2, which lacks the deferring code, +so it is fine until it upgrades — then it hits the same trap once per daemon +start unless the fix is pulled first. + +Order on ratio: =git -C ~/.emacs.d pull= (the push from velox is the telega +session's; confirm =63831060= is on origin first), then the =pacman -Syu= that +brings =emacs-wayland 31.1=, then restart the daemon. Check afterwards: +=emacsclient -e '(pgtk-backend-display-class)'= → =GdkWaylandDisplay=. + +** TODO [#A] Lock-screen clock stale after a real sleep :bug:hyprland:dotfiles:velox: +SCHEDULED: <2026-08-25 Tue> +:PROPERTIES: +:CREATED: [2026-08-25 Tue] +:LAST_REVIEWED: 2026-08-25 +:END: +After waking velox from a real sleep, the hyprlock clock shows a stale time +(Craig confirmed 2026-08-24: the wake-from-sleep case, not an idle-locked +screen). Three isolated tests on 2026-08-24 failed to reproduce it — hyprlock +0.9.6 repainted within a second of a display power-cycle, a three-minute +SIGSTOP, and both together with the screenshot background — so it needs a real +suspend on the real hardware. + +Grading: Minor severity (cosmetic-to-confusing, the screen still unlocks) × +most users frequently (every wake) = P3 = [#C] by the matrix; held at [#A] at +Craig's direction on 2026-08-25 so it gets run while velox is the daily driver +on the road. Revisit the letter once the manual check has an answer. + +Not :solo: — the distinguishing observation is Craig's. The check lives under +Manual testing and validation: "Lock screen after a real sleep: is the clock +frozen, or is all of hyprlock frozen?". Its three outcomes each name a +different fix: stale-then-corrects → repaint interval; frozen with live input +→ clock rendering; frozen with dead input → hyprlock hung, a crash/hang +recovery bug the =screen-lock= watchdog doesn't cover. + ** TODO [#C] post-rebuild-check: probe that Emacs frames come up Wayland-native :feature:emacs:velox:solo:quick: :PROPERTIES: :CREATED: [2026-08-25 Tue] @@ -270,6 +343,48 @@ Grading: Minor severity (nothing behaves wrong, it just waits) x some users sometimes (every failed lookup, which is occasional rather than constant) = P3 = [#C]. +** TODO [#B] Signal tray icon invisible under waybar (Electron 43 well-known-name SNI) :bug:waybar:velox: +:PROPERTIES: +:CREATED: [2026-08-25 Tue] +:LAST_REVIEWED: 2026-08-25 +:END: +Since signal-desktop 8.24.0 (Electron 43.4.0) Signal's tray item registers +under a well-known bus name (=org.freedesktop.StatusNotifierItem-<pid>-1=) +and answers Properties.Get/GetAll only when addressed by that name. waybar's +GDBus proxy addresses the owning unique name instead, reads back no Id or +Category, and logs "Invalid Status Notifier Item", so the icon never shows. +With =--start-in-tray= that leaves Signal running with no window and no icon; +launching it again from fuzzel raises the existing window. Slack (older +Electron, unique-name registration) is unaffected. Measured 2026-08-25 with a +bus monitor: the same connection returns the value for the well-known name +and "error occurred in Get" for its own unique name. + +Grading: Major severity (the app is unreachable from the desktop while +"running") × every user every time on velox = P1 by the matrix, held at +[#B] because the workaround (relaunch to raise the window) is cheap and the +fix is upstream. + +Upstream: [[https://github.com/Alexays/Waybar/issues/5240][Waybar #5240]] (open, proposes a raw-call fallback in item.cpp +proxyReady) and [[https://github.com/signalapp/Signal-Desktop/issues/7992][Signal-Desktop #7992]] (open, "Upstream Change Needed"). +Downgrading to 8.23.0 is closed off: 8.24.x migrated the SQLCipher schema to +1770 and 8.23.0 quits with DBVersionFromFutureError (tried and reverted +2026-08-25). + +Re-test after a waybar or signal-desktop upgrade, from the repo root: +#+begin_src sh :results output +grep -c 'Invalid Status Notifier Item' "$(\ls -t ~/.local/var/log/waybar-*.log | head -1)" +n=$(busctl --user list --no-legend | awk '$1 ~ /StatusNotifierItem-/ && $3=="signal-desktop"{print $1}') +busctl --user call "$n" /StatusNotifierItem org.freedesktop.DBus.Properties Get ss org.kde.StatusNotifierItem Id +busctl --user call "$(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetNameOwner s "$n" | cut -d'"' -f2)" /StatusNotifierItem org.freedesktop.DBus.Properties Get ss org.kde.StatusNotifierItem Id +#+end_src +Expected when fixed: 0 "Invalid" lines in a fresh waybar log, or both Get +calls returning the Id (either side fixing it clears the icon). + +Ratio is on 8.21.0 and unaffected until its next upgrade brings 8.24.x. +Alternatives if it drags on: change Signal's tray setting so it keeps a +window (=~/.config/Signal/ephemeral.json= =system-tray-setting=), or run a +waybar carrying the #5240 fallback. + ** TODO [#A] Reseat velox input-cover ribbon — phantom power button :bug:velox:hardware: DEADLINE: <2026-08-26 Wed> :PROPERTIES: @@ -1929,7 +2044,16 @@ each on the live machine rather than reading the list back: The suspend work itself is untouched — no kernel parameter, no drain measurement. Only the riders moved. +*** 2026-08-25 Tue @ 11:57:48 -0600 Logged the two Aug 23 hibernate-leg failures +suspend-then-hibernate failed its hibernate leg twice on 2026-08-23 (21:06 and +23:29): "Failed to put system to sleep. System resumed again: Device or +resource busy". Noticed during the 08-24 Lua-port session and parked there; +filed here at Craig's direction so the sleep task carries it. Nothing +diagnosed yet — first step is =journalctl -b -1 -u systemd-suspend-then-hibernate= +around those timestamps to see which device reported busy. + ** TODO [#A] Port Hyprland config to Lua before 0.57 drops .conf support :hyprland:dotfiles: +SCHEDULED: <2026-08-25 Tue> :PROPERTIES: :LAST_REVIEWED: 2026-08-24 :END: @@ -3236,638 +3360,6 @@ carry a date and Craig's return date isn't known yet — date it and raise it to Workaround in the meantime: Bluetooth mouse, already in use. -** DONE [#A] Hyprlock lockout: AMD-iGPU DPMS invalidates the lock, session wedges :bug:hyprland:installer:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =a9391c9= + dotfiles =3046c9c=, both pushed; applied live to ratio and velox. Reboot ratio to activate the root fix (=amdgpu.runpm=0=); the watchdog covers until then. - -WHAT HAPPENED. Ratio's screen idle-locked, then wedged: hyprlock gone, the compositor still holding the ext-session-lock, no password prompt, recoverable only from a console. Recovered live with =hyprctl dispatch exec hyprlock= (=allow_session_lock_restore=true= was already set, so a replacement client adopted the dead lock). - -ROOT CAUSE (evidence, not the first guess). My first read was "hyprlock crashed on its screenshot buffer" — WRONG. Coredumps are captured here (two telega SIGSEGVs the same afternoon) and there is NO hyprlock coredump, so it did not segfault; memory was fine, so not OOM. The hyprland log shows the real chain: =Modesetting DP-4= / =Restoring crtc 86= (a display modeset) → =color management protocol is enabled and outputs changed= → =SessionLock.cpp:50 SessionLockSurface object remains but surface is being destroyed=. A display power cycle tore down the lock surface. Online research confirms it's a documented AMD-integrated-Radeon issue (hyprlock#953, Hyprland#5822): the GPU resources the lock client holds become invalid when the display powers down and back up. Ratio is a Strix Halo Radeon 8060S — exactly that hardware, and its cmdline already carried =amdgpu.dcdebugmask=0x10= + =no_vpe_idle_pg=1= display workarounds, a history of the same fragility. - -THE FIX, four layers, research-validated: -1. Root cause: =amdgpu.runpm=0= on the kernel cmdline (AMD only, added in =update_grub_cmdline= behind =detect_gpu_vendors=). Keeps GPU runtime PM from invalidating the resources on a display cycle. Live in ratio's grub.cfg; effective next boot. -2. Separate crash cause: =configure_hyprlock_pam= writes a complete =/etc/pam.d/hyprlock= (auth/account/session). The package default is =auth include login= only, so pam_end() crashes on uninitialised handles. Applied live to both machines. -3. Recovery net: the =screen-lock= watchdog (dotfiles) relaunches hyprlock on a non-zero exit; hypridle's =lock_cmd= routes through it. Independently the same shape as the community's watchdog layer. -4. NOT done, deliberately: the =dpms off= listener stays in the committed hypridle — =runpm=0= makes it safe on AMD, and it's wanted on Intel/velox for idle display-off. Ratio's test rail already removed it as a local choice. - -REVERTED a wrong turn: I'd first built a screenshot-to-file change (grim the desktop, point hyprlock at the file) on the theory the live screencopy buffer crashed. The research showed the cause is GPU runtime PM, not the background source, so I dropped it and reverted hyprlock.conf to =path = screenshot=. - -PROCESS NOTE — I hit the pathspec-commit trap AGAIN (the one the =Two agent sessions sharing one repo= VERIFY documents). After surgically staging only the =lock_cmd= line via =git update-index=, I ran =git commit <path> -m ...=, which commits the WORKING TREE of that path, not the index — so it committed ratio's test rail (dpms-off removed, timeout 450) with a message claiming dpms-off stays. Caught it before push, =git reset --soft=, re-verified. The rule: after =update-index=, commit with =git commit= (no pathspec), never =git commit <path>=. - -Tests: archsetup 372 (test_grub_cmdline AMD-runpm cases + test_hyprlock_pam, both call sites in CALL_SITES); dotfiles 3687 incl. tests/screen-lock. Each guard proven by deletion. - -Grading: Critical severity (full session lockout, console-only recovery) x rare edge case (needs an idle lock plus a display modeset on the AMD iGPU) = P2 = [#B by the matrix]. Raised to [#A] here because it stranded a live machine and the root fix needs a reboot to arm — worth Craig seeing at the top until he reboots ratio. -** DONE [#B] Adversarial review of the sentry run — six fixes reworked :bug:test:tooling:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Craig asked for a skeptical review of every sentry change. Eight agents covered all 23 code commits, each told to disbelieve by default and to answer three questions per commit: does the problem exist and is it reachable, is the fix correct or is there a better one, would each test fail with the fix reverted. Every finding below was re-verified by hand before acting on it. - -SIX COMMITS NEEDED WORK, now fixed: archsetup =1207ca5= (wipedisk), =96e12b5= (firmware trim), =560e1dd= (autologin), =3c2155d= (initramfs tabs); dotfiles =ec7229b= (tunnel import), =a81aa0e= (thumbnail sweep), =56807e5= (three residual guards), =c90ee34= (event-log isolation). Both suites green: archsetup 341, dotfiles 3687 on both gates. - -THE ONE THAT MATTERED MOST. =wipedisk= ran =blkdiscard -f= BEFORE the busy check. =-f= disables the exclusive open util-linux has used since 2.36, so on the exact case the round-11 commit reasoned about — the user picked the wrong disk — it discarded a live filesystem and only then let sgdisk fail, printing "could not clear the partition table ... run this again". Data gone, user told nothing happened. The ordering predates the sentry commit, but round 11 wrote reasoning about the busy-disk case into the comment and error text while leaving the discard first, which made the misreport worse in the one direction that costs something. Dropping =-f= makes the kernel's own O_EXCL the gate. - -THREE PATTERNS WORTH MORE THAN THE INDIVIDUAL FIXES: - -1. CALL SITES WENT UNTESTED IN FIVE SUITES. Every helper had thorough tests; not one proved it was called. Deleting the call left everything green — including the guard on a =pacman -Rdd= of twelve firmware packages, whose removal would have run the trim on ratio. Closed with =CALL_SITES= in =test_orchestrators= (nine pairs, static) and a wiring assertion in the settings suite. Static on purpose: the behavioural harness runs un-stubbed bodies for real, which is fine for an orchestrator and not for a leaf that removes packages. - -2. A NEW OUTCOME VALUE NEEDS EVERY CONSUMER WALKED, EVERY TIME. Done for the portal enum in round 3, skipped for the tunnel-import one in round 4 — where =import_configs= folded a disarm failure into "none imported (N failed)", the opposite of what happened, in the multi-select flow the GUI actually uses. - -3. MY FIXTURES TWICE CLAIMED A FIDELITY THEY DID NOT HAVE. The wipedisk fixture used this machine's real disk names, so five of six tests passed with the seam removed. The mkplaylist fake does a full =cat > /dev/null= drain while its docstring says it "drains stdin exactly when the real one would" — which is what let the wrong failure mode survive. - -AND ONE FINDING WAS DISPROVED OUTRIGHT: round 1's =a57c443= claimed ffmpeg drains the read loop so only the first track is processed. Measured under strace and driven end to end with real ffmpeg (three runs of three, four 120s mp3s), the loop never truncates. The hazard is real and =-nostdin= is right; the symptom was reasoned from shellcheck SC2095 and never run. Corrected in =a30741a=, along with the OpenVPN autoconnect claim and the "four consumers" undercount. - -ALL THREE NOW CLOSED, in dotfiles =c7cb40d= (pushed). =_restore_dot='s =noop= split into =already-on= and =not-managed=, so the step stops claiming a restore that never happened. =_disable_dot= checks its restart as well as its move, since moving the drop-in aside does nothing until resolved reloads. - -The thumbnail one could not be built as described, and that is worth recording. The cache name is a SHA-1 of realpath plus mtime, so no filename says which source it came from; per-source sweeping would mean changing the key format and invalidating every cached thumbnail. Bounding the growth gets the same result for less: a deferred sweep now trims to a 500-file ceiling, oldest first, because eviction is safe exactly where sweeping is not (an evicted thumbnail is rebuilt on the next warm pass, costing one decode and never a file). WHEN A FIX CANNOT BE BUILT AS SPECIFIED, SAY SO AND SOLVE THE ACTUAL HAZARD — the hazard here was unbounded growth, not imprecise attribution. -** DONE [#D] Repair tiers call an unverifiable service restart a failed one :bug:network:bluetooth:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =041d6b9= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). 7 new tests across =tests/bt/test_bt.py= and =tests/net/test_net.py=; dotfiles suite 3665 -> 3672, =make test= exit 0 on both gates. Each of the three guards proven a real gate by deleting it and watching the suite go red. - -Found in the 2026-07-24 sentry bug-hunt, round 14, on the cross-package =repair.py= diff that rounds 5-13 had left unspent. - -=cmd.service_active= is tri-state in both the net and bt packages, and its docstring says so outright: True, False, or None when systemctl itself can't answer (absent binary, or a timeout). Six callers. Three rule on it correctly — =bt/doctor._service_step= branches on None with "systemctl unavailable — can't check the service", and =net/diag= compares =is False= at both its call sites. Three tested it with plain truthiness: - -- =bt/repair.py= =repair_service_restart= -- =net/repair.py= =_service_restart= (the nm-restart and resolved-restart tiers) -- =net/repair.py= =repair_unmask_nm= - -So an unanswerable systemctl was reported as "bluetooth.service is still not active" / "NetworkManager still isn't running after a restart" — a statement about the service made on no evidence at all. Each then pointed the user at =journalctl -u <unit>=, which is the same systemd client stack that had just failed to answer. That last part is round 10's read again: an error message advertising a remedy it cannot honour. - -All three now report =warn= on None, with evidence naming the verification rather than the service, and a next action of checking systemd is reachable and re-running the doctor. Control flow is unchanged: =warn= was already a status both packages emit, both CLIs already exit non-zero on anything but =pass=, and =net/doctor= only inspects a repair step's status for the =dns-test= tier — every consumer was checked before the change, not after. (An adversarial re-review counted twelve, not four; all twelve handle =warn= correctly, so the conclusion held while the claim understated the work.) - -THE SEAM FOR THE TESTS, worth reusing: both suites already carry an exec-failure harness that plants a non-executable file on an emptied PATH, which is exactly what makes =cmd.run= return None. So the None case is reachable through the real code path with no mocking at all. Each test class asserts that premise first (=service_active= really is None in the sandbox) rather than assuming it. - -Grading: Minor severity (the claim is wrong but errs pessimistic — it says a repair failed when it may have worked, rather than falsely reassuring; nothing is damaged) x rare edge case = P4 = [#D]. Fixed rather than filed because the change is three branches and it completes a class — leaving two of three sites collapsed is the failure mode the round-6 =c2eb3e1= commit exists to remember. - -NOT PART OF THIS CLASS, checked and left alone: =settings/toggles.dim_state= is the only other genuine True/False/None helper in the tree, and both its callers pass the value through to the viewmodel rather than collapsing it. Every other "or None" in the packages is two-state (a value or nothing), where falsy handling is correct. -** DONE [#B] Firmware trim gated on a DMI field that never carries the vendor :bug:tooling:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =2e228f7= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_framework_firmware_trim.py=, 12 tests carrying the real DMI strings off both daily drivers. Each of the three conditions proven load-bearing by deleting it and watching the suite go red, and the old gate proven wrong by restoring it (4 failures). - -Found in the 2026-07-24 sentry bug-hunt, round 13, reading archsetup's remaining state-mutating steps. =trim_firmware= gated on =grep -qi "framework" /sys/class/dmi/id/product_name= and no Framework machine has "framework" in =product_name= — it lives in =sys_vendor=. Read live: velox is =Framework= / ="Laptop (13th Gen Intel Core)"=, ratio is =Framework= / ="Desktop (AMD Ryzen AI Max 300 Series)"=. The gate returns false on both, so the step has been a silent no-op on the exact hardware it was written for. velox IS trimmed today (=linux-firmware-{atheros,intel,realtek,whence}= and nothing else) but not by this code path. - -THE REPAIR IS WHERE THE DANGER IS, which is why this is worth reading twice. Swapping =product_name= for =sys_vendor= is the obvious one-word fix and it is wrong: ratio is a Framework Desktop, and =trim_firmware= runs =pacman -Rdd linux-firmware-amdgpu=, which takes the firmware its Ryzen AI Max iGPU needs to bring up a display. Today only the =grep -qi intel /proc/cpuinfo= second gate stands between ratio and that. So =is_framework_intel_laptop= wants three DMI facts — vendor Framework, and a model naming both Laptop and Intel — and the cpuinfo read stays as an independent second gate rather than the only one. - -Verified live after the change: velox TRIM=yes, ratio TRIM=no, where the old gate said no to both. - -Grading: Minor severity (the trim never happens; nothing breaks, the machine just carries ~550MB it was meant to shed) x every user, every time (every Framework Intel install, which is the whole population the step targets) = P2 = [#B]. The AMD-firmware removal is not graded separately because it never shipped — it is the hazard the fix is shaped to avoid. -** DONE [#B] Fresh install leaves the dotfiles repo permanently dirty :bug:tooling:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =c3b3617= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_mark_volatile_configs.py=, 8 tests against a fixture git repo with =sudo= stubbed on PATH. Every guard proven a real gate by deletion. A note went to =~/.dotfiles/inbox/= because =skip-volatile= now has an outside caller. - -Found in the 2026-07-24 sentry bug-hunt, round 13, diffing archsetup's =stow_dotfiles= against the dotfiles Makefile's =stow= target — two implementations of one operation, which is round 5's read applied across repos rather than across packages. - -The Makefile's =stow= target ends with =$(MAKE) skip-volatile=, setting git's skip-worktree bit on the four configs their apps rewrite in place (=btop=, =qalculate=, =calibre=, =waypaper=; the list is =volatile-configs=). archsetup stows inline with raw =stow= calls and never ran that step. So a machine archsetup installed goes dirty the first time one of those apps writes its config, and every later =git pull --ff-only= trips over paths the user never edited. Confirmed by grep: archsetup contains no =skip-volatile=, no =volatile=, and no =make stow= — yet both daily drivers carry the bits, so they came from a hand-run =make stow=, not the installer. ratio in fact carries seven, three more than =volatile-configs= lists, which is evidence the churn is real and ongoing. - -The fix calls the dotfiles target rather than copying its logic, so the volatile list stays single-source. Two details that are load-bearing: it runs *after* =git restore .= so the bit lands on a pristine tree, and it runs as the user, because root writing =.git/index= leaves it root-owned and the user's next git command then cannot update the index at all. A checkout with no Makefile is a quiet no-op — nothing to delegate to is not an error. - -DELIBERATELY NOT DONE: replacing the whole inline stow with =make -C "$dotfiles_dir" stow "$desktop_env"=. The Makefile stows =--target=$(HOME)=, which during an install is root's home, and it carries interactive conflict handling; archsetup stows =--target=/home/$username --adopt= as root on purpose. =skip-volatile= is the one target with no such coupling — it works on the repo through =git -C= and never reads HOME. - -Grading: Minor severity (a repo that reads dirty forever and pulls that need a stash; the workaround is one command) x every user, every time (every fresh install that stows dotfiles) = P2 = [#B]. -** DONE [#C] Unattended install blocks on an interactive prompt :bug:tooling:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =cbcb53f= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_configure_autologin.py= (11) and =tests/installer-steps/test_select_locale.py= (11). Every guard proven a real gate by breaking it and watching the suite go red: dropping the autologin unattended branch fails 1 (on the leftover-stdin assertion, which is the real gate — the drop-in still gets written because the read swallows the sentinel and treats it as "yes"); dropping the locale unattended branch fails 1; breaking either precedence rule fails 2. - -Found in the 2026-07-24 sentry bug-hunt, round 12, continuing through archsetup's own installer. Two members of one class, which is the point: round 10 fixed the third member and left these. - -THE CLASS: an advisory prompt — one that carries its own default — still reading stdin under =--config-file=, the documented unattended mode. Round 10 ruled on it for =nvidia_preflight='s rc-10 prompt. Two sites never got the ruling. - -1. =configure_autologin=. When =enable_autologin= is unset (=AUTOLOGIN= is optional, and =archsetup.conf.example= line 31 ships it commented out) and the root is encrypted, it prompted =Enable automatic console login for $username? [Y/n]= on a bare =read=. It runs from =configure_encrypted_autologin=, inside =boot_ux=, the last entry in =STEPS= — so an unattended install of an encrypted machine works for 40-60 minutes and then sits at a prompt nobody is watching. Under =curl | bash= it is worse: stdin is the script itself, so the read eats a line of source. - -2. =select_locale= (extracted from =preflight_checks= by this commit). The =Choice [1]:= menu fired whenever =/etc/locale.conf= carried no =LANG== and =LOCALE= was unset — also commented out in the example config. archsetup does not require an archangel install, and =configure_build_environment='s own "no LANG=" branch is proof it expects that state. - -Both now take the prompt's own default under =--config-file= and print an =[OK] ... (unattended, --config-file)= line saying so. An explicit =AUTOLOGIN=yes/no= or =LOCALE== still wins; the default only answers a question nobody can. - -WHAT MADE THEM TESTABLE, which is round 10's read (d) applied again: =configure_autologin= hardcoded =/etc/systemd/system/getty@tty1.service.d= and =select_locale= hardcoded =/etc/locale.conf=, so neither could run against a fixture — while their siblings =replace_sudoers_pacnew= and =ensure_nvme_early_module= both take a defaulted path argument for exactly that reason. Both now do. Zero shellcheck delta against HEAD; =make test-unit= 276 -> 298, exit 0. - -Grading: Major severity (unattended installation, a documented feature, does not complete; recoverable by pressing a key, no data loss) x some users, sometimes (needs unattended mode plus an omitted key) = P3 = [#C]. - -THE PROMPTS DELIBERATELY LEFT ALONE, because the class is "prompts with a default", not "all prompts": username (line 636) and password (648/650) have no default to take — there is no sane fallback for either, and =archsetup.conf.example= documents both as "If not set, you will be prompted". They also fire in =preflight_checks=, in the first second of the run, where a blocked prompt is visible rather than silent. The "Enter locale" sub-prompt is reachable only from menu choice 9, which unattended never picks. -** DONE [#C] wipedisk says "Disk erased." when it erased nothing :bug:tooling:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/wipedisk/test_wipedisk.py=, 6 tests running the real script against a fixture device directory with fake blkdiscard/sgdisk on PATH. All four guards proven real by deleting each and watching the suite go red. - -Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 30 lines, no tests, and the most destructive script in the repo. Not installed by the installer; it is run by hand from the checkout, which is why the frequency axis stays low. - -Three defects, all of which make the script's final word untrue: - -1. =sgdisk --zap-all= had its result discarded, and "Disk erased." printed unconditionally. sgdisk refuses a busy device — a mounted filesystem or a live md/LVM/ZFS holder — which is exactly what a user hits after picking the wrong disk. So the tool announced an erase it had not performed and exited 0. - -2. "Disk erased." overstates what the tool does even on success. =sgdisk --zap-all= destroys partition tables, not data, and =blkdiscard -f ... || true= deliberately tolerates a device that cannot discard. On a disk without discard support the script cleared the partition table and left every byte readable, while telling the user the disk was erased. That is the one path where the wrong belief has a privacy consequence — someone trusting the message before disposing of a drive. - -3. The prompt says "Select the disk id to use" and then listed every entry in =/dev/disk/by-id=. On this machine that is 18 entries of which 12 are =-partN= partitions (verified by listing it). The menu promised disks and offered partitions. - -Fix: whole disks only (globbed rather than =ls | grep=, so a name with whitespace cannot split into two menu entries); the zap's result is checked and a failure exits 1 naming the busy-device cause; the closing message reports what actually happened, and when discard was unsupported it says the data is still recoverable and points at =nvme format= / =hdparm= for a disposal-grade wipe. - -Grading: Major severity (the tool reports an outcome it did not achieve; in the disposal case that is a data-exposure consequence) × rare edge case (a hand-run helper the installer does not install, and defect 1 additionally needs sgdisk to fail) = P3 = [#C]. - -Worth recording about the tests rather than the code: two of the six passed against the unmodified script for the wrong reason. Without the =WIPEDISK_BY_ID= override the script read the real =/dev/disk/by-id=, so the harness was driving a menu of this machine's actual disks (harmless — the fake blkdiscard/sgdisk shadowed the real ones on PATH — but it was not testing the fixture). And =test_empty_by_id_directory= was not a gate at first: with the guard deleted the empty select menu still falls through to the confirm prompt, reads EOF and declines, so exit code and call log alone pass either way. It now asserts the message. -** DONE [#B] zfs-replicate reports success when every backup failed :bug:backup:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). Diagnostics moved to stderr; the loop counts failures and exits 1 when any dataset failed. New =tests/zfs-replicate/test_zfs_replicate.py=, 9 tests driving the real script with a fake syncoid and a fake ping on PATH (the =tests/zfs-pre-snapshot/fake-zfs= pattern). Both fixes proven real gates by reverting them: dropping the counter fails 3, putting =error()= back on stdout fails 1. - -Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 73 lines with no test file, installed by =configure_zfs_snapshots= as =/usr/local/bin/zfs-replicate= and run by =zfs-replicate.service=, a =Type=oneshot= on a nightly timer. Its exit code and its journal output are the only signals anyone ever sees. - -Two defects, both verified by running the script rather than argued: - -1. The full-replication loop caught each =syncoid= failure, warned, carried on, then printed "Replication complete." and exited 0 regardless. Driven with a fake syncoid failing all four datasets: four =[WARN] Failed= lines, then "Replication complete.", exit code 0. systemd records =Result=success=. A backup that has not run for months is indistinguishable from a working one — and the whole point of the tool is to have a copy when the primary is gone. - -2. =determine_host= runs inside a command substitution (=TRUENAS_HOST=$(determine_host)=) and its =error()= wrote to stdout. On an unreachable TrueNAS the message was captured into =TRUENAS_HOST= and discarded, and =set -e= then killed the script. Driven with both hosts unreachable: exit 1 and completely empty output. A nightly service failing with nothing in the journal to say why. - -Same class as three bugs already fixed this session — =_restore_dot= claiming "DNS-over-TLS restored" without checking, =portal_restore_watch= discarding its outcome, =import_config= returning ok on an unchecked modify. A mutating operation that reports a success it did not get. - -Grading: Critical severity (a backup system that reports success while backing nothing up; the failure surfaces only when the backup is needed — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs a ZFS root, a reachable TrueNAS, and the user enabling the timer by hand — archsetup deliberately does not enable it, and =findmnt -n -o FSTYPE /= on this machine says btrfs, so it is latent here) = P2 = [#B]. - -Left alone: =BACKUP_PATH="backups" # TODO: Configure actual path= is still an unresolved TODO in the destination, and single-dataset mode relies on =set -e= to propagate a syncoid failure rather than reporting it. Neither is a defect in the sense above; the TODO is Craig's call. -** DONE [#D] Wireless regdom is silently unset for a three-letter-language locale :bug:installer:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =249bb93=. =locale_country= matches the =_CC= group instead of counting characters, and =set_wireless_regdom= verifies the substitution landed rather than trusting sed's exit code. 16 tests. -=configure_networking= derives the wireless regulatory domain by fixed offset: =wireless_region="${current_lang:3:2}"=, with a comment reading "extract country code (positions 3-4)". That is correct only for a two-letter language code. - -=validate_config= accepts =^[a-z]{2,3}(_[A-Z]{2})?...=, so a three-letter language is a legal =LOCALE=, and glibc ships 75 of them (=agr_PE=, =ast_ES=, =ber_DZ=, =ayc_PE=, ...). Verified by running the expansion: =ber_DZ.UTF-8= yields =_D=, =ayc_PE.UTF-8= yields =_P=, =C= yields the empty string, =POSIX= yields =IX=. - -The sed that follows only uncomments an existing =#WIRELESS_REGDOM="XX"= line in =/etc/conf.d/wireless-regdom= (176 of them, owned by wireless-regdb). A garbage region matches nothing, sed exits 0, and the =|| error_warn= never fires — so the regdom is never set and nothing says so. The task line does print the garbage region ("configuring wireless regulatory domain (_D)"), so it is visible in the log rather than fully silent. - -Confirmed the mechanism itself works for the normal case: line 168 of this machine's =/etc/conf.d/wireless-regdom= reads =WIRELESS_REGDOM="US"= uncommented, which is archsetup's own edit. - -Grading: Minor severity (WiFi falls back to the conservative "00" regdomain — fewer channels and lower tx power, but WiFi works) × rare edge case (one of 75 three-letter-language locales, or a =LOCALE= with no country) = P4 = [#D]. - -Fix when it comes up: derive the country from the =_CC= group by pattern rather than by offset, and warn when it cannot be derived or when the sed changed nothing. Worth doing together with the sibling gap — nothing in the installer verifies that a =sed -i= uncomment actually matched, so a distro reshuffling one of these config files would fail the same silent way. All 22 =sed -i= sites share that stance, so it is a uniform design choice rather than an odd one out. -** DONE [#B] Initramfs hook swap can leave a LUKS machine unbootable :bug:installer:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The swap moved into =switch_udev_hook_to_systemd=, which declines when =hooks_need_busybox_init= sees a standalone =encrypt= token, and the caller now rebuilds the initramfs only when the conf actually changed. New =tests/installer-steps/test_switch_udev_hook.py=, 10 tests; both guards proven real by breaking them (removing the refusal: 4 failures; loosening the token match to a bare =encrypt= substring: 1 failure). - -Found in the 2026-07-24 sentry bug-hunt, round 10. =configure_initramfs_hook= ran =sed -i '/^HOOKS=/ s/\budev\b/systemd/'= on any non-ZFS root, then =mkinitcpio -P=. Its only guard was =is_zfs_root=. - -Why that breaks a LUKS machine, verified against the installed mkinitcpio rather than argued: -- =/usr/lib/initcpio/install/systemd= line 70 is =add_symlink /init usr/lib/systemd/systemd=, so the systemd hook replaces the busybox init outright. -- =/usr/lib/initcpio/hooks/encrypt= is an =#!/usr/bin/ash= script whose entire body is a =run_hook()= function — the busybox init's mechanism. Under systemd init nothing calls it. -- =mkinitcpio= carries no conflict check for the pairing (grepped; nothing), so the rebuild succeeds and archsetup reports success. -- This machine's own =/etc/mkinitcpio.conf= documents the two valid pairings as separate examples: =udev= + =encrypt= (line 45) and =systemd= + =sd-encrypt= (line 51). The sed converted half of the first pairing and produced neither. - -Effect: on a LUKS root using the standard busybox =encrypt= hook, archsetup rewrites HOOKS to =systemd= while leaving =encrypt= behind, rebuilds the initramfs, and exits cleanly. At the next boot the root is never unlocked. The machine needs live media and manual mkinitcpio surgery to recover. - -The sibling asymmetry: =is_encrypted_root()= already exists in this script and =configure_autologin= uses it to branch on exactly this condition. The initramfs step consulted neither it nor HOOKS. =merge_grub_cmdline='s own comment names =cryptdevice== as a boot-critical parameter to preserve — and =cryptdevice== is read only by the =encrypt= hook, so archsetup explicitly anticipates the configuration that another of its steps then breaks. - -Grading: Critical severity (the machine will not boot and recovery needs external media — graded on the harm once in the failure state, not on how rarely it is entered) × some users, sometimes (LUKS-encrypted non-ZFS root using the busybox =encrypt= hook; deterministic for those machines, absent everywhere else) = P2 = [#B]. - -Deliberately not attempted: migrating =encrypt= to =sd-encrypt=. That means rewriting the kernel cmdline from =cryptdevice== to =rd.luks.name== against the volume's UUID, which is a real migration and not a mechanical edit. Refusing the cosmetic swap keeps a working machine working, which is the right trade against quieter fsck output. -** DONE [#D] keymap and consolefont hooks are inert under the systemd initramfs :bug:installer:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =249bb93=. The swap rewrites both to =sd-vconsole=, collapsing them into one entry and never duplicating an existing one. The open question is answered: this machine is KEYMAP=us with no encrypt hook, but the function runs on LUKS machines where a non-US layout at the passphrase prompt is exactly what sd-vconsole restores. 7 tests. -Same class as the =encrypt= bug above, but cosmetic rather than boot-critical, so it was filed rather than bundled into that fix. - -Enumerating the busybox-only hooks on this machine (every hook under =/usr/lib/initcpio/hooks/= defining =run_hook=/=run_earlyhook=/=run_latehook=) gives: btrfs, consolefont, encrypt, grub-btrfs-overlayfs, keymap, memdisk, resume, sleep, udev, usr. All go inert once =/init= is systemd. Of those, =encrypt= is the only boot-critical one — =resume= is handled natively by systemd's hibernate-resume generator, and =btrfs= by udev rules (this machine runs =btrfs= alongside =systemd= and boots fine). - -=keymap= and =consolefont= are the live leftovers. Run =grep '^HOOKS=' /etc/mkinitcpio.conf= on this machine: the line carries =systemd= plus =keymap consolefont= and no =udev=, so archsetup's swap has already run here and both hooks are installed into the image and never executed. The systemd equivalent is the single =sd-vconsole= hook, which is what the distro's own systemd example on line 51 of =/etc/mkinitcpio.conf= uses. - -Effect: the early-boot console keeps the default font and keymap until =systemd-vconsole-setup= runs in the real root. =add_nvme_early_module= sets =FONT=ter-132n= in =/etc/vconsole.conf= expecting it to apply at that stage, so the configured font is briefly not what archsetup asked for. - -Grading: Cosmetic severity (a few seconds of default console font on a machine that boots normally) × some users, sometimes = P4 = [#D]. - -Fix when it comes up: have =switch_udev_hook_to_systemd= also rewrite =keymap consolefont= to =sd-vconsole= when it performs the swap, and add the fixture cases to =tests/installer-steps/test_switch_udev_hook.py=. Worth confirming first whether a non-US keymap is ever needed at the initramfs prompt on a machine that reaches this path. -** DONE [#B] NVIDIA Wayland preflight blocks dwm and headless installs :bug:installer:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The NVIDIA block moved out of =preflight_checks= into a new =nvidia_preflight= function that returns early unless =desktop_env= is =hyprland= and archsetup is the one installing drivers. New =tests/nvidia-preflight/test_nvidia_preflight_gate.py=, 11 tests; each of the three guards was proven a real gate by deleting it and watching the suite go red (3, 1, and 1 failures respectively). - -Found in the 2026-07-24 sentry bug-hunt, round 10, reading archsetup's own installer. =preflight_checks= called =nvidia_preflight_report= unconditionally and exited 1 on rc 11 (repo driver below the 535 Wayland floor, or =pacman -Si nvidia-utils= unable to answer). The check is Wayland-specific — every line it prints names Wayland/Hyprland — but it ran before any =desktop_env= branch and consulted neither =desktop_env= nor =skip_gpu_drivers=. - -Effect, proven empirically rather than argued (three scenarios driven against the extracted block): =DESKTOP_ENV=dwm= plus =--no-gpu-drivers= on an NVIDIA machine with an old repo driver aborts the install; so does =DESKTOP_ENV=none=. Neither install ever runs a compositor, and =--no-gpu-drivers= means the user installs the driver themselves. Worse, the abort's own fix hint reads "install with DESKTOP_ENV=dwm (X11) instead" — the one remedy it prints is the one it refuses to honor, so the user has no working workaround short of editing the script. - -The sibling asymmetry that makes it an oversight rather than a decision: =install_gpu_drivers= returns early on =skip_gpu_drivers=, and =display_server= / =window_manager= both branch on =desktop_env= with a =none= arm that skips outright. The preflight gate applied neither ruling. - -Second defect at the same site, fixed in the same commit: the rc-10 path (card detected, driver fine) prompts with a bare =read=. =--config-file= is documented as "unattended installation", and =aur_install= already rules that a prompt not covered by =--noconfirm= "blocks forever waiting for input" on a headless install. The rc-10 prompt is advisory, so it now answers itself with its own =[Y/n]= default when a config file was supplied. rc 11 stays a hard stop either way. - -Grading: Critical severity (archsetup cannot be run at all on that machine, and the printed workaround does not work — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs an NVIDIA card, a repo driver below the floor or an unsynced pacman db, and a non-hyprland =desktop_env=; hyprland is the default and Craig's own machines are AMD and Intel) = P2 = [#B]. - -Noted, not fixed: =display_server= and =window_manager= both point their unknown-value hint at a =--desktop-env= flag that the argument parser does not implement. Both arms are unreachable today (=validate_config= rejects a bad =DESKTOP_ENV=, and without a config file the value is always the default), so it is a stale string rather than a live defect. -** DONE [#B] mkplaylist retags only the first file :bug:music:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =a57c443= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). =ffmpeg -nostdin= on the conversion call. New =tests/mkplaylist= suite, 12 tests; removing the flag turns the suite red (verified by reverting: 5 failures, green on restore). NOTE: the fake ffmpeg does a full =cat > /dev/null= drain, which the real one does not do — so the suite gates the flag's presence, not the production failure mode. The docstring claiming the fake "drains stdin exactly when the real one would" is false and should be corrected. -Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2095). =common/.local/bin/mkplaylist=: =generate_music_m3u= pipes the file list into =tag_music_file= (line 130), which consumes it with =while IFS= read -r file=. Inside that loop, =ffmpeg -i "$file" -vn -c:a flac "$outputfile"= (line 46) reads stdin by default for its interactive keyboard controls, so it consumes bytes the loop is relying on. - -CORRECTION (2026-07-24, from an adversarial re-review): the failure mode stated above — "the loop sees EOF and exits after the first file" — is WRONG, and this task originally asserted it. Measured under strace, ffmpeg polls fd 0 and reads roughly one byte per half-second of transcode wall time; flac encoding runs about 2000x realtime, so a ten-minute mp3 converts in ~0.28s and yields zero or one stolen byte, never a drain. Driven end to end with real ffmpeg against four 120s mp3s, three runs of three: all four were converted and retagged every time. The loop never truncated. - -What is real is the hazard, not the observed symptom: one stolen byte mangles a path, which makes mid3v2/metaflac fail and =set -e= abort the run loudly. =-nostdin= is still the right fix and the commit still stands. The original finding came from shellcheck SC2095 plus reasoning, and was never run — which is exactly what "verify before filing" exists to prevent. - -Effect: on a directory of non-flac audio, only the first file is converted and retagged. Files 2..N are silently skipped — no error, no output, and the playlist itself still generates (a separate =find=), so nothing signals that the retagging stopped. - -Grading: Major severity (the retagging feature is broken past the first file, and it fails silently) × most users frequently (the script exists to batch-process a directory, so more than one non-flac file is the normal case) = P2 = [#B]. - -Fix: =ffmpeg -nostdin= (or =< /dev/null= on the call). Verifiable with a fake =ffmpeg= on PATH asserting it is invoked once per input file. -** DONE [#C] timezone-change prints command-not-found instead of its help :bug:tooling:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review), together with the Portugal-zone defect below. New =tests/timezone-change= suite, 12 tests. -Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2288). =common/.local/bin/timezone-change=, default =*)= case (lines 63-67): =echo= sits alone on its own line, so the following quoted string runs as a *command* rather than as its argument. - -#+begin_src sh -*) - echo - "Invalid option chosen." - echo - "Some valid options are: eastern, central, pacific, rome, london, st_lucia, italy, france, spain ." - ;; -#+end_src - -The user gets two blank lines and two =command not found= errors; the list of valid options never prints. The timezone is correctly left unchanged, so this is an output defect only. - -Grading: Minor severity (wrong output on an error path, nothing corrupted) × some users sometimes (only on an unrecognized option) = P3 = [#C]. - -Fix: fold each string into its =echo=. Verifiable by running the script with a bogus argument and asserting the option list appears on stdout. -** DONE [#C] Thumbnail sweep wipes the whole cache when a wallpaper source is unreadable :bug:settings:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =0bd8c67= (committed locally, deliberately NOT pushed — held for Craig's morning review). 8 new tests. -Found in the 2026-07-24 sentry bug-hunt, reviewing the orphan sweep shipped the night before (dotfiles =e752a16=). =os.walk= stays silent about a directory it cannot enter, so =wallpaper.scan_sources= returns =[]= for a source that is missing, renamed, or permission-denied — the same answer it gives for a gallery the user emptied on purpose. =settings/cli.py= tick then hands that empty list to =thumbstore.sweep_orphans=, =live_names= comes back empty, and every cache-shaped file is classified an orphan. - -Proven empirically rather than reasoned: seeding three well-formed thumbnails plus a stray README, then sweeping against a nonexistent source directory, deleted all three (the README survived, so the cache-name regex guard works — it just doesn't help here). - -Effect once entered: the entire persistent thumbnail cache is deleted, so the next wallpaper-view open pays the cold-decode cost the cache was built to remove (measured at 3.7s for a viewport of Craig's largest 8, which is what tripped the compositor's kill prompt), and the tick needs roughly ten idle beats — about twenty minutes — to rewarm at =WARM_PER_BEAT= 8. - -Grading: Major severity (grading the being-in-it, per the don't-double-count-rarity rule: the cache is gone, the original freeze returns, and recovery is unattended and slow) × rare edge case (both configured sources — =~/videos/wallpaper= and =~/pictures/wallpaper= — are local directories, so this needs one deleted, renamed, or made unreadable while a beat fires; a removable or network source would hit it routinely) = P3 = [#C]. - -Fixed in this session: new =wallpaper.sources_available(sources)= tells "readable and empty" apart from "could not read", and =sweep_orphans= grew a =sources_ok= parameter that declines to sweep when it is False. Deferring a sweep costs only some stale files; sweeping wrongly costs the whole cache. -** DONE [#C] timezone-change sets a nonexistent zone for Portugal :bug:tooling:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review). =Europe/Lisbon=. The suite also pins the general invariant: every zone the script can emit must exist in tzdata, so a future bad entry fails at test time rather than in Craig's hands. -Found in the 2026-07-24 sentry bug-hunt, validating every zone the script sets against =/usr/share/zoneinfo=. =common/.local/bin/timezone-change= line 39 maps =portugal= / =lisbon= to =Europe/Portugal=, which is not a tzdata identifier — the real one is =Europe/Lisbon= (a bare =Portugal= legacy alias also exists at the top level, but not under =Europe/=). =timedatectl set-timezone "Europe/Portugal"= fails, so the timezone is never changed. - -The other 17 zones the script sets all resolve correctly, so this is the single bad entry. - -Grading: Major severity (the option is wholly broken — the zone is not set and the command errors) × rare edge case (one option of eighteen, hit only when actually switching to Portugal) = P3 = [#C]. - -Fix: =Europe/Lisbon=. Verifiable by asserting the argument handed to a fake =timedatectl=, plus a suite-wide check that every zone the script names exists in the tzdata database. -** DONE [#C] settings-project stop() can SIGTERM an unrelated process :bug:settings:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 2, reviewing =settings/src/settings/project.py=. =stop()= read a pid out of =$XDG_RUNTIME_DIR/settings-project.pid= and SIGTERMed it with no check that the pid still belonged to the projection. A projection that dies without running =stop()= (crash, OOM, a failed =execvpe= on the clock path — that last one was already noted as tolerated residue) leaves the file behind, so once the kernel wraps its pid counter that pid can name something else entirely, and the next =start= or =stop= kills it. - -This is a hazard the codebase had already ruled on elsewhere and simply hadn't applied here: =maint/src/maint/doctor.py= revalidates =/proc/<pid>/comm= against the expected name before its KILL remedy fires, explicitly to refuse recycled pids. - -Grading: Major severity (grading the being-in-it — an arbitrary user process takes a SIGTERM, and an editor with unsaved work is a plausible victim) × rare edge case (needs an unclean exit *and* pid reuse; =pid_max= here is 4194304, so wrap-around takes a very long time) = P3 = [#C]. - -Fixed as dotfiles =722994e= (committed locally, deliberately NOT pushed — held for Craig's morning review). The pidfile now records the process start time from =/proc/<pid>/stat= next to the pid, and =stop()= fires only when the recorded value still matches the live process. Start time is the right token rather than =comm=: it is mode-independent (the clock channel execs into =python3=, so comm changes while comm-matching would have needed per-mode knowledge) and it is exec-stable, verified directly — pid and start time were identical either side of an =execvpe=. A recycled pid cannot reproduce it. Legacy bare-pid pidfiles keep the old unconditional behavior so the upgrade never strands a live projection. -** DONE [#C] wtimer alarms fire an hour off on the eve of a DST change :bug:timer:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 3, reading =timer/src/timer/engine.py=. =parse_alarm= resolves a bare wall-clock time ("07:00") to its next occurrence: it builds today's instant, and when that is already past it rolled forward with =epoch += 86400=. A DST day is 23 or 25 hours long, so a fixed 86400 lands on the wrong wall time whenever tomorrow crosses a transition. - -Reproduced against America/Chicago and the two 2026 US transitions. Asking for =07:00= at 08:00 on Sat 2026-03-07 (spring forward that Sunday) gave 08:00 Sunday — an hour late. Asking for =07:00= at 08:00 on Sat 2026-10-31 (fall back that Sunday) gave 06:00 Sunday — an hour early. - -The recurring path was never affected, which is what makes this an oversight rather than a design choice: =next_alarm= walks candidate days and rebuilds =datetime(y, m, d, hh, mm)= per day, so it is already DST-correct. Only the one-shot rollover took the shortcut. Both were pinned by the new tests. - -Grading: Major severity (grading the being-in-it — an alarm that fires an hour off has wholly failed at the one thing an alarm does, and the fall-back direction wakes you early while the spring-forward direction lets you oversleep) × rare edge case (two nights a year, and only when the requested wall time has already passed today) = P3 = [#C]. - -Fixed as dotfiles =9b6c2c9= (committed locally, deliberately NOT pushed — held for Craig's morning review). The rollover now rebuilds the local time on tomorrow's calendar date, the same construction =next_alarm= uses. Eight tests pin =TZ=America/Chicago= (saved and restored around each case), covering both transitions, the twelve-hour form, an ordinary-day control, a DST eve where the requested time is still ahead, and two characterization cases asserting the recurring path stays DST-safe. -** DONE [#B] net portal-restore claims encrypted DNS is back without checking :bug:net:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 3, reading =net/src/net/repair.py=. A captive-portal login moves the DNS-over-TLS drop-in aside so plain DNS can reach the venue's login page, and =_restore_dot()= moves it back afterwards. It fired both privileged steps — the =mv= and the =systemctl restart systemd-resolved= — and returned ="restored"= without reading either result. =repair_portal_restore()= then rendered a pass step reading "DNS-over-TLS restored". - -So a declined or failed =sudo -n mv= left DNS-over-TLS off while the tool told the user it was back on. The same for a resolved restart that fails: the drop-in is on disk but the running resolver is still serving plain DNS. - -The asymmetry is what makes it an oversight rather than a decision. The sibling =_disable_dot()=, twenty lines up, checks its own move with =_ok()= and returns False rather than claiming a success it did not get. The restore half simply never got the same treatment, and it is the half where the failure is silent — the disable path's failure is visible immediately because the portal page won't load. - -Grading: graded on severity alone under the privacy carve-out. DNS queries continue in cleartext to the venue resolver on an untrusted network, and the affirmative "restored" message is what removes the user's reason to check. Bounded by =net diagnose='s =encrypted-dns= step, which exists precisely to catch a portal run that never restored, so the exposure ends at the next diagnose rather than persisting unseen forever. Major severity = P2 = [#B]. - -Fixed as dotfiles =018c0c5= (committed locally, deliberately NOT pushed — held for Craig's morning review). Both privileged steps are now checked, with two new outcomes: ="failed"= when the move back fails (encrypted DNS still off, rendered as a fail step) and ="unapplied"= when the drop-in is back but resolved would not restart (rendered as a warn step). Each names the command to run by hand. Four tests cover both failures at the =_restore_dot()= and step levels, mirroring the existing declined-move test on the disable side. -** DONE [#B] the portal restore watcher fails silently, so DNS stays in the clear :bug:net:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 4, reading the rest of =net/src/net/repair.py= after the round-3 fix above. =portal_restore_watch()= polls until the link comes back online, calls =_restore_dot()=, and discards the outcome entirely. - -Three things compound into a silent failure. The watcher is spawned detached with =stdin=, =stdout=, and =stderr= all on =/dev/null=, so nothing it could print reaches anyone. It runs outside the =repair()= dispatch, so unlike every other mutating tier it never wrote an event-log line either. And =repair_portal_login= tells the user "encrypted DNS restores itself once you're online", which is precisely what removes their reason to check. A ="failed"=, ="unapplied"=, or ="ambiguous"= restore therefore left the machine on plain DNS on a venue network with no signal at any level. - -This is the round-3 finding one layer out, and the asymmetry is the tell: =018c0c5= taught =repair_portal_restore()= — the *manual fallback* — to stop claiming a success it did not get, while the *automatic* path, the one that actually runs in the normal flow, kept dropping the same result on the floor. Fixing the fallback and leaving the primary silent is a worse split than the original bug. - -Grading: graded on severity alone under the privacy carve-out, exactly as the round-3 sibling. Same exposure (cleartext DNS to an untrusted venue resolver), same bound (=net diagnose='s =encrypted-dns= step catches the stranded state), and the same affirmative promise removing the reason to look. Major severity = P2 = [#B]. - -Fixed as dotfiles =601c5b4= (committed locally, deliberately NOT pushed — held for Craig's morning review). The watcher now returns the outcome, appends a =portal-restore-watch= event with it, and fires a persistent =notify security= alert on each of the three failing outcomes, each naming the command to run by hand. A clean restore stays silent. Five tests: one per failing outcome, one pinning the silence on a clean restore, and one on the event-log line. The whole =TestPortalLogin= class now shadows =notify= with a logging fake, so no future watcher test can fire a real desktop notification mid-suite. -** DONE [#D] dns-override failure path says "reverted" without checking :bug:net:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =2cf3fb3=. The revert is checked; a declined one now says 1.1.1.1 is still set and names =resolvectl revert <iface>=. -Found in the 2026-07-24 sentry bug-hunt, round 3, sweeping for siblings of the portal-restore finding above. =net/src/net/repair.py=, =repair_dns_override()= failure path: when the 1.1.1.1 override doesn't restore resolution, it calls =priv.run("dns-revert", iface)=, discards the result, and returns evidence reading "override didn't restore resolution — reverted". A failed revert leaves 1.1.1.1 set on the link while the step says it was removed. - -Same defect class as the portal-restore bug, three hundred lines up in the same file, and it survived the sweep only because the consequence is much smaller. Every other mutating repair in this file verifies by re-measuring afterwards rather than by reading an exit code, which is the stronger pattern and is why the sweep otherwise came back dry. - -Grading: Minor severity (a stale per-link override sends DNS to Cloudflare instead of the venue resolver, it dies on the next reconnect, and =net diagnose='s =dns-override-present= step exists specifically to catch it) × rare edge case (needs the override to fail *and* the revert to fail) = P4 = [#D]. - -Fix: the same idiom the portal-restore fix now uses. Wrap the revert in =_ok()= and drop the "— reverted" claim (or say the revert failed and name =resolvectl revert <iface>=) when it returns False. The existing =RepairHarness= makes the privileged call fail with =NET_SUDO="false"=, so the test is a near-copy of =test_restore_reports_failure_when_the_move_back_is_declined=. -** DONE [#B] a timezone-less Date header crashes the whole net diagnose run :bug:net:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/diag.py=. =_clock_skew_s()= fetches the probe server's =Date= header with =curl -sI=, parses it with =parsedate_to_datetime=, and subtracts it from a timezone-aware =datetime.now(timezone.utc)=. RFC 5322 allows a =Date= to carry =-0000=, which means UTC while explicitly claiming no local zone, and a =Date= with no zone at all parses leniently as well. Both come back *naive*, and subtracting a naive datetime from an aware one raises =TypeError=. - -The =try= wraps only the =parsedate_to_datetime= call, so the =TypeError= from the line below it is uncaught. It escapes =_clock_skew_s=, escapes =_steps_egress_edges=, and takes down the entire =diagnose()= run — no report, no steps, a Python traceback. =net doctor= runs diagnose first, so the panel's doctor button dies with it. - -Verified against Python 3.14.6 before writing the fix: =parsedate_to_datetime("Thu, 01 Jan 2020 00:00:00 -0000")= returns =tzinfo=None=, and the subtraction raises. The zoneless form behaves the same. Only the =GMT= form (which the well-behaved probe host sends) comes back aware, which is why this never showed up in normal use. - -What makes it more than a curiosity is *when* the code runs. =_steps_egress_edges= fires only after the http-probe has already failed, so the server answering that =HEAD= is frequently a captive portal's interception appliance rather than the real probe host — and a minimal embedded HTTP stack is exactly the kind that emits a non-GMT =Date=. The one path guaranteed to be talking to a non-standard server is the one that can't survive a non-standard header. - -Grading: Major severity (grading the being-in-it — the diagnostic tool produces no report at all, and =net doctor= goes with it, on precisely the broken network it exists to diagnose) × rare edge case (needs a failing probe *and* a portal appliance that omits a numeric offset) = P2 = [#B]. - -Fixed as dotfiles =8933500= (committed locally, deliberately NOT pushed — held for Craig's morning review). A naive parse is now read as UTC, which is what =-0000= means. Two tests, and the second is the one that matters: it drives a *current* =-0000= timestamp and asserts no clock row, so a lazy "catch =TypeError= and return None" fix would fail it while the correct reading passes. -** DONE [#C] a tunnel import that can't be disarmed still reports success :bug:net:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =import_config()= imports a WireGuard or OpenVPN config as an NM profile, then fires =nmcli connection modify <uuid> connection.id <name> connection.autoconnect no= — and discarded the result, returning =ok=True= regardless. - -That modify is the whole safety of the feature, and the module's own docstring says so: =nmcli connection import= *auto-activates* the profile it creates, "which nobody asked for by picking a file", so "every import here ends with the profile deactivated and autoconnect off". A failed modify inverts that. For WireGuard — a device-type connection — autoconnect stays on, so the tunnel re-arms itself at the next boot and takes the default route with it, and the profile keeps the transient staged interface name (=wgpvpn=) while the envelope reports the config's real name, so the panel names a profile that isn't there. - -CORRECTION (2026-07-24, from an adversarial re-review): the blanket claim originally written here — that a failed disarm re-arms the tunnel at boot — is wrong for OpenVPN. =man 5 nm-settings-nmcli= states autoconnect is not implemented for VPN profiles, and an OpenVPN import is an NM VPN profile, so the modify is near-cosmetic on that half. The bug is real and security-relevant for WireGuard, which is the primary case; the severity as stated overreached to cover both. - -The asymmetry, again the tell: =_nmcli_import()=, twenty lines up in the same file, checks its own =returncode= and raises rather than return a UUID it did not get. The modify below it never got the same treatment. - -Grading: Major severity (grading the being-in-it — a full-tunnel VPN the user never asked to connect arms on every boot and carries all their egress, it persists across reboots rather than self-healing, and the affirmative "imported X" is what removes the reason to check) × rare edge case (needs the modify to fail after the import succeeded) = P3 = [#C]. - -Fixed as dotfiles =e0d4d8a= (committed locally, deliberately NOT pushed — held for Craig's morning review). New =_disarm()= returns whether the modify took. On failure the profile is still deactivated first — the import already brought it up, and the verdict shouldn't decide whether it keeps running — and then a =disarm-failed= envelope names the UUID and the exact command to finish the job. Three tests: the failing verdict, =import_configs= counting it as failed rather than imported, and a characterization test pinning that the deactivate still runs on the failure path. -** DONE [#C] a binary that can't be exec'd crashes the panels instead of degrading :bug:net:bluetooth:audio:maint:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 5, comparing the four panel packages' subprocess wrappers against each other. - -Every wrapper in the panels states the same contract: an unusable tool becomes a degraded result, never an exception. =cmd.run= returns None; =nmcli.run=, =btctl.run= and =pactl.run= raise their own domain error, which every caller already guards on; =speedtest.run_speedtest= returns an error envelope. All of them caught only =FileNotFoundError=, so they kept the contract for a tool that is *absent* and broke it for a tool that is *present but unusable*. - -Verified against Python 3.14.6 rather than argued. =subprocess.run= raises =PermissionError= for a file without its execute bit, =OSError= (ENOEXEC, "Exec format error") for an executable file that is neither a binary nor a script with a shebang, =NotADirectoryError= when a path component is a plain file, and =OSError= when a fork is refused under memory or PID pressure. None of the four is =FileNotFoundError=, so each escapes the guard: waybar's net/bt/audio modules die rather than dimming, and a maint probe takes the whole envelope with it — in exactly the machine state maint exists to report on. - -The asymmetry, and this codebase had already ruled on it three separate times: =net/iw.py='s =signal_dbm= and =settings/spawn.py='s =detached= both catch =(OSError, subprocess.TimeoutExpired)=, and =audio/cmd.py='s doctor-tier =probe()= enumerates =FileNotFoundError=, =NotADirectoryError= and =PermissionError= as "absent" under a docstring promising it never raises. Its sibling =run()=, twenty lines up in the same file, kept the narrow catch — as did all five copies of =run()= and all three tool wrappers. =audio/status.py='s docstring records that this same class already bit once ("the bar's audio module died rather than dimming"); that fix widened the guard's *scope* and left its *exception set* alone. - -Grading: Major severity (grading the being-in-it — the status surface is dead while the condition holds, and for maint the tool that reports the fault is the one that dies of it; no data loss, and it clears when the tool or the pressure does) × rare edge case (needs a binary with wrong permissions, a lost shebang, or a fork refused under pressure) = P3 = [#C]. - -Fixed as dotfiles =44fdae1= (committed locally, deliberately NOT pushed — held for Craig's morning review). Widened to =OSError= across net, bt, audio, maint and panelkit — five =cmd.run= helpers, the three tool wrappers, =probe._curl= and =speedtest.run_speedtest=. The domain-error wrappers keep their "<tool> not found" message for a genuinely absent binary and add a second arm naming the errno for an unusable one, so the report can still tell the two apart. 28 tests, one class per package, driving all three exec failures against real files on a temp PATH; each was watched failing against unmodified production code first (27 red). Audio's class carries a characterization case pinning =cmd.probe='s existing behavior, so the sibling that got this right can't regress into the one that didn't. -** DONE [#C] a failed pty-backed spawn strands both ends of the pty :bug:net:bluetooth:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 6, auditing the =subprocess.Popen= sites the round-5 fix didn't reach. - -Two spawns open a pty before launching and catch only =FileNotFoundError= around the =Popen=: =bt/pairing.py='s =pair_interactive= (bluetoothctl under a pty so the passkey agent is interactive) and =net/speedtest.py='s =run_speedtest_stream= (speedtest-go under a pty because it buffers everything to exit when piped). Both are the same exec-failure class as =44fdae1= — a binary present but not executable raises =PermissionError=, a lost shebang raises =OSError= — and neither is =FileNotFoundError=. - -What makes these worse than the =run= wrappers is where the cleanup lives. =os.close(master)= and =os.close(slave)= sit *inside* the =FileNotFoundError= arm, so an escaping =OSError= skips them: every failed attempt strands two descriptors. Both call sites are buttons in a long-lived panel process — the pairing flow and the console's SPEED key — and a user who gets no feedback presses again, so the leak accumulates under exactly the conditions that caused it. - -Grading: Major severity (grading the being-in-it — a descriptor leak in a process meant to run for days, on a path the user retries, plus the exception escaping a documented "(ok, detail)" / error-envelope contract) × rare edge case (needs an unusable bluetoothctl or speedtest-go) = P3 = [#C]. - -Fixed as dotfiles =c2eb3e1= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm on each closes both ends and returns the module's own failure shape, naming the errno. Four tests: two pin the return contract, two count =/proc/self/fd= across three attempts — the fd count is what actually fails against unmodified code, and it was watched failing before the fix. - -The wider sweep this came from is recorded so it isn't repeated: every =except FileNotFoundError= in production was enumerated. The other exec sites were already correct (=maint/gui.py= x3, =net/kick.py=, =timer/engine.py= x2, =timer/gui.py=, =net/repair.py= x2, =audio/peak.py= all catch =OSError=), and the remaining hits are file-open catches, not exec. =clock/__main__.py='s =toggle()= has no guard at all but spawns =sys.executable=, which is by definition runnable; not filed. -** DONE [#C] one impatient client kills the clock panel's toggle listener for good :bug:clock:waybar:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 6, sweeping every acquired resource (pty, socket, mkstemp, tempdir) for cleanup that isn't in a =finally=. - -=clock/src/clock/app.py='s =_listen()= guards =accept()= with =except OSError: return= and leaves the request body — =recv=, =runtime_log=, =sendall= — outside any guard. =send_toggle()= in =__main__.py= gives the panel 0.25s to acknowledge, then closes. An ack later than that hits a dead peer and raises =BrokenPipeError=, which escapes the =while= loop and ends the listener thread. - -Verified empirically, not argued: a client that connects, sends, and gives up after 250ms makes the server's =sendall= raise =BrokenPipeError= (errno 32) and the listener thread exits. - -What makes it Major rather than a nuisance is that it neither self-heals nor announces itself. The socket file stays bound, so every later =clock toggle= still *connects* — then stalls the full 250ms, gets no reply, and falls through to spawning =clock serve=. GTK's single-instance forwarding turns that into =do_activate= on the running service, and =do_activate= calls =show_clock()=, not =toggle()=. So from the first bad client onward, clicking the waybar time module opens the panel every time and never closes it; the only ways out are the right-click dismiss inside the panel or restarting the service. Nothing logs it. - -Grading: Major severity (grading the being-in-it — the toggle is one-way from then on, it persists for the life of the service, and there is no signal it happened) × rare edge case (needs a reply to miss the 250ms budget: a busy main loop mid-redraw, a slow runtime-log write, or an interrupted =clock toggle=) = P3 = [#C]. - -Fixed as dotfiles =7c02614= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm around the request body scopes a dead peer to its own request, mirroring the guard =accept()= already had. =GLib.idle_add= runs before the ack, so the user's click still takes effect — only the acknowledgement is lost. New =tests/clock/test_socket.py=, 3 tests driving the real =_listen= against a stand-in owner (it touches only =self._socket= and =self.toggle=, so no Gtk.Application is needed). The gate is the second toggle after an impatient first: it times out on unmodified code because no listener is left. The other two pin what the fix must preserve — the toggle fires even when the ack can't be delivered, and an unknown command is still answered without toggling. - -Left alone deliberately: =do_activate= calling =show_clock()= rather than =toggle()=. Changing it would alter what a cold =clock toggle= does on first launch, which is a design call for Craig rather than part of this defect. Worth raising if he ever wants the spawn path to toggle too. -** DONE [#B] fuzzel breaks the pinentry protocol loop on every passphrase :bug:security:gpg:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 7 — from the live journal rather than from reading. Grepping this boot for tracebacks turned up four instances of =pinentry-fuzzel: line 36: read: 0: read error: Resource temporarily unavailable=, and every one sits 4-7 seconds after a =GETPIN= (the time it takes to type a passphrase). The =BYE= handler's log line never appears once. - -=hyprland/.local/bin/pinentry-fuzzel= speaks the Assuan pinentry protocol on a pipe gpg-agent keeps open, reading one command per iteration of =while read cmd rest=. The =GETPIN= arm shells out to fuzzel, which *inherits that pipe as its stdin*. fuzzel runs an event loop over its own input, so it sets =O_NONBLOCK= on fd 0 — and =--dmenu= would read the pipe as menu items besides. The flag lands on the shared open file description and outlives fuzzel, so the shell's next =read= fails with =EAGAIN= and the loop ends mid-protocol. - -Grading: Minor severity (the passphrase is delivered *before* the break, so decrypts still succeed and nothing is corrupted — what's lost is everything after: =BYE= is never acknowledged, and gpg-agent's same-connection retry after a wrong passphrase, =SETERROR= then =GETPIN= again, can't be served; that retry is what the script's "reenter" label exists for, and it has never once been reachable) × every user, every time (four for four in the journal, and the test reproduces it deterministically) = P2 = [#B]. - -Fixed as dotfiles =e727dcd= (committed locally, deliberately NOT pushed — held for Craig's morning review). =< /dev/null= on the fuzzel call, so the non-blocking flag lands somewhere harmless; =--lines 0= was already there, so no menu input was ever wanted. =ENABLE_LOGGING= became env-overridable as a test seam — the script logs through an absolute =/usr/bin/logger= that PATH can't shadow, so without it every test run would write ten lines into the real journal. - -New =tests/pinentry-fuzzel/=, 8 tests driving the real script over a live pipe the way gpg-agent does. The fake fuzzel sets =O_NONBLOCK= on whatever fd 0 it is handed, exactly as the real one does, which is what makes them a gate rather than a restatement of the fix. Four fail against unmodified code — one reproducing the journal's message verbatim — and one records the fd fuzzel was given, pinning the cause rather than the symptom. - -THE CALIBRATION NOTE, and it is about my own earlier sweep. This is the same shape as round 1's =a57c443= (ffmpeg draining the pipe a =while read= loop was consuming). Round 1 swept both repos for siblings of that bug and came back empty — because it searched for the *mechanism* (a child that drains stdin) rather than the *shape* (a child that inherits stdin at all inside a read loop). Two different mechanisms, one shape, and the narrower search missed a live daily-use instance. Scope a class sweep by shape, not by the mechanism of the first instance found. -** DONE [#C] a truncated webcam record strands every camera off :bug:settings:privacy:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt, round 8, sweeping production for non-atomic file writes. - -=settings/src/settings/webcam.py='s =_record()= wrote =~/.local/state/settings/webcam.json= with a plain truncate-in-place =open(path, "w")=. That record is the only route back on, and the module docstring says so: deauthorizing a camera removes its video4linux nodes, so =usb_devices()= returns nothing afterward and =_recorded()= becomes the sole source of the paths to re-authorize. A write that truncated and then failed left an empty file; =_recorded()= caught the resulting =JSONDecodeError= and returned =[]=; =_known_devices()= then had nothing; and =set_power(True)= returned None without re-authorizing anything. Every camera stranded off, with no way back through the panel until a replug or a reboot. - -The asymmetry, seventh instance of this read: six other state writers in the tree already write through a temp file and a rename — =maint/cache=, =net/cache=, =audio/ptt=, =timer/engine=, =settings/store=, =maint/curation=. The one whose loss is most expensive was the one that didn't. - -Grading: Major severity (grading the being-in-it — the privacy switch becomes one-way, the panel offers no route back, and the user has to know to replug the camera or write sysfs by hand; bounded by the fact that a reboot re-enumerates USB and restores authorized=1) × rare edge case (needs a crash or ENOSPC inside a microsecond-wide write window) = P3 = [#C]. - -Fixed as dotfiles =8b40b79= (committed locally, deliberately NOT pushed — held for Craig's morning review). =_record= now mirrors =store.save=: =mkstemp= in the target directory, write, =os.replace=, unlink the temp on any failure. Four tests; the gate is a =_record= whose =json.dump= raises, after which the previous record must still be readable — it isn't on the old code. The other three pin what the fix must preserve: no temp-file residue, the =_recorded()= round trip, and the end-to-end power-off/power-on with the class symlinks removed, which is the scenario the record exists for. - -HOW IT WAS FOUND, and it confirms round 7's lesson twice over. Round 4 ran an atomic-write sweep and reported "nine sites, six unique-per-writer, three sharing a fixed =.tmp=" — it enumerated the writers that *were* atomic and compared their temp-file naming, and never asked which state writers aren't atomic at all. Same narrowing that made round 1's stdin sweep miss the pinentry bug: the sweep was scoped to a property of the instances already found rather than to the shape of the hazard. -** DONE [#D] a failed wallpaper apply reports "nothing to apply" :bug:settings:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =2cf3fb3=. The decision moved to =gui.wallpaper_apply_toast=, a module-level pure helper, because the callback lives inside a GTK widget where no test can reach it. 5 tests. -Found in the 2026-07-24 sentry bug-hunt, round 7, sweeping the settings panel's worker callbacks. - -=settings/gui.py='s =_async= passes an exception through as the *result* rather than as a separate error argument, so every =done= callback has to test =isinstance(res, Exception)=. Five do — =_mx_pin=, =_mx_letter=, =_after_matrix=, =_set_pointer=, the drum/dial/gallery/refresh callbacks. =_wp_apply= is the one that doesn't: - -#+begin_src python -def _wp_apply(self, note="Wallpaper set"): - self._async(lambda: panel.wallpaper_apply(self.state), - lambda ok: self._toast( - note if ok is True else "nothing to apply", - good=ok is True)) -#+end_src - -=panel.wallpaper_apply= calls =store.save=, which can raise =OSError= (disk full, a permissions change on the config dir). The exception then arrives as =ok=, =ok is True= is False, and the toast reads "nothing to apply" — describing a no-op when the apply actually failed. The toast is at least marked =good=False= (red), so the user gets a negative signal; what's lost is the reason, which every sibling callback surfaces via =str(res)=. - -Grading: Minor severity (wrong text on an error path, correctly marked as a failure, nothing corrupted) × rare edge case (needs =store.save= or =wallpaper.apply= to raise rather than return False) = P4 = [#D]. - -Fix: give it the same =isinstance(res, Exception)= arm its five siblings have — toast =str(res)= on an exception, keep the current two-way message otherwise. One callback, three lines. -** DONE [#D] two manage.py nmcli reads sit outside their own error conversion :bug:net:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =2cf3fb3=. =_key_mgmt= converts both nmcli exceptions to "", which both call sites already treat as neither wpa-eap nor sae. 2 tests, including one driving =_classify_up_failure= end to end. -Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =nmcli.run()= raises =NmcliTimeout= on timeout and =NmcliError= on a missing binary, and every mutation in this module is written to convert both into a result envelope. Two calls escape that conversion because they run through =_key_mgmt()=, which wraps =nmcli.get_value= and catches nothing: - -- =edit()= line 243 calls =_key_mgmt(uuid)= for the enterprise-profile refusal *before* its own =try=, while the next four lines catch exactly those two exceptions around =nmcli.run=. -- =_classify_up_failure()= calls it on =up()='s failure path, so a slow =connection show= turns a classifiable activation failure into an exception. - -Consequence is a leaked exception where the caller expected an envelope. The panel absorbs it — =gui.bg()= catches =Exception= and renders =str(e)= — so there it degrades to a worse message rather than a crash. =net edit= from the CLI has no such catch and prints a traceback. - -Grading: Minor severity (the operation fails either way; what's lost is the classified message, and only the CLI path shows a traceback) × rare edge case (=connection show= has a 2s timeout and nmcli's presence is already established by the time either site runs) = P4 = [#D]. - -Fix: give =_key_mgmt= the same conversion its callers use — catch =(nmcli.NmcliError, nmcli.NmcliTimeout)= and return "", which both call sites already handle correctly (neither "wpa-eap" nor "sae"). One =try= in one helper covers both sites. -** DONE [#D] three atomic writers share one fixed .tmp name :bug:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Fixed as dotfiles =2cf3fb3=. All three carry =.tmp.$(getpid)=, matching the six writers that already did. 6 tests across audio and maint. -Found in the 2026-07-24 sentry bug-hunt, round 4, sweeping both repos for the temp-file half of the atomic-write idiom. The tree writes state atomically in nine places, and six of them make the temp path unique per writer: =net/cache.py= and =timer/engine.py= both use =f"{path}.tmp.{os.getpid()}"=, and =settings/store.py=, =settings/idle.py=, =bt/repair.py=, =net/probe.py= all use =tempfile.mkstemp=/=NamedTemporaryFile=. Three use a bare =path + ".tmp"=: - -- =audio/src/audio/ptt.py= =write_state= (the lead carried over from round 3's Next Steps) -- =maint/src/maint/cache.py= =put= -- =maint/src/maint/curation.py= =_write_user= - -=os.replace= makes the *rename* atomic, but a shared temp name is not: two writers open the same path, the second truncates under the first, and the file that gets renamed into place is a blend of both. The loser's own =os.replace= then raises =FileNotFoundError=, because the winner already renamed the name out from under it. - -Real concurrent-writer pairs exist for two of the three. =maint/cache.py= =updates_repo= is written by =maint-net-scan.timer= hourly and again by =doctor._fresh_pending()= at UPDATE fire time. =audio/ptt.py= has three writers by design (the CLI toggle bound to a key, the waybar right-click, and the GTK panel) — its module docstring says so. =curation.py= is written by panel key presses and CLI verbs. - -Grading: Minor severity (every reader degrades rather than crashes — =cache.get= catches =ValueError= and reports no data, =read_state= reads a torn file as disarmed, and both recover on the next write; the sharpest edge is the loser's =FileNotFoundError= aborting the rest of =scan_net=, which the next hourly run repairs) × rare edge case (the write window is a millisecond or two, and the overlapping writers are an hourly timer against a human keypress) = P4 = [#D]. - -Fix: give all three the =f"{path}.tmp.{os.getpid()}"= form the two careful siblings already use. It is three one-line changes and needs no new abstraction. Note this closes the torn-file half only — the read-modify-write in =ptt.toggle_plan= and =curation.set_preference= can still lose an update between two writers, which wants a lock rather than a temp-name change and should stay a separate decision. -** DONE [#C] dmenuexitmenu word-splits its menu so no entry matches :bug:dwm:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2128). =dwm/.local/bin/dmenuexitmenu= line 4 expands the menu unquoted: =choice=$(echo -e $menuitems | dmenu ...)=. Word-splitting collapses the runs of spaces the labels carry, so dmenu shows =Lock= where the =case= arm expects =Lock = (two spaces) and =Logout = where the arm expects a trailing space. No arm matches, so choosing an entry does nothing at all. - -CORRECTION (2026-07-24): THE BUG AS FILED DOES NOT EXIST. Ran it. =echo= rejoins the words split off the unquoted expansion with single spaces, and no label carries two spaces, so quoted and unquoted produce byte-identical output — verified against the exact literals from git rather than a retyped copy. Every =case= arm matches and every menu action works. - -What is real is latent. An unquoted expansion collapses a double space and glob-expands a =*=; the second was demonstrated turning a label into a directory listing. No current label triggers either. - -Hardened anyway in dotfiles =2cf3fb3= as robustness, not as a bug fix: the expansion is quoted and the bogus one-element array is now a plain string. Output confirmed unchanged byte-for-byte. New =tests/dmenuexitmenu/= (10 tests) pins the working behaviour, and shellcheck on the file drops from three findings to one. - -SECOND SENTRY FILING DISPROVED BY RUNNING IT, after =a57c443= (mkplaylist). Both came from a shellcheck hit plus reasoning, neither was executed. A static-analysis finding says a construct is unsafe, not that it currently misbehaves, and both filings treated the first as the second. -** DONE [#C] Timer module hero hierarchy :feature:waybar:timer:quick:solo: -CLOSED: [2026-07-24 Fri] -From the roam inbox (Craig, claimed 2026-07-22). Which display ("hero") wins the waybar timer module when several timer modes run simultaneously: pomodoro wins over everything (the user is actively working; it's likely their main focus). The rest rank in chronological order of when they would ring. Worked example: with a just-started 15-min timer, a 1-hr timer at 10 minutes left, a pomodoro, and an alarm ringing in 12 minutes — show the pomodoro; when it completes, the 1-hr timer (rings first), then the alarm, then the 15-min timer. Feeds the timer-panel spec (docs/specs/2026-07-02-timer-panel-spec.org). - -Shipped as dotfiles =9eedb39=. Pomodoro wins the hero, then soonest-to-ring, in both selectors (=engine.select_primary= for the bar, =panel.primary_id= for the GTK hero). Craig's worked example is a test. FLAGGED FOR CRAIG: the two selectors diverge on a *ringing* alarm (the bar excludes it, the panel gives it the hero) and I left that as-is rather than reverse a deliberate choice. Whether to unify them is your call. -** DONE [#C] Timer module: drop RING message, persistent notifications :bug:waybar:timer:quick:solo: -CLOSED: [2026-07-24 Fri] -From the roam inbox (Craig, claimed 2026-07-22). Remove the RING message from the timer module display; verify all timer and alarm notifications are persistent; the icon returns to normal once the notification has fired. Rationale: keeps timers and pomodoros from interfering with one another's displays (pairs with the hero-hierarchy task above). - -Shipped as dotfiles =9eedb39=. The tooltip no longer prints RING or a (ringing) suffix; a fired alarm shows its clock time and its persistent notification carries the alert. Verified the timer and alarm completion notes already set persist=True. -** DONE [#C] PTT icon outline removal :bug:waybar:quick:solo: -CLOSED: [2026-07-24 Fri] -From the roam inbox (Craig, claimed 2026-07-22): the waybar PTT icon should not have an outline. Cosmetic × every-glance = P3 = [#C]. - -Shipped as dotfiles =e63c0cf= (live style.css + dupre theme source). Removed the amber/green text-shadow glow from the armed/talk states, the only outline-like effect on the icon. FLAGGED FOR CRAIG: this is my read of "outline" (the glow). If you meant the glyph shape itself, it's a one-line revert. Confirm live by pressing PTT. -** DONE [#C] Video wallpapers don't fit the desktop :bug:dotfiles:solo: -CLOSED: [2026-07-24 Fri] -From the roam inbox (Craig, claimed 2026-07-23): videos don't fit the desktop in desktop-settings. The video channel drives mpvpaper (=settings/src/settings/wallpaper.py=); mpvpaper passes options through to mpv, so the fit is a =--panscan=/=--video-unscaled=/keepaspect question rather than a layout one. Reproduce with a video whose aspect differs from the output, pick the mode that fills without distorting (cover, matching how the image channels behave), and cover it in the wallpaper tests. Minor severity × whenever the video channel is selected = P3 = [#C]. - -Shipped as dotfiles =04d1489=. =set_video= now passes =panscan=1.0=, so mpvpaper fills the output and crops the overflow instead of letterboxing; keepaspect stays on so nothing stretches. Tested against the mpvpaper arg log. -** DONE [#C] World-clock wallpaper arrangement :feature:dotfiles: -CLOSED: [2026-07-24 Fri] -Shipped 2026-07-24 as dotfiles =6afbe09=, iterated live with Craig. The grid of boxed mini-clocks became a centered vertical clock line: cities down a spine, west (Honolulu) top to east (Wellington) bottom, labels alternating both sides, no boxes. Each shows city / time (12h) / day+date / timezone region name ("US Central"). Day/night dimming + amber home carried over, title dropped, cursor restored over the desktop. Prototypes archived in archsetup 40216e7. The face is parameterized (=?layout=vertical|horizontal=, =?hour12=1|0=) so the panel pickers below can drive it. -** DONE [#C] Floating layout — should we? :feature:hyprland: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -From the roam inbox (Craig, claimed 2026-07-23): consider whether Hyprland should offer a floating layout — how it would work, the benefits, and the complexity. A brainstorm/spike, not a build: the deliverable is an assessment Craig reads and decides on, not a shipped layout. Not :solo:. When picked up, run it as a brainstorm — how a floating mode coexists with the current tiling binds (toggle keybind, per-workspace vs global, window-rule interactions), what it buys over the existing =togglefloating=, and the config/muscle-memory cost — then bring Craig the recommendation. - -CONCRETE PROPOSAL from a second roam item (Craig, 2026-07-24 via work) — "floating mode as the easiest mode": -- Can't select floating until at least one window is displayed. -- Entering floating freezes each window's position and floats it exactly where it is. -- During floating, drag windows with mod+mouse-drag. -- Exiting floating switches to tiling or monocle and lets that layout take over. -Craig's note: "simple, could be useful for different reasons." This is the design the brainstorm should evaluate first — assess feasibility against Hyprland's actual float/tile transitions (does freezing current geometry survive the tiling↔floating switch, does re-tiling on exit reflow cleanly) before recommending. - -ASSESSED, dotfiles =8cf4728=: =docs/2026-07-24-floating-layout-assessment.org=. Verdict: buildable and worth building on a capture-then-restore of window geometry (=hyprctl clients -j= gives at/size), which is a real gesture plain =togglefloating= can't express. Craig's four-rule proposal is folded in and each rule assessed. One taste call flagged (exit to previous layout vs always monocle). Ready to file a build task on Craig's go. -** DONE [#C] World clock wallpaper: bold the city names :feature:dotfiles:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -From the roam inbox (Craig, 2026-07-24 via work): bold the city names on the world-clock wallpaper face (=settings/faces/world.html=, shipped =6afbe09=). Cosmetic × every glance at the world face = P3 = [#C]. Solo — a CSS weight change, screenshot-verifiable — but it's a visual call, so build it and show the render rather than close off a green suite. Pairs with the open world-face picker task. - -Shipped as dotfiles =e63c0cf=. =.lbl .city= is now =font-weight:700=. Rendered offscreen and confirmed the bold reads well over the time/zone lines; home city stays amber. Comparison render was on ws5 for Craig. -** DONE [#C] Floating clock toggles on control+mod+c :feature:dotfiles:hyprland:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -From the roam inbox (Craig, 2026-07-24 via work): a control+mod+c keychord should toggle the floating clock, the same as clicking the time waybar module. - -This answers the design question the round-6 clock-toggle fix deliberately left open (see the =clock toggle listener= DONE task above): =do_activate= calls =show_clock()= rather than =toggle()=, and the note there flagged "worth raising if he ever wants the spawn path to toggle too." He does. Build: a hyprland keybind bound to =clock toggle=, and confirm the toggle path (not show-only) fires whether the service is cold or warm. Solo — buildable and locally verifiable. - -Shipped as dotfiles =e73a70e=. =bind = $mod CONTROL, C, exec, clock-panel toggle= reuses the exact command the time module's click runs, so it toggles identically. Registered clean on reload. Live keypress is Craig's to confirm. -** DONE [#C] Calculator scratchpad won't toggle closed on mod+x :bug:hyprland:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -From the roam inbox (Craig, 2026-07-24 via work): =mod+x= opens the calculator scratchpad but doesn't close it — Craig has to kill the window by hand. A second =mod+x= should toggle it shut. Almost certainly a =togglespecialworkspace= vs plain =exec= binding in the hyprland config, or a scratchpad window-rule mismatch. Minor severity (a workaround exists: kill the window) × every time the calc scratchpad is used = P3 = [#C]. Solo — a keybind/window-rule fix, locally verifiable. - -Shipped as dotfiles =e73a70e=. New =calc-toggle= script (mirrors fuzzel-toggle: pgrep -x, pkill or launch), and =mod+X= now points at it, so a second press closes the calculator. 3 tests in tests/calc-toggle. -** DONE [#C] Saving and recalling window configurations :feature:hyprland: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-24 -:END: -From the roam inbox (Craig, 2026-07-24 via work), a research idea: Craig wants to save a specific window+app arrangement and have it reappear on demand. What has to be known and built to make that happen — is there prior art (another WM or OS that does session/layout save-restore), what information do those need (app identity, geometry, workspace, launch command), and what are their rules. Explore how far Hyprland can get (hyprctl clients + dispatch, exec rules, window rules by class/title), document thoroughly, and review with Craig next time. Not :solo: — the deliverable is an assessment he reads and decides on, and it may spawn a build task once the shape is clear. Offer to file the build separately if part of it turns out urgent. - -RESEARCHED, dotfiles =8cf4728=: =docs/2026-07-24-window-config-save-recall-assessment.org=. Prior art surveyed (i3/sway =append_layout= swallow, KDE window rules, macOS Moom). Three tiers from cheapest: (1) reposition open windows — buildable + testable now; (2) relaunch + place by class rule; (3) full swallow-by-title, which hits the same-class ambiguity every tool hands back to the user. Recommends shipping tier 1; tiers 2-3 need Craig's call on how much manual disambiguation he'll accept. ** DONE [#B] Weather tooltip caching :feature:waybar:weather:solo: CLOSED: [2026-07-25 Sat 10:53] From the roam inbox (Craig, claimed 2026-07-22): retrieve the weather tooltip data once per hour and cache it. If the network is unavailable, display the cached tooltip with explanatory text saying so. Dotfiles-side work (archsetup owns the lifecycle); touches common/.local/bin/weather. |
