Windows 守护进程宿主重定位¶
在 Windows 上,终端守护进程不会从安装目录运行。在它派生守护进程之前,Orca 会在
%LOCALAPPDATA%\Orca\daemon-host\<app version>\ 下生成其自身运行时的精简副本,并从那里
派生守护进程(src/main/daemon/daemon-host-relocation.ts)。这正是使活动终端在自动更新和主进程
崩溃期间保持存活的原因。
在修改复制方案、宿主 exe 名称、LOCALAPPDATA 布局或
config/nsis/orca-installer-hooks.nsh 之前,请先阅读本文。
重定位实际规避了什么¶
致命因素是 electron-builder 的进程清理,按映像路径匹配——而不是文件删除。
Windows 不会删除正在运行的映像,因此 RMDir /r "$INSTDIR" 本身无法终止守护进程。
在 app-builder-lib 的 allowOnlyOneInstallerInstance.nsh 中,FIND_PROCESS / KILL_PROCESS 有两个
分支:
| 分支 | 条件 | 选择器 |
|---|---|---|
| 主分支 | powershell.exe 可运行,Get-CimInstance 可解析,且 Get-ExecutionPolicy -Scope Process 不是 Restricted |
Win32_Process,其中 $_.Path.StartsWith('$INSTDIR', 'CurrentCultureIgnoreCase') — 按路径限定 |
| 回退分支 | 其他情况 | 每用户:taskkill /F /IM "<AppName>.exe" /FI "PID ne $pid" /FI "USERNAME eq %USERNAME%";每机器:相同但无用户名过滤 — 按映像名称限定 |
该探测读取 process 作用域,而不是有效策略;组策略写入
MachinePolicy/UserPolicy——因此,即使 GPO 管理的主机有效策略为 Restricted,它仍会
返回 0 并采用主分支。只有当 powershell.exe 不存在、
Get-CimInstance 无法解析、PowerShell 被完全阻止(WDAC/AppLocker、Server Core),或
环境中存在继承的 PSExecutionPolicyPreference=Restricted 时,才会到达回退分支。
因此在几乎所有机器上,清理都是按路径限定的;只要守护进程的映像位于
%LOCALAPPDATA% 下,无论文件叫什么名字,都在范围之外。存活是路径的属性。
名称只在回退分支上重要。
为什么可执行文件按原样复制(且不重命名)¶
宿主可执行文件保留应用可执行文件自身的文件名(daemonHostExeName() 返回
basename(process.execPath)),因此重定位后的映像是应用二进制文件在其原始名称下的逐字节副本。
早期版本特意将其复制为 orca-terminal-daemon.exe,以便回退的
taskkill /IM Orca.exe 无法匹配。这换来了在极少数无 PowerShell 主机上的存活,但代价是一个教科书式的防御规避特征:一个进程将自己的映像以不同名称复制到用户可写
目录中,使按映像名称终止无法匹配它,然后以分离方式运行并存活过安装程序。 Microsoft Defender for Endpoint 将其标记为 MITRE T1036
(伪装),并且——由于它是所有其他被标记操作所归属的进程——它对无关发现起到了信誉放大器的作用。没有任何 VS Code 分支这样做。
用回退分支换取名称是正确的权衡:
- 主分支上没有任何变化:守护进程仍能在更新后存活。
- 回退分支上,守护进程会随应用一起被终止,终端在重新启动时冷恢复。这是重定位前已记录的行为,是更新测试框架已经断言的一等结果(
--expect cold-restore),而不是失败。 - 无论如何,重定位在端到端上都是 fail-open 的:任何生成失败都会返回
null,调用方会派生安装目录中的宿主。
随之而来的一种新失败模式只出现在回退分支上。守护进程现在会在应用的映像名称下匹配
FIND_PROCESS,因此会进入 electron-builder 的重试循环
(allowOnlyOneInstallerInstance.nsh:136-141)。如果那里的 taskkill 无法终止它——一个提权的
或以其他方式无法终止的宿主——循环会到达 MessageBox ... /SD IDCANCEL 并执行 Quit,从而中止静默更新而不是完成更新。在旧的独立名称下,守护进程对该循环是不可见的。概率较低(回退分支 且 一个无法终止的守护进程),但它是一条真实的新路径。
这没有换来什么。有两点诚实地界定了收益:
- 最强的 T1036 指标是 PE 资源与磁盘名称不匹配,而它从未触发:
随附二进制文件的
OriginalFilename为空(只设置了InternalName = Orca),因此没有嵌入名称可供旧的磁盘名称与之矛盾。 - 剩余行为——一个已签名应用将自己的约 225 MB 映像复制到用户可写的
%LOCALAPPDATA%中,并在ELECTRON_RUN_AS_NODE=1下分离运行它——仍然是从非标准用户可写位置执行,这映射到 T1036.005,本身就是一个标准启发式规则。
因此,这移除的是一个真实但部分的信号。预期分数会下降;不要预期该进程不再被评分。
被拒绝的选项¶
| 选项 | 为何不采用 | | -------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 选项 | 为何不采用 |
|---|---|
| --- | --- |
| 从 NSIS 安装程序生成目录树 | 守护进程宿主约为 246 MB。在安装时写入它会使安装占用空间加倍,并延长静默更新期间应用不可用的时间窗口。更糟的是,在按机器安装(INSTALL_MODE_PER_ALL_USERS)时,安装程序以正在安装的管理员身份运行,因此 $LOCALAPPDATA 属于错误的用户——其他每个用户仍然需要运行时路径,这意味着运行时自拷贝仍保留在产品中,只是让该信号变得更罕见。 |
在安装程序中附带第二个已签名的 orca-terminal-daemon.exe |
Orca.exe 为 235,555,328 字节(224.6 MiB)。electron-builder 的 NSIS 使用带 64 MB 字典的 solid LZMA,因此下游 224 MB 处的第二份副本不会去重;压缩后的安装程序大约会增加一整个压缩 Electron 二进制的大小,并由每个用户在每次更新下载时承担。它也不会移除运行时副本——辅助进程仍必须到达 %LOCALAPPDATA% 以避开清理——因此它以巨大的下载成本换得与逐字拷贝相同的信号减少。 |
重写 customCheckAppRunning,以在两个分支上强制路径范围的终止 |
编写成本低(约 6 行:!include "getProcessInfo.nsh"、Var pid,以及一个固定 IsPowerShellAvailable 的宏,复用上游的对话框、重试循环和提权处理)——但在任何规模下都是错误的。在 PowerShell 确实不存在的宿主上强制使用 PowerShell 分支,会使 FIND_PROCESS 和 KILL_PROCESS 静默地成为空操作,因此安装程序会在 真实应用 仍在运行且其文件被占用时继续执行。这比它本可避免的冷恢复更糟糕,因此这永远不值得做,而不仅仅是现在不值得。 |
| 使用硬链接而非拷贝 | 完全避免 246 MB,而且根本不是“拷贝”,但仅限 NTFS 且同一卷,并会引入新的失败模式(链接计数、杀毒软件拦截、跨卷安装)。值得刻意重新评估,而不是作为信号修复的一部分。 |
需要保持的不变量¶
- 宿主 exe 名称是从
process.execPath派生的,绝不使用字面量。未来的executableName或开发渠道重命名必须自动跟随;固定我们自己的名称正是失配重新出现的方式。 - 守护进程通过 PID 和命令行 识别,绝不通过映像名称——在产品
(
daemon-pid-file-parse、daemon-process-inspection)以及在测试框架 (tests/tools/win-update-e2e/daemon-processes.mjs)中。任何地方都不得开始按 exe 名称匹配。 config/nsis/orca-installer-hooks.nsh按映像名称终止守护进程。现在它也会匹配应用自身的 exe,这在真正的卸载时是正确的——产品正在被移除——但它的${isUpdated}守卫必须保留:electron-builder 在每次更新的uninstallOldVersion期间运行卸载程序,在那里终止守护进程会使整个功能失效。遗留的orca-terminal-daemon.exe名称保留在宏中,以清理旧版本构建遗留的宿主。daemon-host-relocation.ts中的LOCAL_HOST_ROOT_NAME与卸载宏中的路径是同一目录。必须一起修改。
验证更改¶
单元测试覆盖位于 src/main/daemon/daemon-host-relocation.test.ts(拷贝计划、逐字命名、标记/原子发布、失败开放、修剪否决)。单元测试中的任何内容都无法证明存活,因此对该文件或 NSIS 宏的任何更改都需要打包测试框架:
.github/workflows/win-update-survival-e2e.yml— 从分支构建安装程序,并使用--expect survival覆盖自身进行更新。主要证明。.github/workflows/win-crash-survival-e2e.yml— 证明守护进程在主进程崩溃后存活。.github/workflows/windows-terminal-restart-e2e.yml— 终端重启行为。.github/workflows/win-update-e2e.yml— 从发布标签到发布标签的更新,包含survival和cold-restore两种配置。
全部四个都只能由 workflow_dispatch 触发(两个更新工作流还带有固定到一个历史功能分支的 push 触发器),因此在合并此处的更改之前,
必须针对该分支手动触发它们——这要求工作流文件已经存在于 main 上。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 orca48/orca