diff --git a/_build/add_cycle_stage.py b/_build/add_cycle_stage.py new file mode 100644 index 0000000..9cbc7bd --- /dev/null +++ b/_build/add_cycle_stage.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +r'''把安装包完整周期测试加进套件,并提交这一轮的关键修复。''' +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +VERIFY = ROOT / "_tests" / "verify_all.ps1" +GIT = r"C:\Program Files\Git\bin\git.exe" +BOM = b"\xef\xbb\xbf" + +STAGE = ''' +# ---------------------------------------------------------------- 3b5 +Banner "3b5/7 安装包完整周期(装 -> 验 -> 卸 -> 再验)" +# 这一步真的装一遍、再卸一遍。踩过的四个卡死 bug 全靠它才发现: +# input() 无 stdin 阻塞 / Popen 不重定向 stdio / +# taskkill 把自己杀了 / 关不掉管理员权限的旧实例导致安装回滚。 +# 注意:需要管理员权限,且会真的往 Program Files 装东西。 +$stageOut = & python (Join-Path $root "_build\\test_installer_cycle.py") 2>&1 + Show-Fails $stageOut +Record "安装包完整周期" ($LASTEXITCODE -eq 0) + +''' + +MSG = """修安装包卸载卡死的真凶: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 项全通过 + +套件新增一个阶段:「安装包完整周期」。 +""" + + +def add_stage() -> None: + raw = VERIFY.read_bytes() + text = raw.decode("utf-8-sig") + if "test_installer_cycle.py" in text: + print(" 套件里已有周期测试阶段") + return + anchor = "# ---------------------------------------------------------------- 3c" + if anchor not in text: + anchor = "# ---------------------------------------------------------------- 4" + if anchor not in text: + print(" [失败] 找不到锚点") + return + text = text.replace(anchor, STAGE.lstrip("\n") + anchor, 1) + VERIFY.write_bytes(BOM + text.replace("\r\n", "\n").encode("utf-8")) + print(" 已加「安装包完整周期」阶段") + + +def commit() -> None: + print() + subprocess.run([GIT, "add", "-A"], cwd=str(ROOT), check=False) + msg_file = ROOT / "_build" / "_cmsg4.txt" + msg_file.write_bytes(MSG.encode("utf-8")) + r = subprocess.run([GIT, "commit", "-q", "-F", str(msg_file)], + cwd=str(ROOT), capture_output=True, text=True, + encoding="utf-8", errors="replace") + msg_file.unlink(missing_ok=True) + print(" commit exit=%s" % r.returncode) + show = subprocess.run([GIT, "log", "--oneline", "-1"], cwd=str(ROOT), + capture_output=True, text=True, encoding="utf-8", + errors="replace") + print(" " + (show.stdout or "").strip()) + env = dict(os.environ) + env["GIT_SSH_COMMAND"] = r'"C:\Program Files\Git\usr\bin\ssh.exe"' + push = subprocess.run([GIT, "push", "origin", "main"], cwd=str(ROOT), + capture_output=True, text=True, encoding="utf-8", + errors="replace", env=env) + for line in (push.stderr or push.stdout or "").strip().splitlines()[-2:]: + print(" " + line) + status = subprocess.run([GIT, "status", "--short"], cwd=str(ROOT), + capture_output=True, text=True, encoding="utf-8", + errors="replace") + print(" 工作区: %s" % ("干净 ✓" if not (status.stdout or "").strip() + else (status.stdout or "").strip()[:160])) + + +def main() -> int: + add_stage() + commit() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/test_installer_cycle.py b/_build/test_installer_cycle.py index 6e5c32b..fb37546 100644 --- a/_build/test_installer_cycle.py +++ b/_build/test_installer_cycle.py @@ -31,7 +31,10 @@ DATA = Path(r"C:\ProgramData\ykxmz") EXE = APP / "ykxmz.exe" REG = (r"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" r"\{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1") -RUN_KEY = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run" +# ⚠ PowerShell 的注册表 provider 路径**必须带冒号**(HKCU:\...)。 +# 写成 HKCU\... 不是合法路径,Get-ItemProperty 静默返回空, +# 于是"开机自启"这一项永远误报失败。(这个坑我自己踩了一次。) +RUN_KEY = r"HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" CODE = "517284" passed = failed = 0 diff --git a/_tests/verify_all.ps1 b/_tests/verify_all.ps1 index 2aaf7a4..6ab3c8d 100644 --- a/_tests/verify_all.ps1 +++ b/_tests/verify_all.ps1 @@ -412,6 +412,16 @@ $stageOut = & python (Join-Path $root "_tests\test_admin_netcheck.py") 2>&1 Show-Fails $stageOut Record "管理页网络体检" ($LASTEXITCODE -eq 0) +# ---------------------------------------------------------------- 3b5 +Banner "3b5/7 安装包完整周期(装 -> 验 -> 卸 -> 再验)" +# 这一步真的装一遍、再卸一遍。踩过的四个卡死 bug 全靠它才发现: +# input() 无 stdin 阻塞 / Popen 不重定向 stdio / +# taskkill 把自己杀了 / 关不掉管理员权限的旧实例导致安装回滚。 +# 注意:需要管理员权限,且会真的往 Program Files 装东西。 +$stageOut = & python (Join-Path $root "_build\test_installer_cycle.py") 2>&1 + Show-Fails $stageOut +Record "安装包完整周期" ($LASTEXITCODE -eq 0) + # ---------------------------------------------------------------- 3c Banner "3c/7 中转管理端批量删除" $stageOut = & python (Join-Path $root "_tests\test_admin_batch_delete.py") 2>&1 diff --git a/installer/ykxmz.iss b/installer/ykxmz.iss index 5637518..d89e503 100644 --- a/installer/ykxmz.iss +++ b/installer/ykxmz.iss @@ -320,11 +320,33 @@ begin Sleep(1200); end; +{ 卸载前先停掉正在跑的进程,不然文件删不干净。 + + 三个参数缺一不可,每个都对应一个实测踩过的卡死/残留问题: + + * 必须在这里做,**不能**改到 UninstallRun 区段去 + —— UninstallRun 是在文件都删完之后才跑的, + 那时候"停进程"已经没意义了(文件早没了)。 + + * 必须带 --keep-dir + —— InitializeUninstall 在卸载器开始删文件**之前**执行。 + 不带的话被控端会安排一个后台 rmdir 删安装目录, + 而卸载器紧接着也在删同一个目录 —— 两边抢文件**永久卡住**。 + + * 必须带 --quiet + —— 卸载器是 runhidden 调的,进程没有控制台。 + 被控端里的 attach_console() 会 AllocConsole + open("CONIN$"), + 在无控制台的隐藏进程里**永久阻塞**;input() 也因为没 stdin 卡住。 + 实测卡住的进程只有 1 个线程、0.16 秒 CPU,就是停在这一步。 + --quiet 让它彻底不碰控制台,结果写进 uninstall.log。 + 实测:不带 --quiet 无限卡住,带了 0.6 秒返回。 +} function InitializeUninstall(): Boolean; var ResultCode: Integer; begin Result := True; - Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--uninstall --keep-dir', + Exec(ExpandConstant('{app}\{#MyAppExeName}'), + '--uninstall --keep-dir --quiet', ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode); end; diff --git a/windows/run_agent.py b/windows/run_agent.py index 6a5ff2f..b2af0e1 100644 --- a/windows/run_agent.py +++ b/windows/run_agent.py @@ -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())