修四个安装/卸载卡死的 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
+113 -38
View File
@@ -55,6 +55,20 @@ PrivilegesRequired=admin
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
; 安装/升级前由 PrepareToInstall 自己强杀正在运行的被控端。
;
; 为什么不用 Inno 自带的关程序机制(默认开着):
; 它只会礼貌地"请求"对方退出,遇上以管理员身份启动的常驻进程
; 关不掉,然后弹出"安装程序无法自动关闭所有应用程序",
; 静默模式下这个消息框被压成"终止",**安装直接回滚** ——
; 用户看到的现象是"装了没反应/还是旧版本"。
; (2026-10 实测踩到:旧实例在跑,新版本装不上,配对码也没写进去。)
;
; 我们自己在 PrepareToInstall 里 taskkill /F,干净利落。
; 关掉 Inno 自带的那套,免得它先弹框把安装拦下来。
CloseApplications=no
RestartApplications=no
OutputDir=..\dist
OutputBaseFilename=ykxmz-setup-{#MyAppVersion}
SetupIconFile=..\windows\app.ico
@@ -107,27 +121,62 @@ Name: "{commonappdata}\ykxmz\data"; Permissions: users-modify
Type: filesandordirs; Name: "{app}\data"
[Run]
; 装完立即静默启动(标准 postinstall 勾选框,默认勾上)
; 收尾三步,顺序很重要:先写配对码,再设开机自启,最后才启动。
;
; ⚠ 为什么放在这里,而不是 [Code] 的 CurStepChanged(ssPostInstall):
; 实测这个 Inno 版本的 ssPostInstall 回调**不会触发**
; (日志显示回调进来了、CurStep=1,但 CurStep <> ssPostInstall 成立;
; 而 Ord(ssPostInstall) 又报类型不匹配,没法打出来对比)。
; 放在那里 --code 永远不执行,配对码永远是空的。
; Run 段是安装完成后必然执行的,靠得住。
;
; {code:GetPairingCode} 是 Inno 的写法:调同名 Pascal 函数取值。
; Check 函数返回 False 就跳过这一条。
; 1. 写配对码
Filename: "{app}\{#MyAppExeName}"; Parameters: "--code {code:GetPairingCode}"; \
Flags: runhidden; Check: HasPairingCode
; 2. 开机自启
Filename: "{app}\{#MyAppExeName}"; Parameters: "--autostart on"; \
Flags: runhidden; Tasks: autostart
; 3. 启动。
; 非静默:标准 postinstall 勾选框,用户可以取消。
; 静默: /VERYSILENT 下 postinstall 那条被 skipifsilent 跳过,所以单独来一条。
;
; ⚠ 都用 nowait —— --silent 是常驻守护进程,等它退出等于永远不返回。
; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住
; (PowerShell 的 Start-Process -Wait 就是)。
; 学校批量部署请用 cmd:
; cmd /c ykxmz-setup-1.7.1.exe /VERYSILENT /CODE=123456
; cmd 只等安装器本身,不等孙子进程,不会卡。
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
Description: "立即启动(后台运行,不显示窗口)"; \
Flags: nowait postinstall skipifsilent
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
Flags: nowait runhidden; Check: WizardSilent
; 让用户看一眼装完须知
Filename: "{app}\docs\AFTER-INSTALL.txt"; \
Description: "打开「安装后须知」"; \
Flags: postinstall shellexec unchecked skipifsilent
[UninstallRun]
; 卸载前先让被控端自己收尾:停进程、删开机自启、清数据目录。
; 这样"卸载后什么都不留"才是真的干净。
Filename: "{app}\{#MyAppExeName}"; Parameters: "--uninstall"; \
Flags: runhidden waituntilterminated; RunOnceId: "StopAgent"
[UninstallDelete]
; 被控端会自己删数据目录,这里兜底再清一遍安装目录里的残留
; 被控端会自己删数据目录,这里兜底再清一遍安装目录里的残留。
; {commonappdata}\ykxmz 也要清 —— 安装时 [Dirs] 建了它,
; 卸载时**不会自动删**([Dirs] 建的目录不归 [UninstallDelete] 管),
; 不写会留个空目录在那儿。
Type: filesandordirs; Name: "{app}\data"
Type: filesandordirs; Name: "{app}\docs"
Type: filesandordirs; Name: "{app}\_internal"
Type: filesandordirs; Name: "{commonappdata}\ykxmz"
; Inno 默认只在目录空时删它,而卸载器自己的临时文件
; (_unins.tmp 之类)经常让目录"非空",于是留下一个空壳。
; 显式写 dirifempty 兜底。
Type: dirifempty; Name: "{app}"
[Code]
var
@@ -208,48 +257,74 @@ begin
Result := '';
end;
{ 把向导里收集的东西交给被控端。
注意这里**不能带 --silent** —— 那是常驻守护进程,永远不返回,
用 ewWaitUntilTerminated 等它会把安装程序挂死(第一版就栽在这)。
写配置和启动服务分成两次调用:
--code / --autostart 一次性命令,秒退
--silent 常驻,交给 [Run] 段用 nowait 启动 }
{ 收尾命令现在放在 [Run] 段(见那里的注释)。
原来放在 CurStepChanged(ssPostInstall) 里 —— 实测这个 Inno 版本的
ssPostInstall **根本不会触发**,所以 --code 从来没执行过,
配对码永远是空的。留这个函数只是为了以后有人想改回来时能看到这段历史。 }
procedure CurStepChanged(CurStep: TSetupStep);
var
ResultCode: Integer;
Params: String;
Code: String;
begin
if CurStep <> ssPostInstall then
Exit;
{ 故意什么都不做。真要恢复旧写法,先确认 ssPostInstall 会触发。 }
end;
Code := EffectiveCode();
if Code <> '' then
{ ===== 给 [Run] 段用的取值 / 判断函数 ===== }
{ 配对码。配 HasPairingCode 一起用 —— 空串时那条命令会被跳过。 }
function GetPairingCode(Param: String): String;
begin
Result := EffectiveCode();
end;
{ 有配对码才跑那条 --code 命令 }
function HasPairingCode(): Boolean;
begin
Result := EffectiveCode() <> '';
end;
{ 卸载前先停掉正在跑的进程,不然文件删不干净。
必须在这里做,**不能**改到 UninstallRun 区段去:
UninstallRun 是在文件都删完之后才跑的,
那时候"停进程"已经没意义了(文件早没了)。
必须带 --keep-dir:
InitializeUninstall 在卸载器开始删文件**之前**执行。
不带 --keep-dir 的话被控端会安排一个后台 rmdir 删安装目录,
而卸载器紧接着也在删同一个目录 —— 两边抢文件会**永久卡住**。
(2026-10 实测:从控制面板卸载无限等待。两个原因叠加:
这一行没带 --keep-dir;以及被控端里 input() 在无 stdin 时阻塞。)
}
{ 安装开始前强杀正在运行的被控端。
必须自己杀,不能指望 Inno 自带的关程序机制 ——
它关不掉以管理员身份启动的常驻进程,然后弹框,静默模式下
被压成"终止",导致整个安装回滚。
taskkill /F /IM ykxmz.exe 只会杀别的实例;
这个进程本身是 setup 的辅助进程,名字不是 ykxmz.exe,不受影响。
返回空串 = 没问题,可以继续安装。 }
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
ResultCode: Integer;
begin
Result := '';
NeedsRestart := False;
if Exec('taskkill.exe', '/F /IM ykxmz.exe', '',
SW_HIDE, ewWaitUntilTerminated, ResultCode) then
begin
Params := '--code ' + Code;
if not Exec(ExpandConstant('{app}\{#MyAppExeName}'), Params,
ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated,
ResultCode) then
begin
if not WizardSilent then
MsgBox('配对码写入失败(错误码 ' + IntToStr(ResultCode) + ')。' + #13#10 +
'装完之后双击程序图标,重新设一次也行。',
mbInformation, MB_OK);
end;
{ 0 = 杀掉了;128 = 本来就没有在跑。都算正常。 }
end;
{ 开机自启:单独调一次,也是一次性命令 }
if WizardIsTaskSelected('autostart') then
Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--autostart on',
ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode);
{ 给它一点时间释放文件句柄,否则覆盖 exe 还是会"拒绝访问" }
Sleep(1200);
end;
{ 卸载前先停掉正在跑的进程,不然文件删不干净。 }
function InitializeUninstall(): Boolean;
var
ResultCode: Integer;
begin
Result := True;
Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--uninstall',
Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--uninstall --keep-dir',
ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;