修四个安装/卸载卡死的 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
+120
View File
@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
r'''验证卸载模块的逻辑(不依赖提权、不依赖真的装一遍)。
为什么单独测这个:
安装包的整体周期测试需要管理员权限,而这个环境里有几个
杀不掉的僵尸卸载器进程挡着。但**卸载逻辑本身**可以直接调,
不需要装、不需要提权。
要验证的三件事(都是这轮修的真 bug):
1. _kill_running 不能用 taskkill /IM 把自己也杀掉
—— 那样后面的删自启/删数据全都不执行
2. run() 必须真的调到 _remove_autostart
3. run(keep_dir=True) 不能安排自删(会和 Inno 卸载器抢目录)
'''
from __future__ import annotations
import os
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
sys.path.insert(0, str(ROOT / "windows"))
import lanremote.uninstall as uni # noqa: E402
passed = failed = 0
def check(name, ok, detail=""):
global passed, failed
if ok:
passed += 1
print(" [PASS] " + name)
else:
failed += 1
print(" [FAIL] " + name + " " + str(detail))
def main() -> int:
print(" 一、_kill_running 不会杀自己")
source = (ROOT / "windows" / "lanremote" / "uninstall.py").read_text(
encoding="utf-8")
check("用了 PID 排除(/FI \"PID ne\")",
'PID ne' in source and "os.getpid()" in source,
"没找到排除自己的逻辑")
check("没有裸的 /IM ykxmz.exe",
'"/IM", exe' in source and 'PID ne' in source,
"taskkill 参数形态不对")
# 直接模拟一下
os.environ.setdefault("YKXMZ_DATA", str(ROOT / "_build" / "_uninst_test"))
result = uni._kill_running()
print(" _kill_running() -> %s" % result)
check("调用后本进程还活着", True) # 能走到这里就说明没被杀
print()
print(" 二、run() 会依次做三件事")
calls = []
originals = {
"_kill_running": uni._kill_running,
"_remove_autostart": uni._remove_autostart,
"_remove_data": uni._remove_data,
"_schedule_self_delete": uni._schedule_self_delete,
}
def spy(name, value):
def wrapper(*args, **kwargs):
calls.append(name)
if callable(value):
return value(*args, **kwargs)
return value
return wrapper
try:
uni._kill_running = spy("kill", originals["_kill_running"])
uni._remove_autostart = spy("autostart", lambda: "(测试替身)")
uni._remove_data = spy("data", lambda: "(测试替身)")
uni._schedule_self_delete = spy("selfdel", lambda: "(测试替身)")
text = uni.run(keep_data=True) # keep_data 免得真删数据
print(" run() 返回:")
for line in text.splitlines():
print(" " + line)
check("调了 _kill_running", "kill" in calls, calls)
check("调了 _remove_autostart(这一步以前会被自己的 taskkill 干掉)",
"autostart" in calls, calls)
check("调了 _schedule_self_delete", "selfdel" in calls, calls)
print()
print(" 三、keep_dir=True 时不安排自删")
calls.clear()
uni.run(keep_data=True, keep_dir=True)
check("调了 _kill_running", "kill" in calls, calls)
check("调了 _remove_autostart", "autostart" in calls, calls)
check("**没有**安排自删(避免和 Inno 卸载器抢目录)",
"selfdel" not in calls, calls)
finally:
uni._kill_running = originals["_kill_running"]
uni._remove_autostart = originals["_remove_autostart"]
uni._remove_data = originals["_remove_data"]
uni._schedule_self_delete = originals["_schedule_self_delete"]
print()
print(" 四、_schedule_self_delete 重定向了子进程 stdio")
check("Popen 用了 DEVNULL 重定向",
"_QUIET" in source and "DEVNULL" in source,
"没重定向的话 capture_output 会等管道 EOF,表现为卡死")
check("用 ping 而不是 timeout 做延迟",
"ping 127.0.0.1" in source,
"timeout 在 DETACHED_PROCESS 下没控制台,会立刻失败")
print()
print("=" * 54)
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())