Fix crash when viewing snapshots (and other list views)

Root cause: under `set -euo pipefail`, the view functions built their body
with `body="$(zfs ... | awk ...)"`. If the pipeline exited non-zero — either
because `zfs list -t snapshot` returned non-zero (e.g. the no-snapshots case)
or because the large report was handed to `whiptail --msgbox` as one argv
string and newt choked — `set -e` aborted the entire script, dropping the
whole TUI back to the shell. The snapshot view is the most exposed since it is
both the largest body and the most likely to hit a non-zero zfs result.

Fix: route every list view through a hardened show() that runs the report
producer with `set -e` disabled (so an inner non-zero can never kill the TUI)
and renders via `whiptail --textbox <tmpfile>` (reads from a file instead of
taking the report as an argv string, and scrolls large content cleanly). Each
view_* is now a thin wrapper over a _report_* producer that prints to stdout.

Verified with a stubbed harness: failing pipeline, 5000-row list, and a
non-zero whiptail all now survive; the previous pattern crashed (exit 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-30 13:34:54 +08:00
parent 936db8b5ff
commit c1bc79efd8

View File

@ -60,10 +60,23 @@ fi
msg() { whiptail --title "$APP_TITLE" --msgbox "$1" "${2:-12}" "${3:-72}"; }
yesno(){ whiptail --title "$APP_TITLE" --yesno "$1" "${2:-12}" "${3:-72}"; }
# Show arbitrary text in a scrollable box.
# Display the stdout of a producer command/function in a scrollable box.
#
# The producer runs with `set -e` disabled and its exit status discarded, so a
# non-zero result from zfs / awk / pipefail can never abort the whole TUI — this
# is what made "View all snapshots" crash back to the shell. The captured report
# is shown via --textbox (which reads from a file) instead of --msgbox (which
# takes the entire report as one argv string and chokes on large snapshot lists).
show() {
local title="$1" body="$2"
whiptail --title "$title" --scrolltext --msgbox "${body:-(no output)}" 24 90
local title="$1"; shift
local out tmp
set +e
out="$("$@" 2>&1)"
set -e
tmp="$(mktemp)"
printf '%s\n' "${out:-(no output)}" >"$tmp"
whiptail --title "$title" --scrolltext --textbox "$tmp" 24 90 || true
rm -f "$tmp"
}
# Run a command, capturing output, and show the result in a scrollable box.
@ -144,14 +157,15 @@ latest_common_snapshot() {
# ---------------------------------------------------------------------------
# Snapshots menu
# ---------------------------------------------------------------------------
view_snapshots() {
local body
body="$(list_snapshots | awk -F'\t' \
# Report producers print to stdout; show() captures them with `set -e` off.
_report_snapshots() {
local rows; rows="$(list_snapshots)"
[[ -z "$rows" ]] && { echo "(no snapshots found)"; return 0; }
printf '%s\n' "$rows" | awk -F'\t' \
'BEGIN{printf "%-48s %8s %8s %s\n","SNAPSHOT","USED","REFER","CREATED"}
{printf "%-48s %8s %8s %s\n",$1,$2,$3,$4}')"
[[ -z "$body" ]] && body="(no snapshots found)"
show "ZFS Snapshots" "$body"
{printf "%-48s %8s %8s %s\n",$1,$2,$3,$4}'
}
view_snapshots() { show "ZFS Snapshots" _report_snapshots; }
create_snapshot() {
local ds name
@ -305,14 +319,14 @@ pick_cron() {
fi
}
view_schedules() {
local body
body="$(parse_snapshot_schedules | awk -F'\t' \
_report_schedules() {
local rows; rows="$(parse_snapshot_schedules)"
[[ -z "$rows" ]] && { echo "(no snapshot schedules configured)"; return 0; }
printf '%s\n' "$rows" | awk -F'\t' \
'BEGIN{printf "%-40s %-16s %s\n","DATASET","CRON","KEEP"}
{printf "%-40s %-16s %s\n",$1,$2,$3}')"
[[ -z "$body" ]] && body="(no snapshot schedules configured)"
show "Snapshot Schedules" "$body"
{printf "%-40s %-16s %s\n",$1,$2,$3}'
}
view_schedules() { show "Snapshot Schedules" _report_schedules; }
add_schedule() {
local ds cron keep snaps scrubs
@ -378,14 +392,14 @@ menu_schedules() {
# ---------------------------------------------------------------------------
# Datasets menu
# ---------------------------------------------------------------------------
view_datasets() {
local body
body="$(list_datasets | awk -F'\t' \
_report_datasets() {
local rows; rows="$(list_datasets)"
[[ -z "$rows" ]] && { echo "(no datasets found — is 'zfs list' working as root?)"; return 0; }
printf '%s\n' "$rows" | awk -F'\t' \
'BEGIN{printf "%-40s %8s %8s %s\n","NAME","USED","AVAIL","MOUNTPOINT"}
{printf "%-40s %8s %8s %s\n",$1,$2,$3,$4}')"
[[ -z "$body" ]] && body="(no datasets found — is 'zfs list' working as root?)"
show "ZFS Datasets" "$body"
{printf "%-40s %8s %8s %s\n",$1,$2,$3,$4}'
}
view_datasets() { show "ZFS Datasets" _report_datasets; }
# ---------------------------------------------------------------------------
# LXC mountpoints menu (Proxmox pct)
@ -408,18 +422,20 @@ lxc_mountpoints() {
}'
}
view_mounts() {
local body="" ctid line
_report_mounts() {
local ctid slot host target found=0
while IFS= read -r ctid; do
[[ -z "$ctid" ]] && continue
while IFS=$'\t' read -r slot host target; do
[[ -z "$slot" ]] && continue
body+="$(printf 'CT %-6s %-6s %-30s -> %s' "$ctid" "$slot" "$host" "$target")"$'\n'
printf 'CT %-6s %-6s %-30s -> %s\n' "$ctid" "$slot" "$host" "$target"
found=1
done < <(lxc_mountpoints "$ctid")
done < <(list_lxc_ids)
[[ -z "$body" ]] && body="(no LXC mountpoints found)"
show "LXC Mountpoints" "$body"
[[ "$found" -eq 0 ]] && echo "(no LXC mountpoints found)"
return 0
}
view_mounts() { show "LXC Mountpoints" _report_mounts; }
add_mount() {
local ctid host target used slot=0
@ -509,21 +525,23 @@ EOF
chmod 755 "$REPLICATION_SCRIPT"
}
view_replication() {
local body="" src tgt common latest status
_report_replication() {
local src tgt common latest status found=0
while IFS=$'\t' read -r src tgt; do
[[ -z "$src" ]] && continue
found=1
latest="$(latest_snapshot "$src")"; latest="${latest:--}"
common="$(latest_common_snapshot "$src" "$tgt")"; common="${common:--}"
if ! dataset_exists "$tgt"; then status="target missing (full send needed)"
elif [[ "$common" == "$latest" && "$common" != "-" ]]; then status="up-to-date"
elif [[ "$common" == "-" ]]; then status="no common snap (mismatch)"
else status="incremental pending"; fi
body+="$(printf '%-30s -> %-30s [%s]' "$src" "$tgt" "$status")"$'\n'
printf '%-30s -> %-30s [%s]\n' "$src" "$tgt" "$status"
done < <(load_replication)
[[ -z "$body" ]] && body="(no replication targets configured)"
show "Replication Targets" "$body"
[[ "$found" -eq 0 ]] && echo "(no replication targets configured)"
return 0
}
view_replication() { show "Replication Targets" _report_replication; }
add_replication() {
local src tgt cfg
@ -588,17 +606,20 @@ menu_replication() {
# ---------------------------------------------------------------------------
# Scrub menu
# ---------------------------------------------------------------------------
view_scrub() {
local body="" pool sched line
_report_scrub() {
local pool sched found=0
while IFS= read -r pool; do
[[ -z "$pool" ]] && continue
found=1
sched="$(parse_scrub_schedules | awk -F'\t' -v p="$pool" '$1==p {print $2; exit}')"
body+="$(printf '===== %s (schedule: %s) =====' "$pool" "${sched:-none}")"$'\n'
body+="$(zpool status "$pool" 2>&1 | grep -E '^[[:space:]]*(state|scan|errors):' )"$'\n\n'
printf '===== %s (schedule: %s) =====\n' "$pool" "${sched:-none}"
zpool status "$pool" 2>&1 | grep -E '^[[:space:]]*(state|scan|errors):' || true
echo
done < <(list_pools)
[[ -z "$body" ]] && body="(no pools found)"
show "Pool Scrub Status" "$body"
[[ "$found" -eq 0 ]] && echo "(no pools found)"
return 0
}
view_scrub() { show "Pool Scrub Status" _report_scrub; }
scrub_start() {
local pool