From d71be4fb459741777218df5898fa1392bf574b0c Mon Sep 17 00:00:00 2001 From: dietrich ayala Date: Fri, 7 Aug 2026 13:00:18 +0200 Subject: [PATCH] read a confirmation from stdin instead of racing a wrapper for the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit confirm() opened /dev/tty to read the answer, which made it a second reader on the terminal. Under any wrapper that hands its child a pipe for stdin — pass-cli run does, and that is how every credential-touching script here is invoked — the wrapper is itself reading the terminal to fill that pipe. The two then race for each line: the first answer typed went to the wrapper and into a pipe nobody drains, and only the second reached confirm(). The prompt sat there looking ignored and had to be answered twice. The comment above the old code described this symptom and claimed a fix, which was to read from a fresh /dev/tty open rather than hold one descriptor open across both the prompt and the read. That addressed a different failure and left this one, because the competing reader is the wrapper, not the descriptor. The prompt still goes to /dev/tty, which is unrelated and still necessary: run_log_start routes fd 2 through an awk filter that emits only complete lines, and a prompt has no trailing newline, so a prompt written there is never seen. Five cases pin it. A pipe on stdin is exactly what such a wrapper supplies, so they exercise the real configuration rather than standing in for it — and unlike the /dev/tty path, a pipe is something a suite with no controlling terminal can produce. One of them asserts that exactly one line is consumed: a confirm() that drained stdin would leave the caller's own later read at EOF. --- lib/common.sh | 26 +++++++++++++++++++------- lib/common.test.sh | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index 50cb45a..2a9b099 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -322,18 +322,30 @@ Run this from a shell, or pass --non-interactive to assert rather than ask." # answer typed under `pass-cli run`: the prompt appeared, the first y+Enter # vanished, and the second was accepted. Reading from a fresh open is what # the code did before the prompt moved off fd 2, and it did not lose input. + # The PROMPT goes to /dev/tty for the buffering reason above. The ANSWER is + # read from stdin, and the difference is load-bearing. + # + # Reading the answer from a fresh /dev/tty open made this function a SECOND + # reader on the terminal. Under any wrapper that hands its child a pipe for + # stdin — `pass-cli run` does, which is how every credential-touching script + # here is invoked — the wrapper is itself reading the terminal to fill that + # pipe. Two readers then race for each line: the first answer typed is + # consumed by the wrapper and delivered into a pipe nobody drains, and only + # the second reaches this read. The prompt sits there looking ignored, and + # the operator has to answer twice. Reading the stdin we were given competes + # with nobody. + # + # A failed read means stdin is closed or exhausted — no answer is coming, + # which is the same "no terminal" situation the message below describes. + # Without the die, the unset reply trips set -u one line later and reports + # an unbound variable instead of the real problem. if { exec 3>/dev/tty; } 2>/dev/null; then printf '%s [y/N] ' "$msg" >&3 exec 3>&- - # A failed read means the tty went away mid-prompt, which is the same - # "no terminal" situation the fallback below reports. - read -r reply &2 fi + read -r reply || die "$no_tty_msg" # The prompt above never went through fd 2, so it never reached the run # log either. This copy is newline-terminated, so — unlike the prompt — it # passes straight through the same awk filter, landing in the log as the diff --git a/lib/common.test.sh b/lib/common.test.sh index 9d161cd..b09cb3e 100755 --- a/lib/common.test.sh +++ b/lib/common.test.sh @@ -157,6 +157,32 @@ out=$(NON_INTERACTIVE=1 bash -c 'source lib/common.sh; confirm "do the thing"' 2 t "non-interactive: does not hang" "1" "$(grep -c 'rc=1' <<<"$out")" t "non-interactive: names prerequisite" "1" "$(grep -c 'do the thing' <<<"$out")" +# ---- 9a. confirm() reads its answer from STDIN, and consumes exactly one line +# The regression this pins: reading the answer from a fresh /dev/tty open made +# confirm() a second reader on the terminal, racing whatever wrapper had given +# the child a pipe for stdin (`pass-cli run` does). The wrapper swallowed the +# first answer and the operator had to type it twice. +# +# A pipe is exactly the stdin such a wrapper supplies, so these cases are the +# real configuration rather than a stand-in for it — and unlike the /dev/tty +# path, a pipe is something a test suite with no controlling terminal can +# actually produce. +t "confirm: 'y' on stdin is accepted" "0" \ + "$(printf 'y\n' | bash -c 'source lib/common.sh; confirm "ok?" >/dev/null 2>&1; echo $?')" +t "confirm: 'n' on stdin is declined" "1" \ + "$(printf 'n\n' | bash -c 'source lib/common.sh; confirm "ok?" >/dev/null 2>&1; echo $?')" +t "confirm: empty answer is declined" "1" \ + "$(printf '\n' | bash -c 'source lib/common.sh; confirm "ok?" >/dev/null 2>&1; echo $?')" +# The status is taken from the subshell itself, not from an `echo $?` inside +# it: this case ends in die(), which exits, so an inner echo never runs. +t "confirm: closed stdin dies rather than hanging" "1" \ + "$(bash -c 'source lib/common.sh; confirm "ok?"' >/dev/null 2>&1 /dev/null 2>&1; read -r rest; printf "%s" "$rest"')" + # ---- 10. run_log_start tees stdout/stderr into RUN_LOG_DIR # run_log_start replaces the shell's fd 1 and 2 for the rest of its life with # the write end of a tee/awk pipe, so each case runs it inside a subshell. The -- 2.51.2