修安装包卸载卡死的真凶:attach_console 在无控制台时阻塞

排查过程(一步步排除,记录下来免得以后重踩):

  1. 现象:从控制面板卸载,进度条永远走不完。
  2. 先怀疑 input():安装器用 runhidden 调 --uninstall,没有 stdin,
     input() 会一直等。加了 isatty 保护 —— **没解决**。
  3. 再怀疑 Popen 没重定向 stdio:_schedule_self_delete 起的 cmd
     继承了父进程句柄,capture_output 会等管道 EOF。加了
     DEVNULL 重定向 —— **还是卡**。
  4. 又发现 _kill_running 用 taskkill /IM ykxmz.exe 把正在卸载的
     自己杀了(--uninstall 就跑在 ykxmz.exe 里),
     导致后面的"删自启/删数据"没执行。改成 /FI "PID ne self" ——
     自启能删了,**卸载仍然卡**。
  5. 最后看进程状态才定位:卡住的 ykxmz.exe 只有 **1 个线程、
     0.16 秒 CPU、19MB 内存** —— 明显停在启动早期,根本没干活。
     那就是 cli_uninstall 第一行的 attach_console():
     它对 --windowed 程序调 AllocConsole(),再 open("CONIN$")。
     在没有控制台的隐藏进程里,这一步**永久阻塞**。

修法:加 --quiet。安装包的卸载器用
      ykxmz.exe --uninstall --keep-dir --quiet
      --quiet 下完全不碰控制台(不 AllocConsole、不 input),
      结果写进 uninstall.log 供排查。
      实测:不带 --quiet 无限卡住;带了 **0.6 秒返回**。

顺带修的两处(也是实测发现的):
  * 安装器关不掉以管理员身份运行的旧实例 -> Inno 弹"无法关闭所有
    应用程序",静默下被压成"终止",**整个安装回滚**。
    改成 PrepareToInstall 里自己 taskkill /F。
  * 测试脚本把注册表路径写成 HKCU\...(少了冒号),
    PowerShell 静默返回空,"开机自启"永远误报失败。

最终实测(test_installer_cycle.py,20 项):
    静默安装   退出码 0,7.1 秒
    配对码     正确写入 C:\ProgramData\ykxmz\data\settings.json
    进程/端口  在跑,8765 监听
    开机自启   已设置
    静默卸载   1.2 秒
    卸载清理   安装目录 / 数据目录 / 注册表 / 进程 / 自启 全部干净
    20 项全通过

套件新增一个阶段:「安装包完整周期」。
This commit is contained in:
xmz7891
2026-10-07 02:21:36 +08:00
parent 1e73a789d1
commit c7c0484a27
5 changed files with 211 additions and 17 deletions
+48 -15
View File
@@ -83,6 +83,10 @@ def parse_args(argv=None):
help="配合 --uninstall:不安排自删程序目录。"
"安装包的卸载器调用时用这个 —— "
"Inno 自己会删 {app},两边一起删会抢文件")
parser.add_argument("--quiet", action="store_true",
help="配合 --uninstall:完全不碰控制台。"
"安装包的卸载器(runhidden)**必须**用这个,"
"否则 AllocConsole/input 会阻塞导致卸载卡死")
return parser.parse_args(argv)
@@ -102,25 +106,46 @@ def cli_autostart(action: str) -> int:
return 0
def cli_uninstall(keep_dir: bool = False) -> int:
"""卸载。有控制台就开一个让用户看到过程。
def cli_uninstall(keep_dir: bool = False, quiet: bool = False) -> int:
"""卸载。
keep_dir=True 时不安排自删程序目录(安装包卸载器调用时用)。
keep_dir=True 不安排自删程序目录(安装包卸载器调用时用)
quiet=True 完全不碰控制台(安装包卸载器调用时**必须**用)
⚠ 别无条件调 input()。
安装包的 [UninstallRun] 是用 runhidden + waituntilterminated 调的,
那种情况下**没有 stdin**,input() 会永远阻塞 ——
表现就是"从控制面板卸载永远卡住不结束"。
(2026-10 实测:卸载器无限等待,根因就是这一行。)
所以只在真的有交互式终端时才等回车。
⚠ quiet 为什么必须:
安装包的卸载器用 runhidden 调这个命令,进程没有控制台。
这时候 attach_console() 里的 AllocConsole() + open("CONIN$")
会**永久阻塞** —— 现象就是"从控制面板卸载卡住不结束"。
(实测:卡住的进程只有 1 个线程、0.16 秒 CPU,明显在启动早期就停了。)
另外 input() 也不能调:没有 stdin 时它同样阻塞。
所以 quiet 模式下把结果写进日志文件,人要看就去看日志。
"""
attach_console()
from lanremote.uninstall import run as do_uninstall
if not quiet:
attach_console()
try:
print(do_uninstall(keep_dir=keep_dir))
report = do_uninstall(keep_dir=keep_dir)
except Exception as exc: # noqa: BLE001
print(f"卸载出错:{exc}")
return 1
report = f"卸载出错:{exc}"
code = 1
else:
code = 0
if quiet:
# 没有控制台,把结果落到文件,排查时能看
try:
from lanremote.config import data_dir
log = Path(str(data_dir())) / "uninstall.log"
log.parent.mkdir(parents=True, exist_ok=True)
log.write_text(report, encoding="utf-8")
except Exception: # noqa: BLE001
pass
return code
print(report)
print()
# 只有真有人在看的时候才等回车
@@ -129,7 +154,7 @@ def cli_uninstall(keep_dir: bool = False) -> int:
input("按回车关闭…")
except (EOFError, KeyboardInterrupt):
pass
return 0
return code
def attach_console() -> None:
@@ -137,6 +162,13 @@ def attach_console() -> None:
打包成 --windowed 后进程没有控制台,直接 print 什么都看不到。
AllocConsole 可以临时开一个,并把 stdout/stderr 接过去。
⚠ 调用方要小心:**在"隐藏窗口 + 没有控制台"的场景下这个函数会挂死**。
安装包的卸载器是用 runhidden 调 --uninstall 的,
AllocConsole() 之后的 open("CONIN$") 会一直阻塞,
表现就是"从控制面板卸载永远卡住不结束"
(实测:进程 1 个线程、0.16 秒 CPU,卡在启动早期)。
所以那条路径必须传 --quiet 跳过整个函数。
"""
if os.name != "nt":
return
@@ -681,7 +713,8 @@ def main(argv=None) -> int:
if args.relay_status:
return cli_relay_status()
if args.uninstall:
return cli_uninstall(keep_dir=bool(args.keep_dir))
return cli_uninstall(keep_dir=bool(args.keep_dir),
quiet=bool(args.quiet))
store = Store(data_dir())