From d26ae4a16620d4c1c21bbfda2f11d43b7733543b Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Fri, 17 Jul 2026 00:12:05 +0200 Subject: [PATCH] fix(scripts): stop auto_update hanging on hidden interactive prompts composer global update inside make upgrade-managed blocked for hours: run_cmd discarded stdout/stderr but left stdin attached to the terminal, so composer's prompt was invisible and waited forever for an answer. run_cmd now: - detaches stdin ( --- scripts/auto_update.sh | 84 +++++++++++++++-- tests/test_auto_update_run_cmd.py | 147 ++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 10 deletions(-) create mode 100644 tests/test_auto_update_run_cmd.py diff --git a/scripts/auto_update.sh b/scripts/auto_update.sh index 02f6dd9..41a913b 100755 --- a/scripts/auto_update.sh +++ b/scripts/auto_update.sh @@ -12,6 +12,8 @@ DRY_RUN="${DRY_RUN:-0}" VERBOSE="${VERBOSE:-0}" SKIP_SYSTEM="${SKIP_SYSTEM:-0}" SCOPE="${SCOPE:-}" # Can be: system, user, project, all, or auto-detect if empty +SLOW_SECS="${SLOW_SECS:-10}" # announce the actual command after this many seconds +TAIL_LINES="${TAIL_LINES:-5}" # rolling output lines shown for slow commands (tty only) log() { printf "[auto-update] %s\n" "$*" >&2 @@ -23,20 +25,81 @@ vlog() { fi } +# Erase the rolling tail region (count lines) from the terminal. +_clear_tail() { + local count="$1" + [ "$count" -gt 0 ] || return 0 + printf '\033[%dA' "$count" >&2 + for _ in $(seq 1 "$count"); do + printf '\033[2K\n' >&2 + done + printf '\033[%dA' "$count" >&2 +} + +# Redraw the last TAIL_LINES lines of file $1, replacing $2 previously drawn +# lines. Echoes the new drawn-line count. +_draw_tail() { + local out="$1" drawn="$2" + local width + width="$(tput cols 2>/dev/null || echo "${COLUMNS:-120}")" + _clear_tail "$drawn" + local lines + lines="$(tail -n "$TAIL_LINES" "$out" 2>/dev/null | cut -c1-$((width - 6)) || true)" + if [ -z "$lines" ]; then + echo 0 + return 0 + fi + printf '%s\n' "$lines" | sed 's/^/ │ /' >&2 + printf '%s\n' "$lines" | wc -l +} + +# Run a package-manager command: stdin detached (a hidden interactive prompt +# must fail fast, not hang on the terminal — a swallowed composer prompt once +# blocked upgrade-managed for hours), the real command line announced after +# SLOW_SECS, a rolling TAIL_LINES-line output tail on ttys, and the last +# output lines surfaced when the command fails. Failures stay non-fatal. run_cmd() { local desc="$1" shift if [ "$DRY_RUN" = "1" ]; then log "DRY-RUN: $desc" log " Command: $*" - else - log "$desc" - if [ "$VERBOSE" = "1" ]; then - "$@" - else - "$@" >/dev/null 2>&1 || true + return 0 + fi + + log "$desc" + if [ "$VERBOSE" = "1" ]; then + "$@" "$out" 2>&1 & + local pid=$! + + local elapsed=0 drawn=0 + while kill -0 "$pid" 2>/dev/null; do + sleep 1 + elapsed=$((elapsed + 1)) + if [ "$elapsed" -eq "$SLOW_SECS" ]; then + log " ↳ still running (${elapsed}s): $*" + fi + if [ "$elapsed" -ge "$SLOW_SECS" ] && [ -t 2 ]; then + drawn="$(_draw_tail "$out" "$drawn")" fi + done + + local rc=0 + wait "$pid" || rc=$? + _clear_tail "$drawn" 2>/dev/null || true + + if [ "$rc" -ne 0 ]; then + log " ✗ $desc failed (exit $rc); last output:" + tail -n 20 "$out" | sed 's/^/ /' >&2 fi + rm -f "$out" + return 0 } # ============================================================================ @@ -415,8 +478,8 @@ update_composer() { if ! detect_composer; then return; fi log "Composer: Updating" - run_cmd "Composer: Self-update" composer self-update - run_cmd "Composer: Update global packages" composer global update + run_cmd "Composer: Self-update" composer self-update --no-interaction + run_cmd "Composer: Update global packages" composer global update --no-interaction log "Composer: Complete" } @@ -543,7 +606,8 @@ get_manager_stats() { go) location="$(command -v go 2>/dev/null || echo "N/A")" version="$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//' || echo "unknown")" - local gobin="$(go env GOBIN 2>/dev/null || echo "$(go env GOPATH 2>/dev/null)/bin")" + local gobin + gobin="$(go env GOBIN 2>/dev/null || echo "$(go env GOPATH 2>/dev/null)/bin")" pkg_count="$([ -d "$gobin" ] && ls -1 "$gobin" 2>/dev/null | wc -l | tr -d '[:space:]' || echo "0")" ;; gup) @@ -926,7 +990,7 @@ run_all_updates() { # Composer if [ -f "./composer.json" ] && command -v composer >/dev/null 2>&1 && confirm_project_update "composer" "./composer.json"; then - run_cmd "Composer: Update project dependencies" composer update + run_cmd "Composer: Update project dependencies" composer update --no-interaction fi fi diff --git a/tests/test_auto_update_run_cmd.py b/tests/test_auto_update_run_cmd.py new file mode 100644 index 0000000..dd50df9 --- /dev/null +++ b/tests/test_auto_update_run_cmd.py @@ -0,0 +1,147 @@ +"""Tests for auto_update.sh run_cmd: hidden-prompt protection, slow-command +notice, and failure output. + +`composer global update` hung for hours inside `make upgrade-managed`: run_cmd +discarded stdout/stderr but left stdin attached to the terminal, so composer's +interactive prompt was invisible and waited forever. run_cmd must detach stdin, +announce long-running commands, and surface output of failed commands. +""" + +from __future__ import annotations + +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +skip_on_windows = pytest.mark.skipif( + sys.platform == "win32", reason="Shell script tests require POSIX shell" +) + +SCRIPT = Path(__file__).parent.parent / "scripts" / "auto_update.sh" + + +def _run_cmd(bash_code: str, timeout: int = 15) -> subprocess.CompletedProcess: + """Extract log()/run_cmd() from auto_update.sh (sourcing it would execute + its CLI dispatch) and run bash_code against them.""" + full_code = f""" +set -euo pipefail +DRY_RUN=0 +VERBOSE=0 +SLOW_SECS="${{SLOW_SECS:-10}}" +TAIL_LINES="${{TAIL_LINES:-5}}" +eval "$(sed -n '/^log()/,/^}}/p' "{SCRIPT}")" +eval "$(sed -n '/^run_cmd()/,/^}}/p' "{SCRIPT}")" +{bash_code} +""" + return subprocess.run( + ["bash", "-c", full_code], + capture_output=True, text=True, timeout=timeout, + stdin=subprocess.PIPE, # a held-open stdin, like an attached terminal + ) + + +@skip_on_windows +class TestRunCmdStdin: + def _assert_completes_with_held_stdin(self, bash_code: str) -> None: + """Run bash_code with a stdin pipe that stays OPEN (like an attached + terminal). A run_cmd that leaks stdin makes `read` block forever.""" + full_code = f""" +set -euo pipefail +DRY_RUN=0 +VERBOSE=0 +SLOW_SECS=10 +TAIL_LINES=5 +eval "$(sed -n '/^log()/,/^}}/p' "{SCRIPT}")" +eval "$(sed -n '/^run_cmd()/,/^}}/p' "{SCRIPT}")" +{bash_code} +""" + proc = subprocess.Popen( + ["bash", "-c", full_code], + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + pytest.fail("run_cmd blocked on the caller's stdin (composer hang)") + finally: + if proc.stdin: + proc.stdin.close() + assert proc.returncode == 0 + + def test_command_reading_stdin_does_not_hang(self): + """A hidden interactive prompt must hit EOF instead of blocking on the + caller's terminal (the composer hang).""" + self._assert_completes_with_held_stdin( + 'run_cmd "Prompting tool" bash -c \'read -r answer; echo done\'' + ) + + def test_verbose_mode_also_detaches_stdin(self): + self._assert_completes_with_held_stdin( + 'VERBOSE=1\n' + 'run_cmd "Prompting tool" bash -c \'read -r answer\' || true' + ) + + +@skip_on_windows +class TestRunCmdSlowNotice: + def test_slow_command_prints_actual_command(self): + result = _run_cmd(""" +SLOW_SECS=1 +run_cmd "Slow step" sleep 3 +""") + assert "sleep 3" in result.stderr, ( + f"slow-command notice must show the real command: {result.stderr}" + ) + + def test_fast_command_stays_quiet(self): + result = _run_cmd(""" +SLOW_SECS=2 +run_cmd "Fast step" true +""") + assert "running" not in result.stderr.lower().replace( + "[auto-update]", "" + ).replace("fast step", "") + assert "true" not in [ + line.strip() for line in result.stderr.splitlines() + ] + + +@skip_on_windows +class TestRunCmdFailureOutput: + def test_failed_command_surfaces_output_and_exit_code(self): + result = _run_cmd(""" +run_cmd "Broken step" bash -c 'echo boom-stdout; echo boom-stderr >&2; exit 3' +echo "SCRIPT_CONTINUED" +""") + # failures stay non-fatal (legacy || true semantics) ... + assert "SCRIPT_CONTINUED" in result.stdout + # ... but are no longer silent + assert "exit 3" in result.stderr + assert "boom-stdout" in result.stderr + assert "boom-stderr" in result.stderr + + def test_successful_command_output_stays_quiet(self): + result = _run_cmd(""" +run_cmd "Chatty step" bash -c 'echo lots-of-noise' +""") + assert "lots-of-noise" not in result.stderr + assert "lots-of-noise" not in result.stdout + + +@skip_on_windows +class TestRunCmdDryRun: + def test_dry_run_prints_command_without_running(self): + result = _run_cmd(""" +DRY_RUN=1 +marker="$(mktemp -u)" +run_cmd "Dry step" touch "$marker" +[ ! -e "$marker" ] && echo "NOT_EXECUTED" +""") + assert "NOT_EXECUTED" in result.stdout + assert "touch" in result.stderr