修四个安装/卸载卡死的 bug(都实测复现过)

1. 卸载里 input() 在无 stdin 时永久阻塞
   从控制面板卸载**永远卡住不结束**。安装包的 [UninstallRun]
   用 runhidden 调用,没有 stdin,input() 就一直等。
   改成只在 sys.stdin.isatty() 时才等回车。

2. Popen 没重定向子进程 stdio,导致 capture_output 等管道 EOF
   _schedule_self_delete 起的 cmd 继承了父进程的 stdout/stderr 句柄,
   任何用 capture_output 调 --uninstall 的地方都会一直等,
   表现为"程序卡死"(实测 90 秒不返回,改后 0.2 秒)。
   Popen 加 stdin/stdout/stderr=DEVNULL。
   顺带把延迟从 timeout 换成 ping —— timeout 在 DETACHED_PROCESS
   下没有控制台会立刻失败,等于没延迟直接删。

3. _kill_running 用 taskkill /IM ykxmz.exe 把**自己**杀了
   --uninstall 本身就跑在 ykxmz.exe 里,这条命令一发,
   后面的"删开机自启""删数据目录"全都没执行。
   现象:卸载看着完成了,HKCU 的自启注册表项还留着。
   改成 /FI "PID ne <self>" 排除自己。

4. 安装器关不掉以管理员身份运行的旧实例 -> 整个安装回滚
   Inno 自带机制只会"请求"对方退出,遇上常驻的服务进程关不掉,
   弹"无法自动关闭所有应用程序",静默模式下被压成"终止",
   安装直接回滚 —— 用户看到的是"装了没反应/还是旧版本"。
   改成 PrepareToInstall 里自己 taskkill /F,关掉 Inno 那套。

另外修了两个卸载残留:
   * 安装目录留个空壳 -> [UninstallDelete] 加 dirifempty
   * ProgramData\ykxmz 没删 -> 加进 [UninstallDelete]

还有一处 Inno 写法陷阱(踩了三次):
   注释里写 [区段名] 或 {常量} 会让编译直接失败,
   因为 [Code] 解析器连注释内容也处理。
   加了 _build/check_iss.py 专门扫这个,并进全量套件。

实测结果:
   静默安装  5 秒内退出,退出码 0,配对码正确写入,程序自动跑起来
   静默卸载  0.7 秒退出(原来无限卡住)
   全量回归  29 项全通过
   卸载逻辑  11 项全通过
This commit is contained in:
xmz7891
2026-10-07 01:19:11 +08:00
parent e29b741ddf
commit df8056dd16
13 changed files with 1242 additions and 58 deletions
+83 -15
View File
@@ -1,16 +1,38 @@
# -*- coding: utf-8 -*-
"""卸载:一条命令把程序从这台电脑上清干净。
ykxmz.exe --uninstall
ykxmz.exe --uninstall 自删(用户手动跑)
ykxmz.exe --uninstall --keep-dir 不自删(安装包卸载器调用)
清这些:
1. 停掉正在跑的进程
1. 停掉正在跑的其它实例
2. 删掉开机自启项
3. 删掉数据目录(配对码、已配对手表……)
**故意做这个**:一个能开机自启、又能远程控制的软件,
必须能干净地卸载掉 —— 否则它就成了"赖着不走"的东西。
电脑的主人应该随时能把它彻底清掉。
---- 2026-10 修的一个卡死 bug ----
现象:
从控制面板卸载时,卸载器**永远卡住不结束**。
排查:
1. Inno 的 [UninstallRun] 用 waituntilterminated 等 ykxmz.exe --uninstall 退出
2. 单独跑 ykxmz.exe --uninstall 也是 90 秒不返回 —— 问题在它自己
3. 根因在 _schedule_self_delete():
Popen(["cmd", "/c", cmd], creationflags=...)
**没有重定向子进程的 stdio**。子进程继承了父进程的 stdout/stderr 句柄,
而那个 cmd 要跑 `timeout /t 2` 再 `rmdir /s /q`(上千个文件),
期间一直握着管道。调用方如果用 capture_output 就会**等管道 EOF**,
永远等不到 -> 看起来像"程序卡死"。
修法:
* Popen 时把 stdin/stdout/stderr 全部重定向到 DEVNULL,子进程不碰父进程的管道
* 加 --keep-dir:让安装包的卸载器调用时不走自删
(Inno 自己会删安装目录,两边一起删反而抢文件)
* 补上 _kill_running() —— 之前定义了但从没被调用,文档说停进程其实没停
"""
from __future__ import annotations
@@ -25,18 +47,44 @@ from .autostart import VALUE_NAME, RUN_KEY, LEGACY_VALUE_NAMES, _run
from .config import app_dir, data_dir
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
_DETACHED = 0x00000008 # DETACHED_PROCESS
# 子进程一律不碰父进程的句柄。
# 少了这个,--uninstall 在 capture_output 场景下会"卡死"。
_QUIET = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"close_fds": True,
}
def _kill_running() -> str:
"""把正在跑的进程停掉(除了自己)。"""
"""停掉正在跑的**其它** ykxmz 实例(绝不能杀自己)。
⚠ 这里踩过一个大坑:
原来写的是 taskkill /F /IM ykxmz.exe
但 --uninstall 命令本身**就是 ykxmz.exe** ——
这条命令把正在执行卸载的进程自己杀掉了,
于是后面的"删开机自启""删数据目录"**全都没执行**。
现象:卸载看着完成了,自启注册表项还留着。
正确做法:用 /FI "PID ne <自己的PID>" 把自己排除掉。
"""
if not getattr(sys, "frozen", False):
return "(源码模式,跳过进程清理)"
exe = Path(sys.executable).name
code, out = _run(["taskkill", "/F", "/IM", exe])
me = os.getpid()
code, out = _run([
"taskkill", "/F",
"/FI", "PID ne %d" % me,
"/IM", exe,
])
if code == 0:
return f"已停止进程 {exe}"
if "not found" in out.lower() or "没有" in out:
return "没有正在运行的进程"
return f"已停止其它 {exe} 实例(本进程 {me} 除外)"
if "not found" in out.lower() or "没有" in out or "128" in out:
return "没有正在运行的其它实例"
return f"停止进程:{out[:80]}"
@@ -70,29 +118,49 @@ def _schedule_self_delete() -> str:
进程还在跑的时候删不掉自己,所以要起一个延迟的小命令,
等我们退出后再删目录。
⚠ 两个坑:
1. **必须重定向子进程的 stdio**。不重定向的话子进程会继承
父进程的 stdout/stderr 句柄,调用方用 capture_output 时
会一直等管道 EOF,表现为"卡死"。
2. 用 `ping` 而不是 `timeout` 做延迟。`timeout` 需要一个控制台,
在 DETACHED_PROCESS 下会立刻报错退出(那样就变成不延迟直接删,
反而可能删到一半被占用)。ping 哪都能跑。
"""
if not getattr(sys, "frozen", False):
return "(源码模式,不删自己)"
folder = Path(sys.executable).resolve().parent
cmd = (f'timeout /t 2 /nobreak >nul & '
f'rmdir /s /q "{folder}"')
# ping 3 次约 2 秒;>>nul 把它的输出吞掉
cmd = f'ping 127.0.0.1 -n 3 >nul & rmdir /s /q "{folder}"'
try:
subprocess.Popen(["cmd", "/c", cmd],
creationflags=_NO_WINDOW | 0x00000008)
subprocess.Popen(
["cmd", "/c", cmd],
creationflags=_NO_WINDOW | _DETACHED,
**_QUIET,
)
return f"已安排删除程序目录:{folder}"
except Exception as exc: # noqa: BLE001
return f"(没安排上自删,请手动删 {folder}:{exc})"
def run(keep_data: bool = False) -> str:
"""执行卸载,返回给人看的说明。"""
def run(keep_data: bool = False, keep_dir: bool = False) -> str:
"""执行卸载,返回给人看的说明。
keep_data 保留数据目录
keep_dir 不安排自删程序目录 —— 安装包的卸载器调用时用这个。
因为 Inno 自己会删 {app},两边一起删会抢文件。
"""
lines = ["开始卸载 " + VALUE_NAME]
lines.append(" " + _kill_running())
lines.append(" " + _remove_autostart())
if keep_data:
lines.append(" 保留数据目录(你要求保留)")
else:
lines.append(" " + _remove_data())
lines.append(" " + _schedule_self_delete())
if keep_dir:
lines.append(" 程序目录交给安装包的卸载器删除")
else:
lines.append(" " + _schedule_self_delete())
lines.append("")
lines.append("卸载完成。程序目录会在几秒后自动删除。")
lines.append("卸载完成。")
return "\n".join(lines)