aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-09-13 10:59:31 -0500
committerCraig Jennings <c@cjennings.net>2026-09-13 10:59:31 -0500
commit6e99a7fbae089009e7948cb3853b68364f2f527b (patch)
tree4c4165815ba363824a98485e4a45f16a97e10ffc
parent3bab9f1ff287c61e85504ad5ab0c5a0d69bf0a61 (diff)
downloadarchangel-6e99a7fbae089009e7948cb3853b68364f2f527b.tar.gz
archangel-6e99a7fbae089009e7948cb3853b68364f2f527b.zip
fix(install): run the failure cleanup when an install step fails
Every install step runs inside a function, and bash never delivers an ERR trap to a command failing inside a called function. With `set -e` the installer exited without `install_failure_cleanup` ever running. That left /mnt mounted and the pool imported, so a re-run or the test harness's retry died at "Disk in use". The `|| error` shape had the same problem, because `error()` calls a plain `exit 1` that no ERR trap sees. The failure trap now also fires on EXIT, and `arm_failure_trap` and `disarm_failure_trap` set and clear it in one place. I left errtrace off on purpose. It hands the trap to command substitutions, so a masked failing `$(...)` would run the cleanup in a subshell and unmount /mnt while the install carried on. Once the cleanup runs, three more things could stop it from finishing. A SIGTERM to the whole process group also kills the logging tee, and the cleanup then died on SIGPIPE at its first message. It now ignores SIGPIPE and turns errexit off for itself with `local -`. It also ignores INT and TERM, so a second Ctrl-C can't cut it short. The pool export could race pacman's still-exiting chroot processes and fail as busy, so the export and the Btrfs LUKS close now retry for up to 15 seconds. The Btrfs branch also gets the recursive unmount with a lazy fallback that the ZFS branch already had.
-rwxr-xr-xinstaller/archangel103
-rw-r--r--installer/lib/btrfs.sh11
-rw-r--r--tests/unit/test_archangel.bats398
-rw-r--r--tests/unit/test_btrfs.bats34
4 files changed, 532 insertions, 14 deletions
diff --git a/installer/archangel b/installer/archangel
index 065772c..56290d9 100755
--- a/installer/archangel
+++ b/installer/archangel
@@ -1374,16 +1374,87 @@ cleanup() {
info "Cleanup complete."
}
-# Trap target for ERR / INT / TERM during install_zfs and
+# The trap set that routes a mid-install failure to install_failure_cleanup.
+# EXIT is the one that does the work. Every install step is a function, and
+# bash never delivers an ERR trap to a command failing inside a called
+# function, so errexit used to end the script with the cleanup unrun. The
+# `|| error "..."` shape leaves through a plain `exit 1`, which no ERR trap
+# sees either. Both left /mnt mounted and the pool imported for the next
+# attempt to trip over (2026-08-06, 2026-09-12). ERR still fires for a
+# failure directly in install_zfs/install_btrfs, and INT and TERM cover
+# Ctrl-C and a kill. The success path disarms all four before its own
+# cleanup, whose non-zero exits are expected.
+#
+# I deliberately don't turn on errtrace (set -E) to widen ERR. It hands the
+# trap to command substitutions too, so a masked failing `$(...)` ran the
+# whole cleanup inside that subshell and unmounted /mnt while the install
+# carried on in the parent.
+arm_failure_trap() {
+ trap 'install_failure_cleanup' ERR INT TERM EXIT
+}
+
+# How many one-second tries the failure cleanup gives a busy pool export or
+# LUKS close. When a signal interrupts pacstrap, pacman's children in the
+# chroot can still be exiting as the cleanup runs, so the first export fails
+# busy and a manual one seconds later succeeds (VM, 2026-09-13).
+CLEANUP_BUSY_ATTEMPTS="${CLEANUP_BUSY_ATTEMPTS:-15}"
+
+# Run a command until it succeeds, up to <attempts> times, sleeping one
+# second between tries. Returns 1 if the last try still fails.
+#
+# Usage: retry_busy <attempts> <command> [args...]
+retry_busy() {
+ local attempts="$1" i
+ shift
+ for ((i = 1; i <= attempts; i++)); do
+ "$@" && return 0
+ ((i < attempts)) && sleep 1
+ done
+ return 1
+}
+
+# Close this install's LUKS mappings and succeed only once none is left.
+# close_luks_container swallows cryptsetup's errors, so a busy close has to
+# be detected by looking for the mapping afterwards.
+close_luks_and_confirm() {
+ btrfs_close_encryption 2>/dev/null
+ ! luks_mappings_open
+}
+
+disarm_failure_trap() {
+ trap - ERR INT TERM EXIT
+}
+
+# Trap target for ERR / INT / TERM / EXIT during install_zfs and
# install_btrfs. Captures the failing exit code first, disarms the
-# trap to prevent recursion, clears sensitive variables, and
-# dispatches to the right per-filesystem cleanup before exiting via
-# error(). All cleanup steps swallow their own errors — partial
-# state is expected when this fires mid-install, so individual tool
-# failures are not fatal.
+# trap to prevent recursion (its own error() exit would re-fire EXIT),
+# clears sensitive variables, and dispatches to the right
+# per-filesystem cleanup before exiting via error(). All cleanup
+# steps swallow their own errors — partial state is expected when
+# this fires mid-install, so individual tool failures are not fatal.
install_failure_cleanup() {
local exit_code=$?
- trap - ERR INT TERM
+ disarm_failure_trap
+
+ # A SIGTERM sent to the whole process group (kill -- -PGID, timeout, a
+ # service stop) also kills the tee init_logging put on stdout. With the
+ # tee gone, the first warn below raised SIGPIPE and killed the shell
+ # before anything was unmounted. Ctrl-C doesn't hit this, because bash
+ # starts a process substitution with SIGINT ignored. Ignore SIGPIPE, and
+ # turn errexit off so a failed write to the dead pipe can't end the
+ # cleanup either. `local -` scopes that to this function: the cleanup
+ # normally leaves through error(), but a caller that gets control back
+ # (the unit tests) would otherwise keep running with errexit off.
+ local -
+ trap '' PIPE
+ set +e
+
+ # disarm_failure_trap put INT and TERM back to their defaults, so a
+ # second Ctrl-C or SIGTERM during the busy retries below (several silent
+ # seconds, easy to read as a hang) killed the cleanup halfway and left
+ # the pool imported. Ignore both until the cleanup exits. It's bounded
+ # by CLEANUP_BUSY_ATTEMPTS, and SIGKILL still stops it.
+ trap '' INT TERM
ROOT_PASSWORD=""
ZFS_PASSPHRASE=""
@@ -1402,7 +1473,7 @@ install_failure_cleanup() {
umount "$EFI_DIR" 2>/dev/null || umount -l "$EFI_DIR" 2>/dev/null || true
umount -R "$MNTPOINT" 2>/dev/null || umount -R -l "$MNTPOINT" 2>/dev/null || true
if zpool list "$POOL_NAME" >/dev/null 2>&1; then
- zpool export "$POOL_NAME" 2>/dev/null \
+ retry_busy "$CLEANUP_BUSY_ATTEMPTS" zpool export "$POOL_NAME" 2>/dev/null \
|| zpool export -f "$POOL_NAME" 2>/dev/null \
|| true
fi
@@ -1410,7 +1481,13 @@ install_failure_cleanup() {
btrfs)
umount "$EFI_DIR" 2>/dev/null || umount -l "$EFI_DIR" 2>/dev/null || true
btrfs_cleanup 2>/dev/null || true
- btrfs_close_encryption 2>/dev/null || true
+ # btrfs_cleanup only unmounts the subvolumes it mounted. A
+ # pacstrap interrupted mid-transaction can leave its /proc, /sys
+ # and /dev bind mounts under the root, which keep it busy, so the
+ # LUKS mapping can't close. Same recursive-then-lazy fallback as
+ # the ZFS branch, before closing encryption.
+ umount -R "$MNTPOINT" 2>/dev/null || umount -R -l "$MNTPOINT" 2>/dev/null || true
+ retry_busy "$CLEANUP_BUSY_ATTEMPTS" close_luks_and_confirm || true
;;
esac
@@ -1531,7 +1608,7 @@ install_zfs() {
# cleanup (unmount /mnt + export pool) so the live ISO is left in a
# state where the user can re-run the installer without manual
# intervention.
- trap 'install_failure_cleanup' ERR INT TERM
+ arm_failure_trap
partition_disks
create_zfs_pool
@@ -1553,7 +1630,7 @@ install_zfs() {
# Disarm the failure trap before the success-path cleanup. The
# success cleanup may emit non-zero exit codes that we don't want
# to interpret as "installation failed".
- trap - ERR INT TERM
+ disarm_failure_trap
cleanup
print_summary
}
@@ -1565,7 +1642,7 @@ install_zfs() {
install_btrfs() {
# Arm the failure trap before any destructive operation. See the
# matching block in install_zfs() for the rationale.
- trap 'install_failure_cleanup' ERR INT TERM
+ arm_failure_trap
local btrfs_devices=()
@@ -1605,7 +1682,7 @@ install_btrfs() {
# Disarm the failure trap before the success-path cleanup. See
# the matching block in install_zfs() for the rationale.
- trap - ERR INT TERM
+ disarm_failure_trap
# Cleanup
btrfs_cleanup
diff --git a/installer/lib/btrfs.sh b/installer/lib/btrfs.sh
index 67c96a0..96ad29e 100644
--- a/installer/lib/btrfs.sh
+++ b/installer/lib/btrfs.sh
@@ -153,6 +153,17 @@ close_luks_containers() {
done
}
+# Succeed when any of this install's LUKS mappings is still open. Names
+# follow get_luks_devices: the bare LUKS_MAPPER_NAME for the first disk,
+# then LUKS_MAPPER_NAME1, 2, ... MAPPER_DIR is overridable for tests.
+luks_mappings_open() {
+ local dir="${MAPPER_DIR:-/dev/mapper}" m
+ for m in "$dir/$LUKS_MAPPER_NAME" "$dir/$LUKS_MAPPER_NAME"[0-9]*; do
+ [[ -e "$m" ]] && return 0
+ done
+ return 1
+}
+
# Get list of opened LUKS mapper devices
get_luks_devices() {
local count="$1"
diff --git a/tests/unit/test_archangel.bats b/tests/unit/test_archangel.bats
index 983bfd2..508a062 100644
--- a/tests/unit/test_archangel.bats
+++ b/tests/unit/test_archangel.bats
@@ -20,6 +20,16 @@ setup() {
# shellcheck disable=SC1091
source "${BATS_TEST_DIRNAME}/../../installer/archangel"
UNATTENDED=true
+ # Tests that call install_failure_cleanup in this process would
+ # otherwise run the real disarm_failure_trap, which clears bats' own
+ # EXIT trap, and bats reports a failing assertion from that trap: the
+ # failure would vanish instead of printing "not ok". The trap arming
+ # itself is tested in a child bash, which sources the real functions.
+ disarm_failure_trap() { :; }
+ # Point the LUKS-mapping check at an empty directory, so a machine with
+ # a real /dev/mapper/cryptroot can't leak into the cleanup tests.
+ MAPPER_DIR="$BATS_TEST_TMPDIR/mapper"
+ mkdir -p "$MAPPER_DIR"
}
#############################
@@ -136,9 +146,212 @@ setup() {
}
#############################
+# Failure trap arming
+#############################
+# arm_failure_trap / disarm_failure_trap own the trap set that routes a
+# mid-install failure to install_failure_cleanup. These run in a child
+# bash (via `run bash -c`) because trap inheritance is a property of
+# the live shell. Before the EXIT trap, a failure inside any install step
+# ended the script through errexit without the cleanup running, and an
+# `exit 1` from error() reached no ERR trap at all. Both were how the
+# 2026-08-06 and 2026-09-12 retries found a still-mounted disk.
+
+@test "arm_failure_trap fires the cleanup for a command failing inside a function" {
+ run bash -c 'source "$1"
+ install_failure_cleanup() { disarm_failure_trap; echo CLEANUP-RAN; exit 7; }
+ arm_failure_trap
+ f() { false; }
+ f
+ echo NOT-REACHED' _ "${BATS_TEST_DIRNAME}/../../installer/archangel"
+ [ "$status" -eq 7 ]
+ [[ "$output" == *"CLEANUP-RAN"* ]]
+ [[ "$output" != *"NOT-REACHED"* ]]
+}
+
+@test "arm_failure_trap fires the cleanup when error() exits from inside a function" {
+ run bash -c 'source "$1"
+ install_failure_cleanup() { disarm_failure_trap; echo CLEANUP-RAN; exit 7; }
+ arm_failure_trap
+ f() { false || error "pacstrap failed"; }
+ f
+ echo NOT-REACHED' _ "${BATS_TEST_DIRNAME}/../../installer/archangel"
+ [ "$status" -eq 7 ]
+ [[ "$output" == *"CLEANUP-RAN"* ]]
+ [[ "$output" != *"NOT-REACHED"* ]]
+}
+
+@test "arm_failure_trap fires the cleanup for a failing pipeline inside a function" {
+ run bash -c 'source "$1"
+ install_failure_cleanup() { disarm_failure_trap; echo CLEANUP-RAN; exit 7; }
+ arm_failure_trap
+ f() { printf "\n" | false; }
+ f
+ echo NOT-REACHED' _ "${BATS_TEST_DIRNAME}/../../installer/archangel"
+ [ "$status" -eq 7 ]
+ [[ "$output" == *"CLEANUP-RAN"* ]]
+ [[ "$output" != *"NOT-REACHED"* ]]
+}
+
+@test "disarm_failure_trap lets the success path exit without running the cleanup" {
+ run bash -c 'source "$1"
+ install_failure_cleanup() { disarm_failure_trap; echo CLEANUP-RAN; exit 7; }
+ arm_failure_trap
+ f() { true; }
+ f
+ disarm_failure_trap
+ echo SUCCESS' _ "${BATS_TEST_DIRNAME}/../../installer/archangel"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"SUCCESS"* ]]
+ [[ "$output" != *"CLEANUP-RAN"* ]]
+}
+
+@test "arm_failure_trap fires the cleanup when the install is sent TERM" {
+ run bash -c 'source "$1"
+ install_failure_cleanup() { disarm_failure_trap; echo CLEANUP-RAN; exit 7; }
+ arm_failure_trap
+ f() { kill -TERM $$; sleep 2; echo NOT-REACHED; }
+ f' _ "${BATS_TEST_DIRNAME}/../../installer/archangel"
+ [ "$status" -eq 7 ]
+ [[ "$output" == *"CLEANUP-RAN"* ]]
+ [[ "$output" != *"NOT-REACHED"* ]]
+}
+
+# A failing command substitution whose status is masked (an argument to
+# echo, a `local` declaration, a `|| true`) is not an install failure. With
+# errtrace on, bash ran the whole cleanup inside the substitution's
+# subshell, unmounting /mnt while the install carried on in the parent.
+@test "a masked failing command substitution does not run the cleanup" {
+ local marker="$BATS_TEST_TMPDIR/cleanup-ran"
+ run bash -c 'source "$1"; M="$2"
+ install_failure_cleanup() { disarm_failure_trap; echo ran >> "$M"; exit 7; }
+ arm_failure_trap
+ f() { echo "$(false)" >/dev/null; local y; y=$(false) || true; echo STEP-DONE; }
+ f
+ disarm_failure_trap
+ echo SUCCESS' _ "${BATS_TEST_DIRNAME}/../../installer/archangel" "$marker"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"STEP-DONE"* ]]
+ [[ "$output" == *"SUCCESS"* ]]
+ [ ! -e "$marker" ]
+}
+
+# A SIGTERM to the whole process group also reaches the tee init_logging
+# puts on stdout. With the tee gone, the cleanup's first warn raised
+# SIGPIPE and killed the shell before anything was unmounted: a Btrfs
+# install TERM'd mid-pacstrap in the VM on 2026-09-13 was left with /mnt
+# and the LUKS mapping still open. Ctrl-C's SIGINT leaves the tee alive
+# (bash starts a process substitution with SIGINT ignored), so the INT
+# test guards the ordinary Ctrl-C path rather than the SIGPIPE fix.
+#
+# The child starts through `env --default-signal=INT` because bats runs it
+# in the background, and a background job in a non-interactive shell
+# begins with SIGINT ignored, which bash then refuses to trap. Without the
+# reset, the INT case would pass by never delivering the signal at all.
+signal_group_with_logging_tee() {
+ local sig="$1" d="$2"
+ env --default-signal=INT setsid bash -c 'source "$1"; D="$2"
+ FILESYSTEM=zfs; POOL_NAME=zroot
+ umount() { echo "umount $*" >> "$D/calls"; }
+ zpool() { echo "zpool $*" >> "$D/calls"; [[ "$1" == list ]]; }
+ exec > >(tee -a "$D/tee.log") 2>&1
+ arm_failure_trap
+ grep SigIgn /proc/$$/status > "$D/sigign"
+ echo $$ > "$D/armed"
+ sleep 30' _ "${BATS_TEST_DIRNAME}/../../installer/archangel" "$d" </dev/null >/dev/null 2>&1 &
+
+ local i pid=""
+ for i in $(seq 1 100); do
+ [ -s "$d/armed" ] && pid=$(cat "$d/armed") && break
+ sleep 0.1
+ done
+ [ -n "$pid" ] || return 1
+ kill "-$sig" -- "-$pid"
+ for i in $(seq 1 100); do
+ kill -0 "$pid" 2>/dev/null || break
+ sleep 0.1
+ done
+ kill -0 "$pid" 2>/dev/null && kill -KILL -- "-$pid"
+ return 0
+}
+
+@test "install_failure_cleanup finishes when SIGTERM to the group also killed its logging tee" {
+ local d="$BATS_TEST_TMPDIR"
+ signal_group_with_logging_tee TERM "$d"
+ grep -qx "zpool export zroot" "$d/calls"
+}
+
+@test "install_failure_cleanup finishes when Ctrl-C's SIGINT reaches the whole group" {
+ local d="$BATS_TEST_TMPDIR"
+ signal_group_with_logging_tee INT "$d"
+ # Guard against the vacuous pass: SIGINT (mask bit 0x2) must not be
+ # ignored in the child, or the signal never arrived.
+ local mask
+ mask=$(awk '{print $2}' "$d/sigign")
+ [ $(( 0x$mask & 0x2 )) -eq 0 ]
+ grep -qx "zpool export zroot" "$d/calls"
+}
+
+# The busy retries can keep the cleanup silent for several seconds, and a
+# second Ctrl-C or SIGTERM in that window used to kill it halfway: the
+# export never finished and the pool stayed imported.
+@test "a second SIGTERM during the busy-retry window doesn't stop the cleanup" {
+ local d="$BATS_TEST_TMPDIR"
+ env --default-signal=INT setsid bash -c 'source "$1"; D="$2"
+ FILESYSTEM=zfs; POOL_NAME=zroot; CLEANUP_BUSY_ATTEMPTS=6
+ umount() { :; }
+ zpool() {
+ [[ "$1" == list ]] && return 0
+ if [[ "$*" == "export zroot" ]]; then
+ echo try >> "$D/exports"
+ [ "$(wc -l < "$D/exports")" -ge 4 ] && { echo exported >> "$D/done"; return 0; }
+ return 1
+ fi
+ [[ "$*" == "export -f zroot" ]] && echo forced >> "$D/done"
+ return 0
+ }
+ arm_failure_trap
+ echo $$ > "$D/armed"
+ sleep 30' _ "${BATS_TEST_DIRNAME}/../../installer/archangel" "$d" </dev/null >/dev/null 2>&1 &
+
+ local i pid=""
+ for i in $(seq 1 100); do
+ [ -s "$d/armed" ] && pid=$(cat "$d/armed") && break
+ sleep 0.1
+ done
+ [ -n "$pid" ]
+ kill -TERM -- "-$pid"
+ for i in $(seq 1 50); do
+ [ -s "$d/exports" ] && break
+ sleep 0.1
+ done
+ [ -s "$d/exports" ]
+ kill -TERM -- "-$pid" 2>/dev/null || true
+ for i in $(seq 1 100); do
+ kill -0 "$pid" 2>/dev/null || break
+ sleep 0.1
+ done
+ kill -0 "$pid" 2>/dev/null && kill -KILL -- "-$pid"
+
+ [ "$(cat "$d/done" 2>/dev/null)" = "exported" ]
+}
+
+@test "install_failure_cleanup runs exactly once when its own exit re-fires the trap" {
+ run bash -c 'source "$1"
+ FILESYSTEM=zfs; POOL_NAME=zroot
+ umount() { :; }
+ zpool() { return 1; }
+ arm_failure_trap
+ f() { false; }
+ f' _ "${BATS_TEST_DIRNAME}/../../installer/archangel"
+ [ "$status" -eq 1 ]
+ [ "$(grep -c 'cleaning up' <<<"$output")" -eq 1 ]
+ [[ "$output" == *"system cleaned up"* ]]
+}
+
+#############################
# install_failure_cleanup
#############################
-# install_failure_cleanup is the trap target for ERR / INT / TERM
+# install_failure_cleanup is the trap target for ERR / INT / TERM / EXIT
# during install_zfs and install_btrfs. It clears sensitive vars,
# dispatches on FILESYSTEM, and exits non-zero. Tests use function
# overrides to capture which system tools the cleanup invokes; the
@@ -146,6 +359,143 @@ setup() {
# btrfs_close_encryption) are deliberately VM-tested per
# testing-strategy.org.
+#############################
+# retry_busy
+#############################
+# Teardown after an interrupted pacstrap races pacman's children in the
+# chroot, which can still be exiting: the first export or LUKS close fails
+# busy and succeeds a moment later. retry_busy gives it a bounded window.
+
+@test "retry_busy runs a command that succeeds once, without sleeping" {
+ CALLS=()
+ sleep() { CALLS+=("sleep $*"); }
+ ok() { CALLS+=("ok"); return 0; }
+ retry_busy 5 ok
+ [ "${#CALLS[@]}" -eq 1 ]
+ [ "${CALLS[0]}" = "ok" ]
+}
+
+@test "retry_busy succeeds when the command first succeeds on the last attempt" {
+ CALLS=(); TRIES=0
+ sleep() { CALLS+=("sleep $*"); }
+ flaky() { TRIES=$((TRIES + 1)); [ "$TRIES" -ge 3 ]; }
+ retry_busy 3 flaky
+ [ "$TRIES" -eq 3 ]
+ [ "${#CALLS[@]}" -eq 2 ]
+}
+
+@test "retry_busy returns 1 after the last attempt, sleeping only between attempts" {
+ CALLS=(); TRIES=0
+ sleep() { CALLS+=("sleep $*"); }
+ never() { TRIES=$((TRIES + 1)); return 1; }
+ run retry_busy 4 never
+ [ "$status" -eq 1 ]
+ retry_busy 4 never || true
+ [ "$TRIES" -eq 4 ]
+ [ "${#CALLS[@]}" -eq 3 ]
+}
+
+# bats fails a test through errexit. The cleanup turns errexit off for
+# itself, and before `local -` scoped that, every assertion after a direct
+# call except the last one stopped being able to fail.
+@test "install_failure_cleanup leaves the caller's errexit on" {
+ FILESYSTEM=zfs
+ POOL_NAME=zroot
+ umount() { :; }
+ zpool() { return 1; }
+ warn() { :; }
+ error() { return 1; }
+
+ install_failure_cleanup || true
+
+ # Capture the flags, then restore errexit before asserting. If the
+ # cleanup leaked errexit-off, a bare assertion here couldn't fail.
+ local flags="$-"
+ set -e
+ [[ "$flags" == *e* ]]
+}
+
+@test "install_failure_cleanup ZFS path retries the export while the pool is still busy" {
+ FILESYSTEM=zfs
+ POOL_NAME=zroot
+ CALLS=(); EXPORTS=0
+ umount() { :; }
+ zpool() {
+ CALLS+=("zpool $*")
+ [[ "$1" == list ]] && return 0
+ if [[ "$*" == "export zroot" ]]; then
+ EXPORTS=$((EXPORTS + 1))
+ [ "$EXPORTS" -ge 3 ]
+ return
+ fi
+ return 0
+ }
+ sleep() { CALLS+=("sleep $*"); }
+ warn() { :; }
+ error() { return 1; }
+
+ install_failure_cleanup || true
+
+ [ "$EXPORTS" -eq 3 ]
+ [[ " ${CALLS[*]} " != *" zpool export -f zroot "* ]]
+}
+
+@test "install_failure_cleanup ZFS path forces the export once the busy window runs out" {
+ FILESYSTEM=zfs
+ POOL_NAME=zroot
+ CLEANUP_BUSY_ATTEMPTS=4
+ CALLS=(); EXPORTS=0; SLEEPS=0
+ umount() { :; }
+ zpool() {
+ CALLS+=("zpool $*")
+ [[ "$1" == list ]] && return 0
+ [[ "$*" == "export zroot" ]] && { EXPORTS=$((EXPORTS + 1)); return 1; }
+ return 0
+ }
+ sleep() { SLEEPS=$((SLEEPS + 1)); }
+ warn() { :; }
+ error() { return 1; }
+
+ install_failure_cleanup || true
+
+ [ "$EXPORTS" -eq 4 ]
+ [ "$SLEEPS" -eq 3 ]
+ [[ " ${CALLS[*]} " == *" zpool export -f zroot "* ]]
+}
+
+@test "install_failure_cleanup Btrfs path retries closing LUKS until the mapping is gone" {
+ FILESYSTEM=btrfs
+ CLOSES=0
+ umount() { :; }
+ btrfs_cleanup() { :; }
+ btrfs_close_encryption() { CLOSES=$((CLOSES + 1)); }
+ luks_mappings_open() { [ "$CLOSES" -lt 3 ]; }
+ sleep() { :; }
+ warn() { :; }
+ error() { return 1; }
+
+ install_failure_cleanup || true
+
+ [ "$CLOSES" -eq 3 ]
+}
+
+@test "install_failure_cleanup Btrfs path stops retrying the LUKS close after the busy window" {
+ FILESYSTEM=btrfs
+ CLEANUP_BUSY_ATTEMPTS=4
+ CLOSES=0
+ umount() { :; }
+ btrfs_cleanup() { :; }
+ btrfs_close_encryption() { CLOSES=$((CLOSES + 1)); }
+ luks_mappings_open() { return 0; }
+ sleep() { :; }
+ warn() { :; }
+ error() { return 1; }
+
+ install_failure_cleanup || true
+
+ [ "$CLOSES" -eq 4 ]
+}
+
@test "install_failure_cleanup clears sensitive variables before exiting" {
FILESYSTEM=zfs
POOL_NAME=zroot
@@ -218,6 +568,52 @@ setup() {
[[ " ${CALLS[*]} " != *" zpool"* ]]
}
+# btrfs_cleanup unmounts only the subvolumes it mounted, one level at a
+# time. A pacstrap interrupted mid-transaction can leave its /proc, /sys
+# and /dev bind mounts under /mnt, which keep the root busy, so the LUKS
+# mapping can't close and the retry's disk_in_use still sees a mountpoint.
+@test "install_failure_cleanup Btrfs path unmounts the target root recursively before closing LUKS" {
+ FILESYSTEM=btrfs
+ CALLS=()
+
+ umount() { CALLS+=("umount $*"); return 0; }
+ zpool() { CALLS+=("zpool $*"); return 0; }
+ btrfs_cleanup() { CALLS+=("btrfs_cleanup"); }
+ btrfs_close_encryption() { CALLS+=("btrfs_close_encryption"); }
+ warn() { :; }
+ error() { CALLS+=("error"); return 1; }
+
+ install_failure_cleanup || true
+
+ local i recursive=-1 close=-1
+ for i in "${!CALLS[@]}"; do
+ [[ "${CALLS[$i]}" == "umount -R /mnt" ]] && recursive=$i
+ [[ "${CALLS[$i]}" == "btrfs_close_encryption" ]] && close=$i
+ done
+ [ "$recursive" -ge 0 ]
+ [ "$close" -gt "$recursive" ]
+}
+
+@test "install_failure_cleanup Btrfs path falls back to a lazy recursive unmount when the root is busy" {
+ FILESYSTEM=btrfs
+ CALLS=()
+
+ umount() {
+ CALLS+=("umount $*")
+ [[ "$*" == "-R /mnt" ]] && return 32
+ return 0
+ }
+ btrfs_cleanup() { CALLS+=("btrfs_cleanup"); }
+ btrfs_close_encryption() { CALLS+=("btrfs_close_encryption"); }
+ warn() { :; }
+ error() { CALLS+=("error"); return 1; }
+
+ install_failure_cleanup || true
+
+ [[ " ${CALLS[*]} " == *" umount -R -l /mnt "* ]]
+ [[ " ${CALLS[*]} " == *" btrfs_close_encryption "* ]]
+}
+
@test "install_failure_cleanup ZFS path skips zpool export when pool not imported" {
FILESYSTEM=zfs
POOL_NAME=zroot
diff --git a/tests/unit/test_btrfs.bats b/tests/unit/test_btrfs.bats
index 15bf141..6205311 100644
--- a/tests/unit/test_btrfs.bats
+++ b/tests/unit/test_btrfs.bats
@@ -89,3 +89,37 @@ setup() {
run parse_btrfs_subvol_opts "@x" "nodatacow,nosuid"
[ "$output" = "subvol=@x,noatime,space_cache=v2,discard=async,nodatacow,nosuid,nodev" ]
}
+
+#############################
+# luks_mappings_open
+#############################
+# The failure cleanup can't trust close_luks_container's exit status (it
+# swallows errors), so it checks whether a mapping still exists. Names
+# follow get_luks_devices: the bare LUKS_MAPPER_NAME, then a numeric suffix.
+
+@test "luks_mappings_open succeeds when the bare mapping exists" {
+ MAPPER_DIR="$BATS_TEST_TMPDIR/mapper"; mkdir -p "$MAPPER_DIR"
+ touch "$MAPPER_DIR/$LUKS_MAPPER_NAME"
+ run luks_mappings_open
+ [ "$status" -eq 0 ]
+}
+
+@test "luks_mappings_open succeeds when only a suffixed mapping exists" {
+ MAPPER_DIR="$BATS_TEST_TMPDIR/mapper"; mkdir -p "$MAPPER_DIR"
+ touch "$MAPPER_DIR/${LUKS_MAPPER_NAME}1"
+ run luks_mappings_open
+ [ "$status" -eq 0 ]
+}
+
+@test "luks_mappings_open fails when no mapping exists" {
+ MAPPER_DIR="$BATS_TEST_TMPDIR/mapper"; mkdir -p "$MAPPER_DIR"
+ run luks_mappings_open
+ [ "$status" -eq 1 ]
+}
+
+@test "luks_mappings_open ignores unrelated mappings" {
+ MAPPER_DIR="$BATS_TEST_TMPDIR/mapper"; mkdir -p "$MAPPER_DIR"
+ touch "$MAPPER_DIR/control" "$MAPPER_DIR/vg-root" "$MAPPER_DIR/${LUKS_MAPPER_NAME}-old"
+ run luks_mappings_open
+ [ "$status" -eq 1 ]
+}