mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 12:30:44 +00:00
修安装包卸载卡死的真凶: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:
@@ -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())
|
||||||
@@ -31,7 +31,10 @@ DATA = Path(r"C:\ProgramData\ykxmz")
|
|||||||
EXE = APP / "ykxmz.exe"
|
EXE = APP / "ykxmz.exe"
|
||||||
REG = (r"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
|
REG = (r"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
|
||||||
r"\{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1")
|
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"
|
CODE = "517284"
|
||||||
|
|
||||||
passed = failed = 0
|
passed = failed = 0
|
||||||
|
|||||||
@@ -412,6 +412,16 @@ $stageOut = & python (Join-Path $root "_tests\test_admin_netcheck.py") 2>&1
|
|||||||
Show-Fails $stageOut
|
Show-Fails $stageOut
|
||||||
Record "管理页网络体检" ($LASTEXITCODE -eq 0)
|
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
|
# ---------------------------------------------------------------- 3c
|
||||||
Banner "3c/7 中转管理端批量删除"
|
Banner "3c/7 中转管理端批量删除"
|
||||||
$stageOut = & python (Join-Path $root "_tests\test_admin_batch_delete.py") 2>&1
|
$stageOut = & python (Join-Path $root "_tests\test_admin_batch_delete.py") 2>&1
|
||||||
|
|||||||
+23
-1
@@ -320,11 +320,33 @@ begin
|
|||||||
Sleep(1200);
|
Sleep(1200);
|
||||||
end;
|
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;
|
function InitializeUninstall(): Boolean;
|
||||||
var
|
var
|
||||||
ResultCode: Integer;
|
ResultCode: Integer;
|
||||||
begin
|
begin
|
||||||
Result := True;
|
Result := True;
|
||||||
Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--uninstall --keep-dir',
|
Exec(ExpandConstant('{app}\{#MyAppExeName}'),
|
||||||
|
'--uninstall --keep-dir --quiet',
|
||||||
ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
end;
|
end;
|
||||||
|
|||||||
+48
-15
@@ -83,6 +83,10 @@ def parse_args(argv=None):
|
|||||||
help="配合 --uninstall:不安排自删程序目录。"
|
help="配合 --uninstall:不安排自删程序目录。"
|
||||||
"安装包的卸载器调用时用这个 —— "
|
"安装包的卸载器调用时用这个 —— "
|
||||||
"Inno 自己会删 {app},两边一起删会抢文件")
|
"Inno 自己会删 {app},两边一起删会抢文件")
|
||||||
|
parser.add_argument("--quiet", action="store_true",
|
||||||
|
help="配合 --uninstall:完全不碰控制台。"
|
||||||
|
"安装包的卸载器(runhidden)**必须**用这个,"
|
||||||
|
"否则 AllocConsole/input 会阻塞导致卸载卡死")
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -102,25 +106,46 @@ def cli_autostart(action: str) -> int:
|
|||||||
return 0
|
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()。
|
⚠ quiet 为什么必须:
|
||||||
安装包的 [UninstallRun] 是用 runhidden + waituntilterminated 调的,
|
安装包的卸载器用 runhidden 调这个命令,进程没有控制台。
|
||||||
那种情况下**没有 stdin**,input() 会永远阻塞 ——
|
这时候 attach_console() 里的 AllocConsole() + open("CONIN$")
|
||||||
表现就是"从控制面板卸载永远卡住不结束"。
|
会**永久阻塞** —— 现象就是"从控制面板卸载卡住不结束"。
|
||||||
(2026-10 实测:卸载器无限等待,根因就是这一行。)
|
(实测:卡住的进程只有 1 个线程、0.16 秒 CPU,明显在启动早期就停了。)
|
||||||
所以只在真的有交互式终端时才等回车。
|
|
||||||
|
另外 input() 也不能调:没有 stdin 时它同样阻塞。
|
||||||
|
所以 quiet 模式下把结果写进日志文件,人要看就去看日志。
|
||||||
"""
|
"""
|
||||||
attach_console()
|
|
||||||
from lanremote.uninstall import run as do_uninstall
|
from lanremote.uninstall import run as do_uninstall
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
attach_console()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(do_uninstall(keep_dir=keep_dir))
|
report = do_uninstall(keep_dir=keep_dir)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
print(f"卸载出错:{exc}")
|
report = f"卸载出错:{exc}"
|
||||||
return 1
|
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()
|
print()
|
||||||
|
|
||||||
# 只有真有人在看的时候才等回车
|
# 只有真有人在看的时候才等回车
|
||||||
@@ -129,7 +154,7 @@ def cli_uninstall(keep_dir: bool = False) -> int:
|
|||||||
input("按回车关闭…")
|
input("按回车关闭…")
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
pass
|
pass
|
||||||
return 0
|
return code
|
||||||
|
|
||||||
|
|
||||||
def attach_console() -> None:
|
def attach_console() -> None:
|
||||||
@@ -137,6 +162,13 @@ def attach_console() -> None:
|
|||||||
|
|
||||||
打包成 --windowed 后进程没有控制台,直接 print 什么都看不到。
|
打包成 --windowed 后进程没有控制台,直接 print 什么都看不到。
|
||||||
AllocConsole 可以临时开一个,并把 stdout/stderr 接过去。
|
AllocConsole 可以临时开一个,并把 stdout/stderr 接过去。
|
||||||
|
|
||||||
|
⚠ 调用方要小心:**在"隐藏窗口 + 没有控制台"的场景下这个函数会挂死**。
|
||||||
|
安装包的卸载器是用 runhidden 调 --uninstall 的,
|
||||||
|
AllocConsole() 之后的 open("CONIN$") 会一直阻塞,
|
||||||
|
表现就是"从控制面板卸载永远卡住不结束"
|
||||||
|
(实测:进程 1 个线程、0.16 秒 CPU,卡在启动早期)。
|
||||||
|
所以那条路径必须传 --quiet 跳过整个函数。
|
||||||
"""
|
"""
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
return
|
return
|
||||||
@@ -681,7 +713,8 @@ def main(argv=None) -> int:
|
|||||||
if args.relay_status:
|
if args.relay_status:
|
||||||
return cli_relay_status()
|
return cli_relay_status()
|
||||||
if args.uninstall:
|
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())
|
store = Store(data_dir())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user