Files
ykxmz/windows/lanremote/uninstall.py
T
xmz7891 df8056dd16 修四个安装/卸载卡死的 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 项全通过
2026-10-07 01:19:11 +08:00

167 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""卸载:一条命令把程序从这台电脑上清干净。
ykxmz.exe --uninstall 自删(用户手动跑)
ykxmz.exe --uninstall --keep-dir 不自删(安装包卸载器调用)
清这些:
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
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
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
me = os.getpid()
code, out = _run([
"taskkill", "/F",
"/FI", "PID ne %d" % me,
"/IM", exe,
])
if code == 0:
return f"已停止其它 {exe} 实例(本进程 {me} 除外)"
if "not found" in out.lower() or "没有" in out or "128" in out:
return "没有正在运行的其它实例"
return f"停止进程:{out[:80]}"
def _remove_autostart() -> str:
done = []
for name in (VALUE_NAME,) + tuple(LEGACY_VALUE_NAMES):
code, _out = _run(["reg", "delete", RUN_KEY, "/v", name, "/f"])
if code == 0:
done.append(name)
if done:
return f"已删除开机自启项 {', '.join(done)}"
return "没有开机自启项"
def _remove_data() -> str:
"""删数据目录。删不掉就留着,不影响卸载。"""
target = Path(data_dir())
if not target.is_dir():
return "没有数据目录"
try:
shutil.rmtree(target, ignore_errors=True)
if not target.is_dir():
return f"已删除数据目录 {target}"
return f"数据目录没删干净(可能被占用):{target}"
except Exception as exc: # noqa: BLE001
return f"删数据目录失败:{exc}"
def _schedule_self_delete() -> str:
"""把自己这个 exe 也删掉。
进程还在跑的时候删不掉自己,所以要起一个延迟的小命令,
等我们退出后再删目录。
⚠ 两个坑:
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
# 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 | _DETACHED,
**_QUIET,
)
return f"已安排删除程序目录:{folder}"
except Exception as exc: # noqa: BLE001
return f"(没安排上自删,请手动删 {folder}:{exc})"
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())
if keep_dir:
lines.append(" 程序目录交给安装包的卸载器删除")
else:
lines.append(" " + _schedule_self_delete())
lines.append("")
lines.append("卸载完成。")
return "\n".join(lines)