From 8ef4eb24a2c0a9c9ef7a476c8c57a911936b9ef8 Mon Sep 17 00:00:00 2001 From: Martin Bastien Date: Thu, 23 Jul 2026 09:10:16 -0400 Subject: [PATCH 1/4] Keep Btrfs snapshots out of the locate index and put /home in it The stock Arch updatedb.conf interacts badly with Omarchy's Btrfs layout in both directions: - Snapper snapshots under /.snapshots are nested subvolumes reached by plain directory traversal, so updatedb indexes the entire system once per snapshot. On machines that accumulated snapshots this means multi-hour updatedb runs at full CPU, gigabytes of RAM, and a multi-gigabyte plocate.db (observed: 18 GB db, 7.5 h runs at 96% CPU with 592 snapshots; 43 MB and ~1 min after the fix). - PRUNE_BIND_MOUNTS="yes" treats Btrfs subvolume mounts like /home as bind mounts, so locate finds nothing in home directories at all. Configure updatedb.conf at install time and migrate existing installs, then rebuild the index in the background. Both settings are matched tolerantly and appended when absent, so a hand-edited updatedb.conf is fixed rather than silently skipped. --- install/config/all.sh | 1 + install/config/locate.sh | 23 +++++++++ migrations/1784809451.sh | 30 +++++++++++ test/shell.d/locate-test.sh | 100 ++++++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 install/config/locate.sh create mode 100644 migrations/1784809451.sh create mode 100644 test/shell.d/locate-test.sh diff --git a/install/config/all.sh b/install/config/all.sh index e416b7bc..c4c70752 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -4,5 +4,6 @@ run_logged "$OMARCHY_INSTALL/config/lockscreen-pam.sh" run_logged "$OMARCHY_INSTALL/config/fix-powerprofilesctl-shebang.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" +run_logged "$OMARCHY_INSTALL/config/locate.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" diff --git a/install/config/locate.sh b/install/config/locate.sh new file mode 100644 index 00000000..d532f5df --- /dev/null +++ b/install/config/locate.sh @@ -0,0 +1,23 @@ +UPDATEDB_CONF_PATH="${OMARCHY_UPDATEDB_CONF_PATH:-/etc/updatedb.conf}" + +echo "Configuring locate to skip Btrfs snapshots and index Btrfs subvolumes" + +[[ -f $UPDATEDB_CONF_PATH ]] || exit 0 + +# Btrfs subvolume mounts (like /home) look like bind mounts, so pruning +# bind mounts leaves them out of the index entirely. +if grep -qE '^PRUNE_BIND_MOUNTS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then + sed -i -E 's|^PRUNE_BIND_MOUNTS[[:space:]]*=.*|PRUNE_BIND_MOUNTS = "no"|' "$UPDATEDB_CONF_PATH" +else + printf '%s\n' 'PRUNE_BIND_MOUNTS = "no"' >>"$UPDATEDB_CONF_PATH" +fi + +# Snapper snapshots are nested subvolumes reached by plain directory +# traversal, so without this updatedb indexes the system once per snapshot. +if ! grep -E '^PRUNEPATHS[[:space:]]*=' "$UPDATEDB_CONF_PATH" | grep -qF '/.snapshots'; then + if grep -qE '^PRUNEPATHS[[:space:]]*=[[:space:]]*"' "$UPDATEDB_CONF_PATH"; then + sed -i -E 's|^(PRUNEPATHS[[:space:]]*=[[:space:]]*")|\1/.snapshots |' "$UPDATEDB_CONF_PATH" + else + printf '%s\n' 'PRUNEPATHS = "/.snapshots"' >>"$UPDATEDB_CONF_PATH" + fi +fi diff --git a/migrations/1784809451.sh b/migrations/1784809451.sh new file mode 100644 index 00000000..0cf9d1a0 --- /dev/null +++ b/migrations/1784809451.sh @@ -0,0 +1,30 @@ +echo "Configure locate to skip Btrfs snapshots and index Btrfs subvolumes" + +OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +locate_config_script=/usr/share/omarchy/install/config/locate.sh +if [[ ! -f $locate_config_script ]]; then + locate_config_script="$OMARCHY_PATH/install/config/locate.sh" +fi + +UPDATEDB_CONF_PATH="${OMARCHY_UPDATEDB_CONF_PATH:-/etc/updatedb.conf}" + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +[[ -f $UPDATEDB_CONF_PATH ]] || exit 0 + +if grep -q '^PRUNE_BIND_MOUNTS = "no"' "$UPDATEDB_CONF_PATH" && + grep -E '^PRUNEPATHS' "$UPDATEDB_CONF_PATH" | grep -qF '/.snapshots'; then + exit 0 +fi + +as_root env OMARCHY_UPDATEDB_CONF_PATH="$UPDATEDB_CONF_PATH" bash -euo pipefail "$locate_config_script" + +# Rebuild the index with the new exclusions; pruning /.snapshots turns +# multi-hour runs on snapshot-heavy systems back into one-minute runs. +as_root systemctl start --no-block plocate-updatedb.service >/dev/null 2>&1 || true diff --git a/test/shell.d/locate-test.sh b/test/shell.d/locate-test.sh new file mode 100644 index 00000000..700b5bd2 --- /dev/null +++ b/test/shell.d/locate-test.sh @@ -0,0 +1,100 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +config_script="$ROOT/install/config/locate.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stock_conf() { + cat >"$1" <<'CONF' +PRUNE_BIND_MOUNTS = "yes" +PRUNEFS = "9p afs autofs cifs fuse nfs nfs4 proc sysfs tmpfs" +PRUNENAMES = ".git .hg .svn" +PRUNEPATHS = "/afs /media /mnt /net /sfs /tmp /udev /var/cache /var/lib/pacman/local /var/lock /var/run /var/spool /var/tmp" +CONF +} + +conf="$test_tmp/updatedb.conf" +stock_conf "$conf" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config indexes Btrfs subvolume mounts like /home" +grep -qF 'PRUNEPATHS = "/.snapshots /afs' "$conf" || fail "locate config prunes /.snapshots" +pass "locate config skips Btrfs snapshots and indexes Btrfs subvolumes" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +[[ $(grep -o '/\.snapshots' "$conf" | wc -l) -eq 1 ]] || fail "locate config is idempotent" +pass "locate config leaves an already-configured file alone" + +OMARCHY_UPDATEDB_CONF_PATH="$test_tmp/missing.conf" bash -euo pipefail "$config_script" >/dev/null +pass "locate config tolerates a missing updatedb.conf" + +# A hand-edited updatedb.conf may drop the settings entirely, or write them +# without the spaces around the "=" that the stock Arch file uses. +conf="$test_tmp/sparse-updatedb.conf" +printf '%s\n' 'PRUNENAMES = ".git .hg .svn"' >"$conf" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config adds a missing PRUNE_BIND_MOUNTS" +grep -qFx 'PRUNEPATHS = "/.snapshots"' "$conf" || fail "locate config adds a missing PRUNEPATHS" +pass "locate config adds settings a hand-edited updatedb.conf is missing" + +conf="$test_tmp/unspaced-updatedb.conf" +printf '%s\n' 'PRUNE_BIND_MOUNTS="yes"' 'PRUNEPATHS="/tmp /var/tmp"' >"$conf" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config rewrites an unspaced PRUNE_BIND_MOUNTS" +grep -qFx 'PRUNEPATHS="/.snapshots /tmp /var/tmp"' "$conf" || fail "locate config prunes /.snapshots in an unspaced PRUNEPATHS" +[[ $(grep -c '^PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config keeps a single PRUNEPATHS setting" +pass "locate config handles updatedb.conf written without spaces around =" + +locate_migration=$(grep -rl 'Configure locate to skip Btrfs snapshots' "$ROOT/migrations" | head -n 1 || true) +[[ -n $locate_migration ]] || fail "locate migration exists" + +fake_bin="$test_tmp/bin" +mkdir -p "$fake_bin" + +cat >"$fake_bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +chmod +x "$fake_bin/sudo" + +cat >"$fake_bin/systemctl" <<'STUB' +#!/bin/bash +printf 'systemctl %s\n' "$*" >>"$TEST_LOG" +STUB +chmod +x "$fake_bin/systemctl" + +conf="$test_tmp/migration-updatedb.conf" +stock_conf "$conf" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_PATH="$ROOT" \ +OMARCHY_UPDATEDB_CONF_PATH="$conf" \ + bash -euo pipefail "$locate_migration" >/dev/null + +grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate migration rewrites updatedb.conf" +grep -qF 'PRUNEPATHS = "/.snapshots /afs' "$conf" || fail "locate migration prunes /.snapshots" +grep -qFx 'systemctl start --no-block plocate-updatedb.service' "$test_tmp/calls.log" || fail "locate migration rebuilds the locate index without blocking" +pass "locate migration fixes existing installs and rebuilds the index" + +: >"$test_tmp/calls.log" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_PATH="$ROOT" \ +OMARCHY_UPDATEDB_CONF_PATH="$conf" \ + bash -euo pipefail "$locate_migration" >/dev/null + +[[ ! -s $test_tmp/calls.log ]] || fail "locate migration skips already-configured installs" +pass "locate migration is a no-op once updatedb.conf is configured" From d548c064eef6509e2a4998ac427be7cb75e00074 Mon Sep 17 00:00:00 2001 From: Martin Bastien Date: Thu, 23 Jul 2026 09:10:16 -0400 Subject: [PATCH 2/4] Remove Snapper timeline snapshots leaked by earlier defaults Installs from before the Snapper setup was normalized ran hourly timeline snapshots. Newer configs stopped creating them but never deleted the existing ones, and number cleanup skips snapshots marked Cleanup=timeline, so they sit there forever: one machine installed from the 2026-05-11 ISO had accumulated 592 of them, silently pinning 219 GB of disk. The limine-snapper-sync limit-mismatch warning that would have surfaced this is disabled by default since the notifier migration. Delete leaked timeline snapshots in batches of 20 (a single mass delete can die on a DBus timeout partway through), and only when TIMELINE_CREATE="no" so anyone who deliberately re-enabled timeline snapshotting keeps their setup untouched. The drain is best effort: a batch that fails is skipped rather than aborting the migration run and everything queued behind it, since the next run re-lists whatever is left. --- migrations/1784809452.sh | 43 +++++++++ test/shell.d/snapper-timeline-leak-test.sh | 104 +++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 migrations/1784809452.sh create mode 100644 test/shell.d/snapper-timeline-leak-test.sh diff --git a/migrations/1784809452.sh b/migrations/1784809452.sh new file mode 100644 index 00000000..e32da36b --- /dev/null +++ b/migrations/1784809452.sh @@ -0,0 +1,43 @@ +echo "Remove Snapper timeline snapshots leaked by earlier defaults" + +SNAPPER_CONFIG_PATH="${OMARCHY_SNAPPER_CONFIG_PATH:-/etc/snapper/configs/root}" + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +command -v snapper >/dev/null || exit 0 +[[ -f $SNAPPER_CONFIG_PATH ]] || exit 0 + +# Only clean up when timeline snapshotting is off, as Omarchy configures it. +# Anyone who deliberately turned it back on keeps their snapshots. +grep -qFx 'TIMELINE_CREATE="no"' "$SNAPPER_CONFIG_PATH" || exit 0 + +# Earlier installs ran hourly timeline snapshots. Later configs stopped +# creating them but never deleted the existing ones, and number cleanup +# skips snapshots marked Cleanup=timeline, so they pile up forever: +# hundreds of snapshots pinning 100+ GB of extents on long-running machines. +leaked=$(as_root snapper -c root --csvout list --columns number,cleanup 2>/dev/null | awk -F, '$2 == "timeline" { print $1 }' || true) +[[ -n $leaked ]] || exit 0 + +echo "Deleting $(wc -w <<<"$leaked") leaked timeline snapshots (disk space is reclaimed in the background)" + +# Delete in small batches; one big delete can die on a DBus timeout partway. +# A failed batch must not take the rest of the migration run down with it, so +# the drain is best effort: whatever survives is picked up by the next run. +batch=() +for number in $leaked; do + batch+=("$number") + if (( ${#batch[@]} == 20 )); then + as_root snapper -c root delete "${batch[@]}" || true + batch=() + fi +done + +if (( ${#batch[@]} > 0 )); then + as_root snapper -c root delete "${batch[@]}" || true +fi diff --git a/test/shell.d/snapper-timeline-leak-test.sh b/test/shell.d/snapper-timeline-leak-test.sh new file mode 100644 index 00000000..a5adb84f --- /dev/null +++ b/test/shell.d/snapper-timeline-leak-test.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +leak_migration=$(grep -rl 'timeline snapshots leaked by earlier defaults' "$ROOT/migrations" | head -n 1 || true) +[[ -n $leak_migration ]] || fail "Snapper timeline leak migration exists" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +fake_bin="$test_tmp/bin" +mkdir -p "$fake_bin" + +cat >"$fake_bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +chmod +x "$fake_bin/sudo" + +cat >"$fake_bin/snapper" <<'STUB' +#!/bin/bash +printf 'snapper %s\n' "$*" >>"$TEST_LOG" +if [[ "$*" == *"--csvout list"* ]]; then + echo "number,cleanup" + for i in $(seq 1 45); do + echo "$i,timeline" + done + echo "100,number" + echo "101," +fi +STUB +chmod +x "$fake_bin/snapper" + +snapper_config="$test_tmp/root" +printf '%s\n' 'TIMELINE_CREATE="no"' 'NUMBER_CLEANUP="yes"' >"$snapper_config" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_SNAPPER_CONFIG_PATH="$snapper_config" \ + bash -euo pipefail "$leak_migration" >/dev/null + +deletes=$(grep -c '^snapper -c root delete ' "$test_tmp/calls.log" || true) +[[ $deletes -eq 3 ]] || fail "leak migration deletes snapshots in batches" "expected 3 delete calls, got $deletes" + +first_batch=$(grep -m1 '^snapper -c root delete ' "$test_tmp/calls.log") +[[ $first_batch == "snapper -c root delete $(seq -s ' ' 1 20)" ]] || fail "leak migration caps delete batches at 20 snapshots" "$first_batch" + +last_batch=$(grep '^snapper -c root delete ' "$test_tmp/calls.log" | tail -n 1) +[[ $last_batch == "snapper -c root delete $(seq -s ' ' 41 45)" ]] || fail "leak migration deletes the final partial batch" "$last_batch" + +! grep -E '^snapper -c root delete .*\b(100|101)\b' "$test_tmp/calls.log" || fail "leak migration only deletes timeline snapshots" +pass "leak migration removes leaked timeline snapshots in batches and keeps the rest" + +# omarchy-migrate runs under set -e, so a batch that dies on a DBus timeout +# would otherwise abort the run and skip every migration queued behind it. +: >"$test_tmp/calls.log" +printf '%s\n' 'TIMELINE_CREATE="no"' 'NUMBER_CLEANUP="yes"' >"$snapper_config" + +cat >"$fake_bin/snapper" <<'STUB' +#!/bin/bash +printf 'snapper %s\n' "$*" >>"$TEST_LOG" +if [[ "$*" == *"--csvout list"* ]]; then + echo "number,cleanup" + for i in $(seq 1 45); do + echo "$i,timeline" + done + exit 0 +fi +echo "failure: dbus timeout" >&2 +exit 1 +STUB + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_SNAPPER_CONFIG_PATH="$snapper_config" \ + bash -euo pipefail "$leak_migration" >/dev/null 2>&1 || + fail "leak migration survives a failed delete batch" + +deletes=$(grep -c '^snapper -c root delete ' "$test_tmp/calls.log" || true) +[[ $deletes -eq 3 ]] || fail "leak migration keeps draining after a failed batch" "expected 3 delete calls, got $deletes" +pass "leak migration tolerates a batch that fails partway" + +: >"$test_tmp/calls.log" +printf '%s\n' 'TIMELINE_CREATE="yes"' >"$snapper_config" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_SNAPPER_CONFIG_PATH="$snapper_config" \ + bash -euo pipefail "$leak_migration" >/dev/null + +[[ ! -s $test_tmp/calls.log ]] || fail "leak migration leaves deliberate timeline setups alone" +pass "leak migration skips systems where timeline snapshots are intentional" + +: >"$test_tmp/calls.log" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_SNAPPER_CONFIG_PATH="$test_tmp/missing" \ + bash -euo pipefail "$leak_migration" >/dev/null + +[[ ! -s $test_tmp/calls.log ]] || fail "leak migration skips systems without a Snapper root config" +pass "leak migration is a no-op without Snapper configured" From a6a13cf3e6b7f6dfbc8aa129dc6f337577a0a2fa Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:00:02 -0700 Subject: [PATCH 3/4] Replace the in-flight updatedb and read PRUNEPATHS out of any quoting The machines this targets are the ones with an updatedb already grinding through every snapshot, and `systemctl start` on an active unit is a no-op. updatedb reads /etc/updatedb.conf once at startup, so a run that began before the rewrite keeps burning CPU on the old config until it finishes. Restart the service instead: it's Type=oneshot and plocate builds into a temp db, so nothing is lost by replacing the run. Quotes are optional in updatedb.conf, so parse the existing paths out of whatever quoting the file uses and write the setting back in one canonical form. `PRUNEPATHS=/tmp` previously fell through to the append branch and got a second PRUNEPATHS line, which drops /tmp from the pruned set. Comparing whole paths rather than substrings also keeps a config that already prunes something like /var/lib/machines/.snapshots from being mistaken for one that prunes /.snapshots. Prefer $OMARCHY_PATH over the packaged copy when locating the config script, per docs/migrations.md, so the migration test exercises the checked-out script rather than whatever release is installed. Skip when neither exists: omarchy-migrate runs under set -e, so a missing script would take down every migration queued behind it. Co-Authored-By: Claude Opus 5 (1M context) --- docs/file-layout.md | 3 ++- install/config/locate.sh | 10 +++++++--- migrations/1784809451.sh | 16 +++++++-------- test/shell.d/locate-test.sh | 40 ++++++++++++++++++++++++++++++++++--- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/docs/file-layout.md b/docs/file-layout.md index 18c4f0f3..9be59863 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -248,7 +248,8 @@ the legacy finalization marker from `~/.local/state/omarchy/` into `done/`. finalization. It sources: - `install/config/*.sh` — theme links, lockout limits, lockscreen PAM, - powerprofilesctl shebang fix, docker setup, service enablement, firewall. + powerprofilesctl shebang fix, docker setup, Snapper retention, locate + index tuning, service enablement, firewall. - `install/hardware/all.sh` via `omarchy-setup-hardware` — vendor- and device-specific kernel modules, udev rules, microcode, wireless regdom, ASUS / Framework / Intel / Apple / Lenovo quirks. diff --git a/install/config/locate.sh b/install/config/locate.sh index d532f5df..529fa1ab 100644 --- a/install/config/locate.sh +++ b/install/config/locate.sh @@ -14,9 +14,13 @@ fi # Snapper snapshots are nested subvolumes reached by plain directory # traversal, so without this updatedb indexes the system once per snapshot. -if ! grep -E '^PRUNEPATHS[[:space:]]*=' "$UPDATEDB_CONF_PATH" | grep -qF '/.snapshots'; then - if grep -qE '^PRUNEPATHS[[:space:]]*=[[:space:]]*"' "$UPDATEDB_CONF_PATH"; then - sed -i -E 's|^(PRUNEPATHS[[:space:]]*=[[:space:]]*")|\1/.snapshots |' "$UPDATEDB_CONF_PATH" +# The quotes are optional in updatedb.conf, so read the paths back out of +# whatever quoting the file uses and write the setting in one canonical form. +pruned=$(sed -nE 's|^PRUNEPATHS[[:space:]]*=[[:space:]]*"?([^"]*)"?[[:space:]]*$|\1|p' "$UPDATEDB_CONF_PATH" | tail -n 1) + +if [[ " $pruned " != *" /.snapshots "* ]]; then + if [[ -n $pruned ]]; then + sed -i -E "s|^PRUNEPATHS[[:space:]]*=.*|PRUNEPATHS = \"/.snapshots $pruned\"|" "$UPDATEDB_CONF_PATH" else printf '%s\n' 'PRUNEPATHS = "/.snapshots"' >>"$UPDATEDB_CONF_PATH" fi diff --git a/migrations/1784809451.sh b/migrations/1784809451.sh index 0cf9d1a0..fb59a794 100644 --- a/migrations/1784809451.sh +++ b/migrations/1784809451.sh @@ -1,11 +1,7 @@ echo "Configure locate to skip Btrfs snapshots and index Btrfs subvolumes" OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" -locate_config_script=/usr/share/omarchy/install/config/locate.sh -if [[ ! -f $locate_config_script ]]; then - locate_config_script="$OMARCHY_PATH/install/config/locate.sh" -fi - +locate_config_script="$OMARCHY_PATH/install/config/locate.sh" UPDATEDB_CONF_PATH="${OMARCHY_UPDATEDB_CONF_PATH:-/etc/updatedb.conf}" as_root() { @@ -17,14 +13,18 @@ as_root() { } [[ -f $UPDATEDB_CONF_PATH ]] || exit 0 +[[ -f $locate_config_script ]] || exit 0 if grep -q '^PRUNE_BIND_MOUNTS = "no"' "$UPDATEDB_CONF_PATH" && - grep -E '^PRUNEPATHS' "$UPDATEDB_CONF_PATH" | grep -qF '/.snapshots'; then + grep -E '^PRUNEPATHS' "$UPDATEDB_CONF_PATH" | grep -qE '(^|[[:space:]"])/\.snapshots([[:space:]"]|$)'; then exit 0 fi as_root env OMARCHY_UPDATEDB_CONF_PATH="$UPDATEDB_CONF_PATH" bash -euo pipefail "$locate_config_script" # Rebuild the index with the new exclusions; pruning /.snapshots turns -# multi-hour runs on snapshot-heavy systems back into one-minute runs. -as_root systemctl start --no-block plocate-updatedb.service >/dev/null 2>&1 || true +# multi-hour runs on snapshot-heavy systems back into one-minute runs. Restart +# rather than start: the machines this targets are the ones with an updatedb +# already grinding through every snapshot, and a run that started before the +# rewrite keeps using the config it read at startup. +as_root systemctl restart --no-block plocate-updatedb.service >/dev/null 2>&1 || true diff --git a/test/shell.d/locate-test.sh b/test/shell.d/locate-test.sh index 700b5bd2..77446103 100644 --- a/test/shell.d/locate-test.sh +++ b/test/shell.d/locate-test.sh @@ -36,7 +36,7 @@ OMARCHY_UPDATEDB_CONF_PATH="$test_tmp/missing.conf" bash -euo pipefail "$config_ pass "locate config tolerates a missing updatedb.conf" # A hand-edited updatedb.conf may drop the settings entirely, or write them -# without the spaces around the "=" that the stock Arch file uses. +# without the spaces around the "=" or the quotes that the stock Arch file uses. conf="$test_tmp/sparse-updatedb.conf" printf '%s\n' 'PRUNENAMES = ".git .hg .svn"' >"$conf" @@ -52,10 +52,28 @@ printf '%s\n' 'PRUNE_BIND_MOUNTS="yes"' 'PRUNEPATHS="/tmp /var/tmp"' >"$conf" OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config rewrites an unspaced PRUNE_BIND_MOUNTS" -grep -qFx 'PRUNEPATHS="/.snapshots /tmp /var/tmp"' "$conf" || fail "locate config prunes /.snapshots in an unspaced PRUNEPATHS" +grep -qFx 'PRUNEPATHS = "/.snapshots /tmp /var/tmp"' "$conf" || fail "locate config prunes /.snapshots in an unspaced PRUNEPATHS" [[ $(grep -c '^PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config keeps a single PRUNEPATHS setting" pass "locate config handles updatedb.conf written without spaces around =" +conf="$test_tmp/unquoted-updatedb.conf" +printf '%s\n' 'PRUNEPATHS = /tmp' >"$conf" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +grep -qFx 'PRUNEPATHS = "/.snapshots /tmp"' "$conf" || fail "locate config keeps the paths an unquoted PRUNEPATHS already prunes" +[[ $(grep -c '^PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config replaces an unquoted PRUNEPATHS instead of adding a second one" +pass "locate config handles updatedb.conf written without quotes" + +# A path that merely ends in /.snapshots is not the root snapshot directory. +conf="$test_tmp/nested-snapshots-updatedb.conf" +printf '%s\n' 'PRUNEPATHS = "/var/lib/machines/.snapshots"' >"$conf" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +grep -qFx 'PRUNEPATHS = "/.snapshots /var/lib/machines/.snapshots"' "$conf" || fail "locate config prunes /.snapshots alongside a path that ends in it" +pass "locate config tells /.snapshots apart from a path that ends in it" + locate_migration=$(grep -rl 'Configure locate to skip Btrfs snapshots' "$ROOT/migrations" | head -n 1 || true) [[ -n $locate_migration ]] || fail "locate migration exists" @@ -85,7 +103,7 @@ OMARCHY_UPDATEDB_CONF_PATH="$conf" \ grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate migration rewrites updatedb.conf" grep -qF 'PRUNEPATHS = "/.snapshots /afs' "$conf" || fail "locate migration prunes /.snapshots" -grep -qFx 'systemctl start --no-block plocate-updatedb.service' "$test_tmp/calls.log" || fail "locate migration rebuilds the locate index without blocking" +grep -qFx 'systemctl restart --no-block plocate-updatedb.service' "$test_tmp/calls.log" || fail "locate migration replaces an in-flight run and rebuilds the index without blocking" pass "locate migration fixes existing installs and rebuilds the index" : >"$test_tmp/calls.log" @@ -98,3 +116,19 @@ OMARCHY_UPDATEDB_CONF_PATH="$conf" \ [[ ! -s $test_tmp/calls.log ]] || fail "locate migration skips already-configured installs" pass "locate migration is a no-op once updatedb.conf is configured" + +# A dev checkout carries migrations from a release whose install scripts the +# checked-out tree may not have yet, and omarchy-migrate runs under set -e. +: >"$test_tmp/calls.log" +conf="$test_tmp/no-config-script-updatedb.conf" +stock_conf "$conf" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_PATH="$test_tmp/empty" \ +OMARCHY_UPDATEDB_CONF_PATH="$conf" \ + bash -euo pipefail "$locate_migration" >/dev/null || + fail "locate migration survives a tree without the locate config script" + +[[ ! -s $test_tmp/calls.log ]] || fail "locate migration touches nothing without the locate config script" +pass "locate migration is a no-op when the locate config script is missing" From 9b9d4b39eb1db8e8320a5a2cbbc1a4f5e87965ce Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:19:33 -0700 Subject: [PATCH 4/4] Never leave updatedb.conf with two definitions of the same setting updatedb refuses to run at all on a config that defines a variable twice ("variable `PRUNEPATHS' was already defined"), so any rewrite that misses an existing line and appends a second one takes the locate index down rather than fixing it. Two shapes updatedb accepts got missed: a trailing comment after the value, and a setting indented by whitespace. Read the existing paths out of the quoted value and write the whole setting back canonically instead of splicing into a line of unknown shape. Quotes are not optional to updatedb ("value in quotes expected after `='"), so a bare value is already a broken config: rewriting it quoted repairs the file as a side effect. The tests now hand every rewritten file to the real parser through `updatedb --config-file`, which is what caught this. Read the Snapper config as root when the running user cannot read it. snapper create-config leaves the config root-only, and a config the user cannot read was passing for one that wants its timeline snapshots kept. Report the snapshots the drain could not delete. omarchy-migrate writes the completion marker whether or not the batches succeeded, so there is no later run to pick up the remainder, whatever the comment claimed. Co-Authored-By: Claude Opus 5 (1M context) --- install/config/locate.sh | 25 +++++++------ migrations/1784809452.sh | 23 +++++++++--- test/shell.d/locate-test.sh | 41 ++++++++++++++++++++-- test/shell.d/snapper-timeline-leak-test.sh | 30 +++++++++++++--- 4 files changed, 97 insertions(+), 22 deletions(-) diff --git a/install/config/locate.sh b/install/config/locate.sh index 529fa1ab..cc95e9b8 100644 --- a/install/config/locate.sh +++ b/install/config/locate.sh @@ -4,24 +4,29 @@ echo "Configuring locate to skip Btrfs snapshots and index Btrfs subvolumes" [[ -f $UPDATEDB_CONF_PATH ]] || exit 0 +# updatedb refuses to run at all on a config that defines a variable twice, so +# every setting here is rewritten where it already stands and only appended +# when the file has no line for it. + # Btrfs subvolume mounts (like /home) look like bind mounts, so pruning # bind mounts leaves them out of the index entirely. -if grep -qE '^PRUNE_BIND_MOUNTS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then - sed -i -E 's|^PRUNE_BIND_MOUNTS[[:space:]]*=.*|PRUNE_BIND_MOUNTS = "no"|' "$UPDATEDB_CONF_PATH" +if grep -qE '^[[:space:]]*PRUNE_BIND_MOUNTS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then + sed -i -E 's|^[[:space:]]*PRUNE_BIND_MOUNTS[[:space:]]*=.*|PRUNE_BIND_MOUNTS = "no"|' "$UPDATEDB_CONF_PATH" else printf '%s\n' 'PRUNE_BIND_MOUNTS = "no"' >>"$UPDATEDB_CONF_PATH" fi # Snapper snapshots are nested subvolumes reached by plain directory # traversal, so without this updatedb indexes the system once per snapshot. -# The quotes are optional in updatedb.conf, so read the paths back out of -# whatever quoting the file uses and write the setting in one canonical form. -pruned=$(sed -nE 's|^PRUNEPATHS[[:space:]]*=[[:space:]]*"?([^"]*)"?[[:space:]]*$|\1|p' "$UPDATEDB_CONF_PATH" | tail -n 1) +if grep -qE '^[[:space:]]*PRUNEPATHS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then + # updatedb only accepts quoted values and allows a comment after them. Read + # back what the machine already prunes and write the whole setting out again + # rather than splicing into a line of unknown shape. + pruned=$(sed -nE 's|^[[:space:]]*PRUNEPATHS[[:space:]]*=[[:space:]]*"([^"]*)".*|\1|p' "$UPDATEDB_CONF_PATH" | tail -n 1) -if [[ " $pruned " != *" /.snapshots "* ]]; then - if [[ -n $pruned ]]; then - sed -i -E "s|^PRUNEPATHS[[:space:]]*=.*|PRUNEPATHS = \"/.snapshots $pruned\"|" "$UPDATEDB_CONF_PATH" - else - printf '%s\n' 'PRUNEPATHS = "/.snapshots"' >>"$UPDATEDB_CONF_PATH" + if [[ " $pruned " != *" /.snapshots "* ]]; then + sed -i -E "s|^[[:space:]]*PRUNEPATHS[[:space:]]*=.*|PRUNEPATHS = \"/.snapshots${pruned:+ $pruned}\"|" "$UPDATEDB_CONF_PATH" fi +else + printf '%s\n' 'PRUNEPATHS = "/.snapshots"' >>"$UPDATEDB_CONF_PATH" fi diff --git a/migrations/1784809452.sh b/migrations/1784809452.sh index e32da36b..d70d7659 100644 --- a/migrations/1784809452.sh +++ b/migrations/1784809452.sh @@ -14,8 +14,14 @@ command -v snapper >/dev/null || exit 0 [[ -f $SNAPPER_CONFIG_PATH ]] || exit 0 # Only clean up when timeline snapshotting is off, as Omarchy configures it. -# Anyone who deliberately turned it back on keeps their snapshots. -grep -qFx 'TIMELINE_CREATE="no"' "$SNAPPER_CONFIG_PATH" || exit 0 +# Anyone who deliberately turned it back on keeps their snapshots. Snapper's +# own create-config leaves the file readable by root alone, and a config this +# user cannot read must not pass for one that wants its snapshots kept. +if [[ -r $SNAPPER_CONFIG_PATH ]]; then + grep -qFx 'TIMELINE_CREATE="no"' "$SNAPPER_CONFIG_PATH" || exit 0 +else + as_root grep -qFx 'TIMELINE_CREATE="no"' "$SNAPPER_CONFIG_PATH" || exit 0 +fi # Earlier installs ran hourly timeline snapshots. Later configs stopped # creating them but never deleted the existing ones, and number cleanup @@ -28,16 +34,23 @@ echo "Deleting $(wc -w <<<"$leaked") leaked timeline snapshots (disk space is re # Delete in small batches; one big delete can die on a DBus timeout partway. # A failed batch must not take the rest of the migration run down with it, so -# the drain is best effort: whatever survives is picked up by the next run. +# the drain is best effort. omarchy-migrate records the migration either way, +# so say what is left rather than counting on a rerun that will not come. +failed=0 batch=() + for number in $leaked; do batch+=("$number") if (( ${#batch[@]} == 20 )); then - as_root snapper -c root delete "${batch[@]}" || true + as_root snapper -c root delete "${batch[@]}" || failed=$((failed + ${#batch[@]})) batch=() fi done if (( ${#batch[@]} > 0 )); then - as_root snapper -c root delete "${batch[@]}" || true + as_root snapper -c root delete "${batch[@]}" || failed=$((failed + ${#batch[@]})) +fi + +if (( failed > 0 )); then + echo "$failed snapshots could not be deleted. Finish with: sudo snapper -c root delete " fi diff --git a/test/shell.d/locate-test.sh b/test/shell.d/locate-test.sh index 77446103..cab54c24 100644 --- a/test/shell.d/locate-test.sh +++ b/test/shell.d/locate-test.sh @@ -18,6 +18,19 @@ PRUNEPATHS = "/afs /media /mnt /net /sfs /tmp /udev /var/cache /var/lib/pacman/l CONF } +# updatedb dies on a config that defines a variable twice, so hand every +# rewritten file to the real parser rather than trusting the greps below. +empty_tree="$test_tmp/empty-tree" +mkdir -p "$empty_tree" + +assert_conf_parses() { + command -v updatedb >/dev/null || return 0 + + local errors + errors=$(updatedb --config-file "$1" -U "$empty_tree" -o "$test_tmp/plocate.db" 2>&1 >/dev/null | grep -F "$1:" || true) + [[ -z $errors ]] || fail "updatedb accepts the rewritten config" "$errors" +} + conf="$test_tmp/updatedb.conf" stock_conf "$conf" @@ -25,11 +38,13 @@ OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/nul grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config indexes Btrfs subvolume mounts like /home" grep -qF 'PRUNEPATHS = "/.snapshots /afs' "$conf" || fail "locate config prunes /.snapshots" +assert_conf_parses "$conf" pass "locate config skips Btrfs snapshots and indexes Btrfs subvolumes" OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null [[ $(grep -o '/\.snapshots' "$conf" | wc -l) -eq 1 ]] || fail "locate config is idempotent" +assert_conf_parses "$conf" pass "locate config leaves an already-configured file alone" OMARCHY_UPDATEDB_CONF_PATH="$test_tmp/missing.conf" bash -euo pipefail "$config_script" >/dev/null @@ -44,6 +59,7 @@ OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/nul grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config adds a missing PRUNE_BIND_MOUNTS" grep -qFx 'PRUNEPATHS = "/.snapshots"' "$conf" || fail "locate config adds a missing PRUNEPATHS" +assert_conf_parses "$conf" pass "locate config adds settings a hand-edited updatedb.conf is missing" conf="$test_tmp/unspaced-updatedb.conf" @@ -53,16 +69,34 @@ OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/nul grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config rewrites an unspaced PRUNE_BIND_MOUNTS" grep -qFx 'PRUNEPATHS = "/.snapshots /tmp /var/tmp"' "$conf" || fail "locate config prunes /.snapshots in an unspaced PRUNEPATHS" -[[ $(grep -c '^PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config keeps a single PRUNEPATHS setting" +[[ $(grep -c 'PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config keeps a single PRUNEPATHS setting" +assert_conf_parses "$conf" pass "locate config handles updatedb.conf written without spaces around =" +# updatedb allows a comment after a value and indented settings, and defining +# either setting twice makes it refuse to run at all. +conf="$test_tmp/commented-updatedb.conf" +printf '%s\n' ' PRUNE_BIND_MOUNTS = "yes" # subvolumes look like bind mounts' \ + 'PRUNEPATHS = "/tmp" # scratch' >"$conf" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config rewrites an indented PRUNE_BIND_MOUNTS" +grep -qFx 'PRUNEPATHS = "/.snapshots /tmp"' "$conf" || fail "locate config keeps the paths a commented PRUNEPATHS already prunes" +[[ $(grep -c 'PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config replaces a commented PRUNEPATHS instead of adding a second one" +assert_conf_parses "$conf" +pass "locate config handles indented settings and trailing comments" + +# A hand-edited file may have dropped the quotes updatedb requires, which +# leaves it unparseable until something writes the setting out properly. conf="$test_tmp/unquoted-updatedb.conf" printf '%s\n' 'PRUNEPATHS = /tmp' >"$conf" OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null -grep -qFx 'PRUNEPATHS = "/.snapshots /tmp"' "$conf" || fail "locate config keeps the paths an unquoted PRUNEPATHS already prunes" -[[ $(grep -c '^PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config replaces an unquoted PRUNEPATHS instead of adding a second one" +grep -qFx 'PRUNEPATHS = "/.snapshots"' "$conf" || fail "locate config repairs an unquoted PRUNEPATHS" +[[ $(grep -c 'PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config replaces an unquoted PRUNEPATHS instead of adding a second one" +assert_conf_parses "$conf" pass "locate config handles updatedb.conf written without quotes" # A path that merely ends in /.snapshots is not the root snapshot directory. @@ -72,6 +106,7 @@ printf '%s\n' 'PRUNEPATHS = "/var/lib/machines/.snapshots"' >"$conf" OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null grep -qFx 'PRUNEPATHS = "/.snapshots /var/lib/machines/.snapshots"' "$conf" || fail "locate config prunes /.snapshots alongside a path that ends in it" +assert_conf_parses "$conf" pass "locate config tells /.snapshots apart from a path that ends in it" locate_migration=$(grep -rl 'Configure locate to skip Btrfs snapshots' "$ROOT/migrations" | head -n 1 || true) diff --git a/test/shell.d/snapper-timeline-leak-test.sh b/test/shell.d/snapper-timeline-leak-test.sh index a5adb84f..21b15ac7 100644 --- a/test/shell.d/snapper-timeline-leak-test.sh +++ b/test/shell.d/snapper-timeline-leak-test.sh @@ -15,6 +15,7 @@ mkdir -p "$fake_bin" cat >"$fake_bin/sudo" <<'STUB' #!/bin/bash +printf 'sudo %s\n' "$*" >>"$TEST_LOG" exec "$@" STUB chmod +x "$fake_bin/sudo" @@ -72,14 +73,18 @@ echo "failure: dbus timeout" >&2 exit 1 STUB -TEST_LOG="$test_tmp/calls.log" \ -PATH="$fake_bin:$PATH" \ -OMARCHY_SNAPPER_CONFIG_PATH="$snapper_config" \ - bash -euo pipefail "$leak_migration" >/dev/null 2>&1 || +output=$(TEST_LOG="$test_tmp/calls.log" \ + PATH="$fake_bin:$PATH" \ + OMARCHY_SNAPPER_CONFIG_PATH="$snapper_config" \ + bash -euo pipefail "$leak_migration" 2>/dev/null) || fail "leak migration survives a failed delete batch" deletes=$(grep -c '^snapper -c root delete ' "$test_tmp/calls.log" || true) [[ $deletes -eq 3 ]] || fail "leak migration keeps draining after a failed batch" "expected 3 delete calls, got $deletes" + +# omarchy-migrate writes the completion marker even when the drain gave up, so +# what is left has to be said out loud rather than left for a rerun. +grep -qF '45 snapshots could not be deleted' <<<"$output" || fail "leak migration reports the snapshots it could not delete" "$output" pass "leak migration tolerates a batch that fails partway" : >"$test_tmp/calls.log" @@ -102,3 +107,20 @@ OMARCHY_SNAPPER_CONFIG_PATH="$test_tmp/missing" \ [[ ! -s $test_tmp/calls.log ]] || fail "leak migration skips systems without a Snapper root config" pass "leak migration is a no-op without Snapper configured" + +# Snapper's create-config writes a root-only config, and a config this user +# cannot read says nothing about whether timeline snapshots are wanted. +: >"$test_tmp/calls.log" +printf '%s\n' 'TIMELINE_CREATE="no"' >"$snapper_config" +chmod 000 "$snapper_config" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_SNAPPER_CONFIG_PATH="$snapper_config" \ + bash -euo pipefail "$leak_migration" >/dev/null 2>&1 + +chmod 600 "$snapper_config" + +grep -qF "sudo grep -qFx TIMELINE_CREATE=\"no\" $snapper_config" "$test_tmp/calls.log" || + fail "leak migration reads a root-only Snapper config as root" "$(cat "$test_tmp/calls.log")" +pass "leak migration does not mistake an unreadable Snapper config for an intentional one"