一键安装函数级套件不再挂在交互提示上
一键安装函数级套件不再挂在交互提示上
标题
fix(deploy): stop the one-click shell suite from blocking on interactive input
目标分支
main(本次直接落在 codex/vpn-phase6-server-data-plane,随 VPN 阶段 6 一起集成)
关联记录
- 实施计划:一键安装函数级套件交互挂死修复实施计划(补记计划)
- 引入方:三端一键安装脚本(v1.2.4,
main的90c5888) - 发现方:VPN 网关 阶段 6:Server 数据面 合并
main后跑全量门禁时
摘要
deploy/install/oneclick/testdata/run_tests.sh 会在任何「输入通道打开着但没有人回答」的环境里
永久阻塞。合并 main 之后,go test ./... 在带 PTY 的终端里必然挂满 10 分钟包级超时,panic
只报 running tests: TestShellFunctionSuite (10m0s),栈顶是 syscall.Wait4,指不出是哪个脚本。
根因是测试驱动脚本没有切断共享库的交互通道:
- 脚本第 46 行为了测
tm_tty_init真的调用了它,有控制终端时TM_TTY=/dev/tty。 - 后面
tm_validate_listen 0.0.0.0:18080会走到tm_ask_choice auth_mode,tm_read_line于是read < /dev/tty,永久阻塞。 - 没有控制终端时
TM_TTY="",读的是调用方 stdin;脚本又export TM_ONECLICK_ALLOW_STDIN=1让tm_require_input_channel无条件放行。go test恰好把 stdin 接到/dev/null,read立刻 EOF 回落默认值password,测试「通过」——这是环境巧合,不是脚本性质。
顺带修掉一条假断言:assert_contains "tty/value" "/dev/tty " "${TM_TTY} " 的 needle 是
"${TM_TTY} ",TM_TTY 为空时退化成单个空格,对任意取值都成立。
用户影响
零产品影响。 没有改动 tunnelmesh-install-common.sh、三个 install-<role>.sh 或
install-<role>.ps1:tm_tty_init 探测 /dev/tty、tm_read_line 回落 stdin 对真人安装是
正确行为,缺陷在测试脚本没有声明「我不是交互环境」。发布归档内容不变。
受益方是贡献者与 CI:
go test ./...不再因为在终端里手跑而挂死 10 分钟。bash deploy/install/oneclick/testdata/run_tests.sh在交互终端里可以直接跑完(修复前会卡在认证方式提示上)。- 将来谁在测试驱动脚本里引入一条交互提示,会在 60s 内得到点名脚本的失败信息。
API、Schema 与配置影响
无。不改 API 路由、OpenAPI、migrations/、SchemaVersion、配置键、协议 frame、错误码与前端产物。
安全影响
无新增攻击面。runBash 只收紧测试执行:给每个 bash 脚本一个硬期限,并在超时时杀掉进程。
TestNoHardcodedSecrets 等既有安全断言未改动。
不变量
- 测试驱动的 shell 脚本必须是非交互的:切断 stdin(
exec </dev/null)、在调用tm_tty_init之后立刻置空TM_TTY、需要具体输入的断言用printf '...\n' | tm_ask_*_stdio在子 shell 里 覆盖、走安装/渲染路径时预置答案或传--yes。 - 任何跑 bash 脚本的 Go 测试都必须有硬期限,且期限到点后
Wait必须能返回 (cmd.WaitDelay),否则「防止挂死」的机制自己会挂死。 tm_tty_init的取值是二值的:/dev/tty或空串,没有第三种。
改动
| 文件 | 改动 |
|---|---|
deploy/install/oneclick/testdata/run_tests.sh |
开头 exec </dev/null;tty/value 改成精确的二值 case 判定;tm_tty_init 断言后立刻 TM_TTY="";新增 ask/hermetic-channel-falls-back(证明通道已断、回落默认值而不是阻塞);收尾新增 tty/hermetic-at-exit 与 tty/stdin-is-not-a-terminal 复核 |
deploy/install/oneclick/oneclick_scripts_test.go |
新增 bashTimeout(3m) / hermeticSuiteTimeout(60s) / bashWaitDelay(5s) 与 runBash helper;TestShellFunctionSuite 改走 helper;新增 TestShellFunctionSuiteNeverReadsTheCallersStdin;imports 增加 bytes、context、io、time |
deploy/install/oneclick/oneclick_e2e_test.go |
runRoleInstaller 改走 runBash;移除不再使用的 bytes import |
deploy/install/oneclick/oneclick_config_test.go |
renderYAML 改走 runBash,stderr 一并进失败信息;os.WriteFile 收 []byte(out) |
docs/development/testing.md |
部署产物校验表补齐 oneclick 三个测试文件(此前该包完全没登记);新增「测试驱动的 shell 脚本必须是非交互的」小节 |
测试证据
红灯(实测,非推断)
不依赖 PTY 的稳定复现——用一条「打开着但永不写入」的管道当 stdin:
| 步骤 | 命令 | 实测结果 |
|---|---|---|
| 纯 shell 复现(修复前) | sleep 300 \| bash testdata/run_tests.sh,20s alarm |
被杀(exit 142),日志 131 行,末行是 ==> 认证方式(socks5 只支持 none|password) (password none) [password]: |
| Go 红灯 | go test . -run TestShellFunctionSuiteNeverReadsTheCallersStdin -count=1 |
--- FAIL: TestShellFunctionSuiteNeverReadsTheCallersStdin (65.00s),信息 testdata/run_tests.sh timed out after 1m0s: 脚本极可能停在 tm_ask*/tm_read_line 的交互提示上。... |
| 原始形态复现(修复前) | PTY 下 go test ./deploy/... -count=1 |
挂满 10m,panic: test timed out after 10m0s / running tests: TestShellFunctionSuite (10m0s),栈顶 syscall.Wait4 |
红灯过程中还暴露了 helper 自身的缺陷:只用 exec.CommandContext 时 120s 仍不返回,ps 里留着
两个 PPID=1 的 run_tests.sh——stdout/stderr 是 bytes.Buffer,os/exec 自建管道并起转发
goroutine,$(...) 子 shell 继承写端,只杀直接子进程会让 Wait 永久阻塞。补上
cmd.WaitDelay = 5s 后才得到上面那条干净的 65.00s 失败。
绿灯(实测)
| 命令 | 结果 |
|---|---|
sleep 300 \| bash testdata/run_tests.sh(修复后) |
跑完,0 failure(s),tty/value、ask/hermetic-channel-falls-back、tty/hermetic-at-exit、tty/stdin-is-not-a-terminal 全 ok |
PTY 下(原挂死环境)go test ./deploy/install/oneclick/ -run TestShellFunctionSuite -count=1 -v |
TestShellFunctionSuite 0.63s PASS、TestShellFunctionSuiteNeverReadsTheCallersStdin 0.63s PASS |
脱离控制终端 go test ./deploy/install/oneclick/ -run TestShell -count=1 -v |
5 个 TestShell* 全 PASS,包 1.756s |
PTY 下 TM_ONECLICK_E2E=1 go test ./deploy/... -count=1 |
4 个包全 ok,oneclick 8.063s(门控端到端安装/升级/卸载未回归) |
门禁
| 命令 | 结果 |
|---|---|
go build ./... / go build -tags vpn ./... |
通过 |
go test ./... -count=1 |
全绿 |
go test -tags vpn ./... -count=1 |
全绿 |
go vet ./... / go vet -tags vpn ./... |
通过 |
gofmt -l internal scripts cmd deploy |
无输出 |
git diff --check |
无输出 |
bash -n deploy/install/oneclick/*.sh、bash -n testdata/run_tests.sh |
通过 |
python3 scripts/gen_doc_index.py(两跑) |
幂等 |
发布步骤
无。随分支正常集成即可,不需要迁移、不需要改配置、不需要重启已部署的服务。
回滚步骤
git revert 本次提交。没有数据库、配置或运行态需要补偿;回滚后唯一的后果是
go test ./... 在带控制终端的环境里重新挂死。
Reviewer 关注点
exec </dev/null会不会掩盖真实的交互缺陷? 不会:它只作用于测试驱动脚本, 发布用的install-<role>.sh完全没改,真人安装仍然从/dev/tty问答。ask/hermetic-channel-falls-back是否与ask/stdin-empty-uses-default重复? 不重复。后者在子 shell 里显式传TM_TTY=并用printf '\n'喂一行,测的是tm_ask_choice_stdio的输入解析;前者不传任何覆盖、不给任何输入,测的是套件全局 的交互通道确实已断——谁把TM_TTY重新武装起来或删掉exec </dev/null,它就会卡住并被 Go 侧 60s 上限点名。- 60s / 3m 两个期限是否合理? 函数级套件实测 0.6–4s,60s 已是 15 倍以上余量;
安装与渲染脚本要走
tar/install/服务 stub,给 3m。两者都远小于 10m 包级超时, 因此失败信息永远来自runBash而不是 panic dump。 - 为什么不用
setsid让 Go 测试脱离控制终端?setsid()在调用进程已是进程组组长时返回 EPERM,交互 shell 的作业控制会让这种情况偶发出现,等于用一个偶发失败换掉一个稳定失败。 用静默管道复现既稳定又不挑环境。 cmd.WaitDelay只关闭管道、不杀孙子进程;被孤立的子 shell 再写就收到 SIGPIPE 自行退出。 该路径只在失败时走到,5s 内收敛。
集成状态
- 分支:
codex/vpn-phase6-server-data-plane - 变更规模:5 个文件(4 个测试/testdata + 1 份开发文档),无产品代码
- 时点记录:三端一键安装脚本 PR 记录 与其计划、规格 只链接、未修改;本次的事实更正只写在本文件与计划文件
- 活文档:
docs/development/testing.md补齐deploy/install/oneclick三个测试文件的登记 (该包由 v1.2.4 引入时漏登记),并新增「测试驱动的 shell 脚本必须是非交互的」小节 - 索引:
scripts/gen_doc_index.py重新生成,两跑幂等