fix(deploy): 交互提示写不进终端导致静默退出——fd 3 必须用 <> 打开,不能只读

Ubuntu 24.04 实机定位:菜单打印完就退回 shell、退出码 1、没有任何提示(用户三次复现)。
根因是 `exec 3</dev/tty` 以**只读**方式打开 fd 3,而 read_prompt / read_secret 要往它**写**提示语:

  $ exec 3<file; printf x >&3
  bash: printf: write error: Bad file descriptor      # 退出码 1

在 set -e 下这一下就把脚本打死;错误信息还因为走 tee 管道、脚本退出时进程替换的子进程来不及
flush 而被吞掉 —— 于是表现成"菜单打完就默默消失"。

- install.sh 的 open_prompt_fd、classhelper 的 open_tty 都改成 `exec 3<>/dev/tty`(读写打开);
- 提示语写失败改为显式降级(该项用默认值 + 打印一条提示),不再让整条命令消失;
- classhelper 里是同一份实现,不修的话 Linux 上 `classhelper password` 会同样毫无提示地退出。

本地复现验证过三种情形:只读 fd 写 → Bad file descriptor / 退出码 1;`<>` 写 → 成功;
`if printf … >&3; then` 里的失败不会被 set -e 打死(新加的护栏确实兜住了)。
This commit is contained in:
LoveChengke committed 2026-10-05 19:50:24 +08:00
1 parent f6edfa5ea1
commit 2be8d30376
2 files changed
+28 -9

No files matched your search

+17 -6
View File
@@ -747,8 +747,10 @@ cmd_doctor() {
"这是交叉构建的包($(tr -d '\n' <"${INSTALL_DIR}/.cross-built" 2>/dev/null)):Web 端「数据库一键切换」可能失败,其余功能正常;正式部署请用 GitHub Actions 出的包" "这是交叉构建的包($(tr -d '\n' <"${INSTALL_DIR}/.cross-built" 2>/dev/null)):Web 端「数据库一键切换」可能失败,其余功能正常;正式部署请用 GitHub Actions 出的包"
fi fi
local avail; avail="$(df -Pk "$INSTALL_DIR" 2>/dev/null | awk 'NR==2 {print $4}')" local avail_kb; avail_kb="$(df -Pk "$INSTALL_DIR" 2>/dev/null | awk 'NR==2 {print $4}')"
doctor_check "磁盘剩余 ≥1GB($(human_size "${avail:-0}"))" "$([ "${avail:-0}" -ge 1048576 ] && echo 0 || echo 1)" "清理旧备份:classhelper backup list" case "$avail_kb" in ''|*[!0-9]*) avail_kb=0 ;; esac
# df -Pk 给的是 1K 块,human_size 收的是字节 —— 不乘 1024 会把 25 GB 显示成 25 MB
doctor_check "磁盘剩余 ≥1GB($(human_size "$((avail_kb * 1024))"))" "$([ "$avail_kb" -ge 1048576 ] && echo 0 || echo 1)" "清理旧备份:classhelper backup list"
local newest="" backup_ok=1 local newest="" backup_ok=1
newest="$(ls -1t "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}' | head -1)" newest="$(ls -1t "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}' | head -1)"
@@ -778,23 +780,32 @@ cmd_doctor() {
# 也不进 shell history。 # 也不进 shell history。
open_tty() { open_tty() {
[ "${CLASSHELPER_NO_TTY:-0}" = "1" ] && return 1 [ "${CLASSHELPER_NO_TTY:-0}" = "1" ] && return 1
exec 3</dev/tty 2>/dev/null # 必须用 `<>`(读写):read_secret 既要往 fd 3 写提示语、又要从它读输入,
# 而只读打开的 fd 一写就 EBADF("write error: Bad file descriptor")——
# 在 set -e 下会让整个命令当场退出。install.sh 里踩过同一个坑(见那边的注释)。
exec 3<>/dev/tty 2>/dev/null
} }
read_secret() { # $1=提示语 $2=目标变量名;读到 EOF 时把 READ_SECRET_EOF 置 1 read_secret() { # $1=提示语 $2=目标变量名;读到 EOF 时把 READ_SECRET_EOF 置 1
local prompt="$1" var="$2" value='' local prompt="$1" var="$2" value=''
READ_SECRET_EOF=0 READ_SECRET_EOF=0
if open_tty; then if open_tty; then
printf '%s' "$prompt" >&3 # 提示语写不进去(fd 3 不可写)时按"读不到输入"处理,交给调用方报错退出;
# 千万别让这行失败把命令打死 —— set -e 下它会让 `classhelper password` 毫无提示地退出。
if printf '%s' "$prompt" >&3 2>/dev/null; then
if ! IFS= read -r -s value <&3; then READ_SECRET_EOF=1; fi if ! IFS= read -r -s value <&3; then READ_SECRET_EOF=1; fi
printf '\n' >&3 printf '\n' >&3 2>/dev/null || true
exec 3<&- || true else
READ_SECRET_EOF=1
fi
exec 3<&- 2>/dev/null || true
else else
printf '%s' "$prompt" >&2 printf '%s' "$prompt" >&2
if ! IFS= read -r -s value; then READ_SECRET_EOF=1; fi if ! IFS= read -r -s value; then READ_SECRET_EOF=1; fi
printf '\n' >&2 printf '\n' >&2
fi fi
printf -v "$var" '%s' "$value" printf -v "$var" '%s' "$value"
return 0
} }
# 与服务端 tools/admin-cli.mjs 同一套强度规则(两处口径必须一致) # 与服务端 tools/admin-cli.mjs 同一套强度规则(两处口径必须一致)
+11 -3
View File
@@ -348,7 +348,11 @@ PROMPT_FD=""
open_prompt_fd() { open_prompt_fd() {
if [ -n "$PROMPT_FD" ]; then return 0; fi if [ -n "$PROMPT_FD" ]; then return 0; fi
if [ "${CLASSHELPER_NO_TTY:-0}" = "1" ]; then return 1; fi if [ "${CLASSHELPER_NO_TTY:-0}" = "1" ]; then return 1; fi
if exec 3</dev/tty 2>/dev/null; then PROMPT_FD=3; return 0; fi # 必须用 `<>`(读写)打开,不能只用 `<`(只读):下面 read_prompt 要往 fd 3 **写**提示语,
# 而往只读 fd 写会 EBADF("printf: write error: Bad file descriptor"),在 set -e 下
# 直接把安装脚本打死 —— 现象是"菜单打印完就默默退回 shell",错误还被 tee 吞掉,极难查。
# 2026-10-05 在 Ubuntu 24.04 上实测踩到并定位。
if exec 3<>/dev/tty 2>/dev/null; then PROMPT_FD=3; return 0; fi
return 1 return 1
} }
@@ -362,9 +366,13 @@ read_prompt() {
local prompt="$1" var="$2" hidden="${3:-0}" value="" local prompt="$1" var="$2" hidden="${3:-0}" value=""
printf -v "$var" '' printf -v "$var" ''
if open_prompt_fd; then if open_prompt_fd; then
printf '%s' "$prompt" >&3 # 提示语写不进去时**不能让它把脚本打死**:退化成"这一项用默认值",并且把这件事说出来
if printf '%s' "$prompt" >&3 2>/dev/null; then
if [ "$hidden" = "1" ]; then IFS= read -r -s value <&3 || true; else IFS= read -r value <&3 || true; fi if [ "$hidden" = "1" ]; then IFS= read -r -s value <&3 || true; else IFS= read -r value <&3 || true; fi
printf '\n' >&3 printf '\n' >&3 2>/dev/null || true
else
fail_print "$(printf '%s[classhelper]%s 写不进终端(fd 3 不可写),这一项按默认值处理' "$C_YELLOW" "$C_RESET")"
fi
fi fi
# 刻意**不**退回 stdin:`curl … | bash` 时 stdin 就是脚本内容本身, # 刻意**不**退回 stdin:`curl … | bash` 时 stdin 就是脚本内容本身,
# 从 stdin 读会把脚本的下一行当成用户输入吃掉,表现为"脚本后半段莫名其妙乱套"。 # 从 stdin 读会把脚本的下一行当成用户输入吃掉,表现为"脚本后半段莫名其妙乱套"。