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