fix: Ctrl-D at a confirm prompt aborts out loud, not in silence (#111) #112

Merged
dan-claude-bot merged 2 commits from fix/confirm-eof-abort into main 2026-07-19 21:27:57 +00:00
3 changed files with 78 additions and 2 deletions
Showing only changes of commit e27ab239f1 - Show all commits

View file

@ -79,6 +79,31 @@ which records not just what changed but what each drill run proved.
misnumbered one, and an operator sent to correct a version number that is
already right will not find the real problem. Matches
heavy-duty/rig#67, so the three repos agree.
- **Ctrl-D at a confirmation prompt aborts out loud, instead of exiting
in silence** (#111) — `confirm()` and `uninstall_confirm()` both took
the operator's answer with a bare `read -r reply`. Every answer a
human can type routes through the `case` below it and ends at a
`return` or at `die "aborted."` — every answer except EOF. Ctrl-D
makes `read` return non-zero, `set -euo pipefail` ends the run on that
line, and the `case` is never reached: box exits 1 having printed
nothing at all after the question it just asked. It fails closed,
which is why this is a small fix and not an incident — nothing is
destroyed, the abort is real. The damage is that the tool goes mute at
the one moment it had the operator's full attention, and someone who
Ctrl-Ds out of `box rm work` cannot tell from the output whether the
box is still there. The cure is one token in each function,
`read -r reply || die "aborted."`, the same one heavy-duty/rig#43
applied to rig's credential prompts so the two repos read alike. The
bug predates everything it touches — `rm` has carried a confirm gate
for as long as the verb has existed — but #105 took the number of
verbs reaching that line from one to two, and both are irreversible,
which is the argument for closing it now rather than the next time
someone notices. The three answers a human can actually give (`y`,
`n`, and Ctrl-D) are now driven for real on a pty via util-linux
`script`: they were structurally untested before, because `[ -t 0 ]`
sends a terminal-less suite to the refusal branch and every existing
check stopped there — which is exactly how this survived four
releases.
- **`box restore` asks before it destroys — and the confirmation prompt is
now the row's, not rm's** (#105) — `restore` and `rm` both irreversibly
discard user state, and only one of them asked. The table gave `restore`

View file

@ -823,7 +823,9 @@ confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
local reply
printf 'box: %s? this cannot be undone. [y/N] ' "$1"
read -r reply
# EOF (Ctrl-D) is an answer, and it means no. Unguarded, 'read' returns
# non-zero and 'set -e' ends the run in silence — heavy-duty/rig#43.
read -r reply || die "aborted."
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
}
@ -1651,7 +1653,8 @@ uninstall_confirm() { # $1 = question. --force, or BOX_YES=1, or a TTY.
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on; BOX_YES=1 also means yes)"
local reply
printf 'box: %s? [y/N] ' "$1"
read -r reply
# Same EOF cure as confirm() above — Ctrl-D must abort out loud.
read -r reply || die "aborted."
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
}

View file

@ -890,6 +890,54 @@ check "dispatch: the confirm prompt comes from the row, not a constant (#105)" 0
# Pinned here because the rehearsal itself needs a daemon and this suite has none.
check "rehearsal: the unattended restore passes --force (#105)" 0 "" \
grep -qF 'box restore mine s1 --force' "$ROOT/drill/multiuser.sh"
# --- the three answers a human can give — DRIVEN ON A REAL PTY (#111) -------
# Everything above stops at the no-TTY refusal, because confirm() branches on
# '[ -t 0 ]' and this suite has no terminal. So the interactive half — 'y',
# 'n', and Ctrl-D — had never been executed here at all, which is precisely
# how #111 survived: an unguarded 'read' returns non-zero on EOF, 'set -e'
# ends the run before the 'case', and the abort happens in total silence.
#
# 'script' from util-linux gives the child a pty, so box takes the interactive
# branch for real and reads the answer we write to the master side. This does
# NOT hang a suite run from a terminal: script's own stdin is a file or
# /dev/null on every run below, never the developer's tty, so the answer (or
# the EOF) is always already waiting.
if command -v script >/dev/null 2>&1 && script --version 2>/dev/null | grep -q util-linux; then
PWORK="$(mktemp -d)"; PLOG="$PWORK/pty.log"
printf 'y\n' > "$PWORK/yes"; printf 'n\n' > "$PWORK/no"
# Invoked through a file so 'script -c' needs no quoting of its own; the log
# path and the shim PATH ride the environment script hands to the child.
cat > "$PWORK/run" <<RUNNER
#!/usr/bin/env bash
exec env PATH="$CSHIM:\$PATH" "$BOX" rm work
RUNNER
chmod +x "$PWORK/run"
ptybox() { # ptybox <answers-file> — 'box rm work' on a pty, answered
: > "$PLOG"
FAKE_INCUS_LOG="$PLOG" script -qec "$PWORK/run" /dev/null < "$1"
}
# The load-bearing assertion is the MESSAGE, not the exit code: before the
# fix Ctrl-D also exited 1, just without ever saying why. Asserting on the
# code alone would pass against the bug.
check "rm: Ctrl-D at the prompt aborts OUT LOUD, not in silence (#111)" \
1 "aborted." ptybox /dev/null
check "rm: ...and the Ctrl-D abort really deleted nothing (#111)" 1 "" \
grep -qF 'incus delete' "$PLOG"
check "rm: 'n' at the prompt aborts (#111)" 1 "aborted." ptybox "$PWORK/no"
check "rm: ...and 'n' really deleted nothing (#111)" 1 "" \
grep -qF 'incus delete' "$PLOG"
# The accept path, so the pty rig is proven to be able to reach the work —
# three checks that can only ever refuse would pass against a box that
# refuses everything.
check "rm: 'y' at the prompt goes through (#111)" 0 "removed work" \
ptybox "$PWORK/yes"
check "rm: ...and 'y' really reached 'incus delete -f' (#111)" 0 "" \
grep -qF 'incus delete -f work' "$PLOG"
rm -rf "$PWORK"
else
echo "skip: the interactive confirm answers (no util-linux 'script' here; CI has it)"
fi
rm -rf "$CSHIM" "$CWORK"
# ---------------------------------------------------------------------------