读取 Windows 进程表¶
Orca 需要从 Windows 进程表中获取三样东西:某个 PID 的父进程是谁(用于后代遍历和拆除身份)、某个进程正在运行什么(用于 agent 识别),以及它使用了多少内存/CPU(用于 Resource Manager)。
Node 无法在没有原生代码的情况下回答第一个问题。这就是为什么曾经存在七个独立读取器,每个都会启动 powershell.exe 来运行 Get-CimInstance Win32_Process,并带有 wmic 回退,而 Windows 11 24H2 现已移除该回退。
使用原生快照¶
src/main/windows/windows-process-table.ts 是唯一允许读取该表的模块。它封装了来自 @vscode/windows-process-tree 的 Toolhelp32 快照。
import {
readWindowsProcessIdentityTable,
readWindowsProcessIdentityTableFresh,
readWindowsProcessTable,
readWindowsProcessTableFresh
} from '../windows/windows-process-table'
每一对都是一个共享 TTL 缓存,外加一个 Fresh 变体,该变体在调用之后才开始扫描。对于拆除身份,使用 Fresh,因为缓存行可能早于正在询问的退出事件;对于任何周期性操作,使用缓存版本。
全部四个在无法读取表时都会 reject。不要将其转换为空数组。空表意味着声称没有任何进程在运行,调用方会基于该声称将某棵树判定为已死亡,或将某个 shell 判定为无子进程。“不可用”必须与“空”保持可区分——把两者混为一谈,正是导致一个 PTY 树在自己的拆除过程中存活下来的原因(#9045)。
两组标志:只有读取命令行时才请求命令行¶
这两个标志都不是同一查询中更宽的列。每个标志都是一组独立的逐进程系统调用序列,而且对于正在监视的 EDR 来说,它们的开销并不相同:
CommandLine(process_commandline.cc)—— 先执行OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION),然后两次执行NtQueryInformationProcess(ProcessCommandLineInformation):一次用于确定缓冲区大小,一次用于填充缓冲区。内核负责构建字符串,因此不会打开或读取地址空间。它过去曾通过三次链式ReadProcessMemory调用来遍历目标的 PEB;打过补丁的 addon 不再包含该原语。Memory(process.cc)——已弃用。它需要第二次OpenProcess,而该句柄携带了PROCESS_VM_READ,它获取了该权限却从未使用。
在此处测量(541 个进程,405 个可打开),每次详细扫描,在移除 Memory 前 → 后:OpenProcess 1082 → 541。这一减半是仅移除 Memory 本身所获得的全部收益——当时两个句柄都携带 PROCESS_VM_READ,因此它完全没有改变 PEB 流量。用内核查询替换 PEB 遍历,才是彻底将 PROCESS_VM_READ 和 ReadProcessMemory 从 addon 中移除的原因;这两项变更相互组合,且彼此不能替代。
因此,要精确说明这两组标志现在带来了什么。详细扫描对每个进程执行一次 OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION),并且完全不访问内存。在此基础上,拆分带来的额外收益是句柄本身:身份扫描不打开任何句柄。
因此,该模块暴露两个快照,并且行类型不同,这样低成本调用方就无法读取其标志集未付费获取的内容:
| 读取器 | 行类型 | 标志 | 每进程句柄 |
|---|---|---|---|
readWindowsProcessIdentityTable[Fresh]() |
WindowsProcessIdentityRow |
None \| CreationTime |
无 |
readWindowsProcessTable[Fresh]() |
WindowsProcessRow |
+ CommandLine |
一个 OpenProcess |
Memory 两者都不请求。没有任何内容从该表读取工作集——windows-process-resource-collector.ts 会执行自己的扫描,因为它需要在同一趟中获取 commit 和 CPU 计数器,并且 addon 将 WorkingSetSize 存储到 DWORD 中,因此任何超过 4 GB 的值本来都会回绕。
在 Windows 11 上测量,492 个进程(p50 / p95):
| p50 | p95 | |
|---|---|---|
| 身份(pid + ppid + 名称) | 6.3 ms | 7.0 ms |
| 详细(+ 命令行) | 12.3 ms | 13.4 ms |
| 已弃用(+ 内存) | 13.1 ms | 14.1 ms |
通过 PowerShell 的 Get-CimInstance |
706 ms | 723 ms |
恰好只有两个缓存,绝不按调用方各设一个。该模块旨在防止的扇出是每个 调用方 一次扫描,而每个读取器仍然服务于所有需要其标志集的调用方,因此一次 32 路拆除仍会收敛为每种标志集各一次扫描。第三个缓存需要第三组标志,而不是第三个调用方。
永远只允许一个原生读取在进行¶
这是拥有两组标志的代价,而且它不是可选的。
npm 封装层合并请求而不是排队。getRawProcessList 将回调推入同一个列表,并且只在没有请求正在进行时才调用 addon,因此第二个并发调用方的 flags 会被丢弃,它会被交给第一个调用方的行。针对真实 addon 测量:先发起身份读取,两个调用方获得同一个数组,541 行中有 0 行携带命令行。因此,与身份读取重叠的详细读取会返回一个所有命令行都为空的表,而 agent 识别会将其解读为“无 agent”——悄无声息地,且仅在并发下发生。
该模块中没有其他机制能防止这种情况。每个快照缓存只在其自身内部进行 single-flight(inFlight 是每个读取器的一个闭包),而卡死集合只会在读取错过其 3 秒截止时间_之后_闩锁,因此在一次健康扫描约 12 ms 的过程中,二者互不排斥。重叠是正常状态,而不是边缘情况:其他窗格持续以 750 ms 轮询详细读取,而拆除过程会获取身份快照,并且 codex-structured-turn-processes.ts 会在回合停止时发起新的详细扫描。
nativeReadGate 跨两组标志串行化所有原生读取。它也是让 relay 的裸 addon 安全的原因:adaptAddon 完全没有队列,而两个同时的 CreateToolhelp32Snapshot 调用正是供应商队列旨在防止的崩溃。每个环节都会结束——卡死的读取仍会在其截止时间上 reject——因此等待者永远不会被搁置;它会重新检查卡死状态并 reject。
由于始终只有一个原生调用未完成,楔子门和 3 秒截止时间保持共享,保留量始终有界,恰好为一个回调,而不是每个读取者一个。读取 ID 是模块全局且单调递增的,因此迟到的回调只能清除它自己的楔子。
resetNativeReaderState 链式追加到门,而不是替换它。替换会让仍持有旧链的等待者与排在新链上的读取并行运行;每个链接都会在截止时间前完成,因此链式追加只带来有界的等待,并保持互斥完整。该路径仅用于测试,这正是它重要的原因——否则它会向测试套件提供对其自身 mock 的两次并发调用,而这正是这些测试要检测的条件。
测试此模块:断言一个正向属性,且断言在正确的 mock 上¶
此文件的测试中已经出现了三个缺陷,形态都相同——某个用例因为与其声称要检查的原因不同的原因而通过:
- 一个加载器每次调用都构建一个全新 mock,因此它本应复现的合并永远不会发生。
- 身份侧仅断言
!('command' in row),而一个被正确标记的读取和一个被合并的读取同样满足该断言,因此测试会在它所要防护的回归上变绿。 - 将并发断言放在合并 mock 上,而该 mock 自身的
requestInProgress闩锁意味着它永远无法报告多于一个在途调用——因此无论此模块是否排除了任何内容,该断言都成立,并且在一个真正失去互斥的读取门面前也会通过。
第三个缺陷出现在修复前两个缺陷的过程中,这正是重点:这不是你只犯一次的错误。
因此:断言每个标志集实际获得了什么,而不仅仅是它缺少什么,并将这些断言放在两种顺序都会经过的辅助函数中,否则反向顺序会保留盲点。身份集通过 creationTimeMs 进行检查,因为该标志集存在的目的就是为了携带这个字段。同时保留该检查和标志数组检查——它们捕获不同的失败,且二者都不冗余。标志数组检查能捕获一个读取返回了另一个标志集的行(合并 bug);按位置的 creationTimeMs 检查能捕获字段整形问题——身份标志集从标志中丢弃 CreationTime,或 toIdentityRow 未能转发它——这些没有任何标志断言会注意到。
并且选择与断言匹配的 mock。合并 mock 模拟 npm 包装器的队列语义,是断言这些语义的唯一位置。并发必须针对裸插件 mock 进行测量,它没有队列,因此使重入可观察。
在没有原生绑定时,只有一个扫描需要运行,即 1.4 秒的 PowerShell 扫描,因此身份视图搭载在详细快照上——通过 toIdentityRow 投影,因此身份行在任何主机上都不携带命令行。
哪些调用者需要哪些¶
| 调用者 | 读取 | 标志集 |
|---|---|---|
windows-agent-foreground-process.ts |
command(代理识别) |
详细 |
local-workspace-platform-port-scanner.ts |
command(端口归属) |
详细 |
codex-structured-turn-processes.ts |
command(轮次进程身份) |
详细 |
structured-tui-process-identity.ts |
command(子进程匹配) |
详细 |
windows-pty-root-identity.ts |
仅 pid / ppid |
身份 |
agent-session-process-identity-probe.ts |
仅 creationTimeMs |
身份 |
relay/windows-port-scan.ts |
name(端口所有者标签) |
详细 |
windows-port-scan.ts 是表中唯一的不匹配项:它只读取 pid 和 name,身份集可以回答这些,但它调用的是详细读取器。在具有活动窗格的主机上,这不会额外产生成本——详细快照已经缓存——而在无头中继上,它为没有任何调用者读取的命令行付出代价。这是有意保持现状的,因为将其移到身份集会用每次窗格轮询时的第二次扫描来交换该成本;如果中继在没有窗格的情况下扫描端口,再重新考虑。
每个窗格的前台跟踪器是热点(750 毫秒 / 2 秒节奏),它确实需要命令行,因此按进程重复的 OpenProcess 不是拆分所移除的内容。拆分移除的是拆除身份和所有者探测中的该句柄,它们现在不打开任何内容。
creationTimeMs 在任何已发布构建中都不存在¶
仓库中没有任何内容提供 CreationTime 标志。包枚举为 None/Memory/CommandLine,process_worker.cc 不输出 creationTimeMs,内置补丁也没有添加,且 adaptAddon 的 PROCESS_DATA_FLAG 缺少该位。因此,在生产环境中 creationTimeMs 始终为 undefined,isWindowsProcessStartTimeAvailable() 始终为 false——这是一个早于拆分且需要独立负责人的潜在产品缺口。
有两个后果。IDENTITY_PROJECTION.flags 今天求值为 0,因此身份读取器确实打开零个句柄。并且 agent-session-process-identity-probe.ts 在 isWindowsProcessStartTimeAvailable() 上提前返回,而不是扫描整个表以产生 null。不要基于 Windows 启动时间可用来构建任何东西。
这些 CIM 数字来自一个 1050 进程的主机。扫描随进程数量扩展:在一个 1486 进程的 Windows SSH 主机上,它测得1.36 秒并产生4.8 MiB 的 JSON,而回退方案的限制是 3 秒和 8 MiB。两个限制都与 #15749 之前的读取器一致,因此中继主机处于同等水平,而不是新面临风险——但时间余量约为 2 倍,字节余量约为 1.7 倍,而不是 706 毫秒数字所暗示的约 4 倍。溢出时,输出被截断,JSON 解析失败,读取被拒绝,因此繁忙主机会丢失表,而不是收到错误的表。
当读取楔住时¶
内置读取器将每个回调推送到模块全局队列,并且只有当持有其 requestInProgress 闩锁的请求完成时才排空该队列。如果 Toolhelp32 快照永远不返回——EDR 钩子、受限令牌、死亡的 worker——闩锁会在进程生命周期内卡住,并且每次后续调用都会在该队列中停放另一个闭包。
Two guards, and they work together:
- 每次读取都有 3 秒截止时间,因此调用方会收到拒绝,而不是一个永远不会落定的 Promise;
- 一个粘性楔子:一旦某次读取超过截止时间且尚未回调,模块就会拒绝后续所有读取,直到该读取的回调触发。
该楔子过去是一个 30 秒冷却期,每个窗口只放行一次探测。它限制了新回调的_速率_,但没有限制总量:一个永久被楔住的读取器会在应用运行期间每 30 秒多保留一个闭包,而且每次探测还会先阻塞其调用方整整 3 秒截止时间。改为以未完成的读取作为门控,可将保留量精确限制为一个回调,并且在恢复方面不损失任何东西——排在闩锁后面的探测本来就不可能观察到恢复,而卡住的回调触发本身就是排空。在中继的裸插件上,它本身没有队列,这也是防止 Orca 在一次调用仍在运行时重新进入 CreateToolhelp32Snapshot 的机制。
最后这部分不仅仅是为了整洁。该插件将每次读取作为 Napi::AsyncWorker 运行,因此一次被楔住的读取会永久占用一个 libuv 线程池槽位。在中继上,JS 队列并不存在来吸收重试,因此每个窗口一次探测会在约 2 分钟内占满全部四个默认线程——挂起该进程中的每个异步 fs 和 DNS 调用,而不仅仅是进程表。
楔住不会触发 PowerShell 回退;关于为什么只有缺失才会触发,见下一节。
中继没有绑定,并回退¶
中继部署仅在远程主机上安装 node-pty 和 @parcel/watcher(src/main/ssh/ssh-relay-deploy.ts 中的 RELAY_NATIVE_DEPS),因此用作 SSH 主机的 Windows 机器完全没有 @vscode/windows-process-tree。这是有意不添加的。两种安装方式都会失败,并且两者都在一台拥有 1486 个进程的真实 Windows SSH 主机上验证过:
正常安装会从源码重新构建,而该构建会失败。 该 tarball 带有 binding.gyp,因此 npm 会运行 node-gyp rebuild,无论其中已经编译了什么。在一台_已经安装_ MSVC Build Tools 2022 的主机上,该构建仍然失败:
这正是我们的补丁中 binding.gyp 片段所删除的要求,而补丁无法到达远程主机——pnpm 补丁不会跨越 SSH。否则中继部署会直接中断而不是降级:installNativeDeps 在失败时抛出异常,而跳过工具链的重试仅限于 Linux。
跳过构建并使用随附的二进制文件会返回一个被截断的表。 与这个文件过去声称的相反,已发布的 0.8.0 tarball 确实 包含 build/Release/windows_process_tree.node——一个看起来是意外发布的 MSVC 构建目录(.obj 和 .tlog 文件随附其中)。它是 N-API,因此可以在任何现代 Node 上加载。但它早于我们的补丁,仍然有 process_count < 1024 上限,因此在那台拥有 1486 个进程的主机上:
恰好 1024 行,而发起查询的进程本身就在缺失项中。自身存在性守卫会拒绝这种情况,因此回退仍然会触发——但只在繁忙到超过上限的主机上。这比完全没有绑定更糟:它在空闲机器上有效,在负载下静默失败,而这正是能够逃过测试的 bug 形态。
因此约束并不是没有可交付的二进制文件。而是唯一可交付的二进制文件是损坏的那个,而要构建好的那个需要远程没有的工具链。
相反,windows-process-table.ts 回退到 readWindowsProcessRowsWithCim(windows-process-table-cim-scan.ts),即本模块所替换的 Get-CimInstance 扫描。门控被刻意收窄:
- 它只在模块无法被 require 时触发,绝不在已加载模块失败、楔住或返回不可读表时触发——一个存在但失败的读取器不应在调用方的轮询速率下静默开始 fork 一个 shell;
- 同样失败的回退仍会拒绝,因此“不可用”永远不会退化为“没有正在运行的进程”;
- 该扫描应用与原生路径相同的自身存在性守卫。
src/main/ssh/relay-native-dependency-coverage.test.ts 断言:从中继入口可达的每个原生插件要么已安装在中继主机上,要么在那里列出其缺失安全的原因。该测试存在是因为 #15749 交付了这个缺口:中继测试通过 __setWindowsProcessTreeLoaderForTests 注入了一个假模块,因此没有任何东西验证真实的 require。
仍然将原生读取器交付到中继¶
扫描是底线,而不是终点:它花费约 1.4 秒和一个 powershell.exe,而插件花费约 57 ms。因此发布构建会编译该插件,并将其作为可选的中继工件交付。
config/scripts/build-windows-process-tree-relay-addon.mjs 在 Windows runner 上从 pnpm 已打过补丁的源码构建它,如果缺少任何补丁片段则拒绝运行——Spectre 片段会明显失败,1024 进程片段会_静默_失败,而相对 gyp 路径会在 Windows 的 configure 阶段失败。检查的是源码,而不是信任安装结果。它还会读取输出的 PE machine 字段,因为一个静默输出宿主架构的交叉构建会交付一个目标无法加载的二进制文件。
Windows arm64 从 x64 runner 交叉编译——已在真实硬件上验证,产生 IMAGE_FILE_MACHINE_ARM64(0xaa64),而 x64 为 0x8664。它需要可选的 MSVC v143 ARM64 build tools 组件;没有它,node-gyp 会以 MSB8020 失败,这就是为什么插件构建在漫长的打包步骤之前运行。ORCA_REQUIRE_RELAY_NATIVE_ADDONS 是一个按架构划分的列表,因此未来架构可以先以尽力而为方式添加,再提升为必需。
windows-process-table.ts 直接绑定裸插件,而不是包包装器。该包装器只是在 getProcessList 上增加了一个队列,而该队列就是上面描述的楔子——它闩锁一个模块全局的 requestInProgress,没有 try/catch。本模块已经具备单飞机制和截止时间,因此直接走插件可去掉重复。
该制品在 RELAY_ARTIFACTS 中是可选的:存在时会被哈希,因此携带它的 relay 永远不会与不携带它的 relay 共享同一个不可变目录;也永远不会被探测,因为要求一个只有 Windows 构建机器才能生成的文件,会让一个正确的 relay 被读取为 MISSING 并永远重新部署。在任何其他 OS 上构建的 relay 会继续使用扫描。
为什么需要修补该包¶
config/patches/@vscode__windows-process-tree@0.8.0.patch 包含六处更改。
- Spectre 缓解。 上游
binding.gyp要求 Spectre 缓解库,而 Orca 的 Windows 构建代理并未安装这些库。node-pty因相同原因以相同方式进行了修补。 - 1024 个进程上限。
GetRawProcessList在 1024 个条目后停止。在一台拥有 1051 个进程的真实主机上测量,该模块恰好返回 1024 个,而发起查询的进程本身就在缺失的 27 个之中。被截断的快照会悄悄隐藏 teardown 试图回收的后代进程——这正是原生路径旨在消除的故障。 - 绝对
node-addon-apigyp 路径。require('node-addon-api').targets是相对于 cwd 的。Windows 上的 node-gyp 会从 pnpm store 的 realpath 评估它,然后从node_modules符号链接加载相对路径,因此node_addon_api.gyp会解析到仓库之外,每小时一次的 Windows 构建都会在 configure 阶段失败。node-pty因相同原因以相同方式进行了修补。 - 不读取 PEB,不使用
PROCESS_VM_READ。 见下文。 CreationTime标志(4)。 上游未暴露进程启动时间,而isWindowsProcessStartTimeAvailable()用它来控制结构化 Claude 和 Codex 聊天,因此如果没有此更改,win32 会静默回退到旧版 transcript 路径。GetProcessCreationTime以PROCESS_QUERY_LIMITED_INFORMATION打开进程,并将GetProcessTimes的 FILETIME 转换为 Unix 毫秒;拒绝句柄的进程会以字段缺失的形式输出,而绝不会输出零,因为调用方必须能够区分“无法识别”和时间戳。supportedProcessDataFlags。addon.cc导出编译后的二进制所理解的标志位,lib/index.js再导出它。
为什么单独一个补丁块而不是只改枚举:与 node-pty 不同,该包在 node-gyp 写入的同一 build/Release/ 路径下发布了一个预编译的 .node。pnpm 修补源码树,但不会动那个预编译文件,因此主机可能持有一个已修补的 lib/index.js——包括 ProcessDataFlag.CreationTime 在内——却运行在一个忽略标志 4 的二进制上。CI 恰好产生了这种情况:门控读取为可用,但每一行返回时都没有 creationTimeMs。加载检查或路径检查都无法看出差异,因此二进制必须自己说明。
有两个读取方依赖它。isWindowsProcessStartTimeAvailable() 除非该位被设置,否则返回 false,因为如果声称可用,会导致 captureWindowsDescendantSnapshot 永远返回 null,而结构化聊天却认为自己拥有回收器。此外,windows-process-tree-creation-time.cjs 在安装期间会断言它,这正是强制从源码重新构建的原因——这与 node-pty-job-ownership.cjs 对 node-pty 的 job 导出所起的作用相同。
类型定义声称 commandLine 会在 512 个字符处截断。实测并非如此:在真实主机上观察到的最长长度为 26,059。
命令行来自内核,而不是目标进程的内存¶
在上游中,GetProcessCommandLine 以 PROCESS_QUERY_INFORMATION | PROCESS_VM_READ 打开每个进程,并发起三次链式 ReadProcessMemory 调用——PEB、RTL_USER_PROCESS_PARAMETERS,然后是字符串——以恢复命令行。在重复定时器上遍历另一个进程的地址空间以获取与凭据相邻的数据,正是凭据转储工具所做的行为,因此 Defender for Endpoint 会将其判定为此类行为,无论意图如何。上述标志集本身不会改变这一点;只有移除读取才会改变。
Windows 8.1 为 NtQueryInformationProcess 增加了 ProcessCommandLineInformation 类(60),它以内核构建的 UNICODE_STRING 形式返回相同字符串,只需要 PROCESS_QUERY_LIMITED_INFORMATION。Electron 的最低支持版本是 Windows 10,因此 Orca 支持的每个操作系统都有它。入口点通过 ntdll.dll 上的 GetProcAddress 解析——它没有导入库——大小通过一次空缓冲区调用探测,该调用会返回 STATUS_INFO_LENGTH_MISMATCH。
同一补丁块从 GetProcessMemoryUsage 和 GetCpuUsage 中移除了 PROCESS_VM_READ,这两个函数获取了该权限却从未读取地址空间:GetProcessMemoryInfo 和 GetProcessTimes 可以由 PROCESS_QUERY_LIMITED_INFORMATION 满足。实测表明,对于所有能够打开的进程,两者在较弱权限下都返回相同的值。
在 Windows 11 上测量,约 540 个进程,通过在进程内将 addon 的导入表条目替换为计数桩进行计数:
每次 CommandLine 扫描 |
之前 | 之后 |
|---|---|---|
OpenProcess 调用 |
543 | 543 |
| 所需访问权限 | 0x0410 (VM_READ \| QUERY_INFORMATION) |
0x1000 (QUERY_LIMITED_INFORMATION) |
ReadProcessMemory |
1128 | 0 |
| p50 / p95 | 13.5 / 14.5 ms | 12.3 / 13.5 ms |
对于两个读取方都恢复的每个进程,命令行都逐字节相同(405/405,其他运行中为 399/399 和 376/376),包括一个 24,087 字符的 argv,其中包含嵌入引号、非 ASCII 字符和尾部空白,以及一个 WOW64 目标。较弱权限在可达范围上也是严格超集:三个拒绝 PROCESS_QUERY_INFORMATION | PROCESS_VM_READ 的进程允许了 PROCESS_QUERY_LIMITED_INFORMATION,没有相反情况。
故意没有 PEB 回退¶
早期版本保留了一个 PEB 读取器,用于没有类 60 的内核,并由一个闩锁控制。这是错误的,原因值得记录:ClassifyQueryFailure 将来自任意单个目标的 STATUS_INVALID_INFO_CLASS / NOT_SUPPORTED / NOT_IMPLEMENTED 映射为进程范围、单向切回 PROCESS_VM_READ,并且每次扫描每个 pid 额外进行三次 ReadProcessMemory,持续整个进程生命周期,且 JS 端没有任何可观察信号。
这个读取器存在的环境,是 EDR 会对 ntdll 进行 hook 的环境。一个对未识别的信息类返回 STATUS_INVALID_INFO_CLASS 的 hook,就会在它专门针对的那些机器上,悄无声息地重新启用该补丁移除的那个原语——而一个进程中一个杂散状态就足以造成问题。在 Wine 或任何插桩过的 ntdll 下同样适用。
因此,回退被移除,而不是被保护。GetProcessCommandLine 返回 false 并让命令行保持为空,这本身已是正常结果(WindowsProcessRow.command 的文档说明:当进程拒绝查询句柄时,该字段为空,调用方会回退到映像名称)。降级为没有命令行是可恢复的;悄悄恢复地址空间读取则不可恢复。
这也使该属性可以在产物上检查,而不是在源码上检查:修补后的读取器从不调用 ReadProcessMemory,因此该符号不会出现在编译后的 addon 导入表中。config/scripts/windows-process-tree-gyp-rebuild.mjs 中的 inspectWindowsProcessTreeAddon() 就是这项检查,也是区分这两个二进制的唯一方式——见下文。它返回 clean / unpatched / missing,而不是布尔值,因为不存在的二进制并没有被清除,而把 false 读作“已验证”的调用方,恰恰会放过这项检查要捕获的东西。
由于返回的 UNICODE_STRING 来自同一个可被 hook 的边界,在编码字符之前,会先根据分配对其 Buffer 和 Length 做边界检查,并且探测大小被限制在头部加 64 KiB(Length 是 USHORT),因此错误的大小不会变成 bad_alloc,导致整个扫描失败而不是单个进程失败。
发布的 tar 包附带一个可加载的未修补预编译产物¶
@vscode/windows-process-tree@0.8.0 在 tar 包中发布 build/Release/windows_process_tree.node。它基于 node-addon-api,因此 ABI 稳定,并能在 Node 和 Electron 下干净加载——而且它由未修补的源码构建,因此每次扫描会执行 1179 次 ReadProcessMemory 调用,并以 0x0410 打开每个进程。
这很重要,因为该包的 allowBuilds 为 false,且 CI 使用 --ignore-scripts 安装,因此安装时不会编译它。require() 健康检查无法区分这两个二进制,而被跳过的重建——rebuild-native-deps.mjs 在 postinstall 期间遇到 Windows 文件锁时会软退出 0——会让上游预编译产物保留在原地并被缓存。
四项检查弥补了这一点,全部以缺失的 ReadProcessMemory 导入为键:
ensureWindowsProcessTreeCommandLinePatch()会删除仍然包含该符号的二进制,因此被跳过的重建会明显失败,而不是使用预编译产物;ensure-native-runtime.mjs将这样的二进制视为加载失败,而这正是触发重建的原因;- relay 构建会对其刚生成的产物进行断言;
loadWindowsProcessTree()会对放置在 relay bundle 旁边的 addon 再次断言,并拒绝绑定仍然导入该符号的 addon,回退到 CIM 扫描。仅构建时断言并不足够:bundle 和旁边的 addon 会独立重新部署,因此未获取新 bundle 的主机会保留已有的.node。
以上任何内容都没有缩小 哪些 进程会被询问。详细扫描仍然会查询每个 pid,包括 lsass.exe;它现在使用 Task Manager 所用的相同权限来询问,而不是 PROCESS_VM_READ。将命令行遍历限制在 Orca 自己的子树中是互补的更改,它应属于身份/详细读取器拆分,而不是这里——基于 ppid 的允许列表恰恰会漏掉 tracker 要查找的已分离、被重新父化的后代(#9045, #10475),因此它需要以 job object 成员关系作为事实来源。
打包¶
该 addon 仅支持 Windows,因此遵循与 @orca/windows-registry 相同的约定(由 config/scripts/package-electron-runtime-contract.test.mjs 断言):
- 作为
optionalDependency,因此 macOS/Linux 安装可以容忍其缺失; - 在
pnpm-workspace.yaml的allowBuilds中不启用——pnpm 会在每个主机上安装可选依赖,而 macOS/Linux 绝不应为其运行node-gyp; - 列在
rebuild-native-deps.mjs和ensure-native-runtime.mjs的 win32 分支中; - 仅对 win32 复制到打包后的
node_modules中。
relay 的副本是放置在 bundle 旁边的独立产物,因此 relay 主机只有在重新部署时才会获取重建后的 addon。在此之前,它会保留已有的二进制,这就是为什么在加载时再次检查 addon。
快照未提供的内容¶
CreationDate(进程启动时间)现在有了等价项——上面的 creationTimeMs——但仅限本模块内部。守护进程身份、托管 hook 所有权以及内存收集器中的 CPU 核算仍通过自己的查询读取启动时间;这些调用方尚未迁移。
已提交的私有字节也没有等价项,而 addon 能产生的唯一内存值对于 Orca 现在看到的规模不可用:process.cc 将 pmc.WorkingSetSize 存入 DWORD,因此超过 4 GB 的值会回绕——这就是为什么两组标志都不请求它。这是 windows-process-resource-collector.ts 仍然运行自己的 Get-CimInstance 扫描的第二个原因——它需要在同一次遍历中获取 PageFileUsage(commit)和 CPU 时间计数器。将其迁移到原生表会同时失去这两者,这也是本模块完全不再设置 Memory 标志的原因:该字段没有读取方,而请求它会在每次快照时为每个进程打开一个句柄。
启动时间是身份的代理,而不是身份本身。对于 Orca 自身派生的进程树,持久答案仍然是继承的句柄:job object 指明了 Orca 创建的树,因此无需比较启动时间。本快照现在携带的 creationTimeMs 用于 Orca 未为其创建句柄的树——恢复的 agent 会话、从表中遍历出的后代——在那里,裸 PID 就是重新识别所拥有的一切。
不要采用该包中的 getProcessCpuUsage()。它在一次调用内采集两个 CPU 样本,中间使用阻塞式 Sleep(1000),这会在 Resource Manager 的两秒轮询中占用一个 libuv 线程池槽位整整一秒。
拥有 PTY 的进程树¶
src/main/windows/windows-pty-job.ts 是读取该表的对应实现:它用一个句柄而不是推断来回答“这棵进程树是我的吗,以及我该如何杀死它?”
node-pty 已通过补丁(config/patches/node-pty@1.1.0.patch)在 shell 派生出任何东西之前,为每个 ConPTY 创建一个作业对象,并在 CREATE_SUSPENDED 下将 shell 分配给它。事后分配会留下一段窗口期,让快速派生的子进程逃出该作业。
terminatePtyJob(proc)—— 对整个进程树只需一次TerminateJobObject调用。listPtyJobProcessIds(proc)—— 仍在被追踪的进程树下的活动 pid,包括从控制台分离的子进程。
在 Windows 11 上,对一个以 detached 方式派生出孙进程的 shell 实测:作业成员为 [shell, grandchild],一次调用即可同时杀死两者。无论是沿父 pid 遍历还是 GetConsoleProcessList 都看不到那个孙进程——它会离开控制台并重新指定父进程,这正是导致 claude.exe/node.exe/cmd.exe 一直占用工作树目录的原因(#9045、#10475、#10897)。
每个 PTY 的作业刻意不设置 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE。在 Windows 11 上实测:设置该标志后,在 shell 退出时释放句柄也会杀死用户留下的、仍在运行的任何程序,因此在一个窗格中键入 exit 会收割掉一个原本会存活的 start /b 服务器。该作业的存在是为了让_显式_的拆除精确无误,而不是重新定义“干净退出”的含义。
Git Bash 还需要一项额外限制。Cygwin 运行时——以及 Git for Windows 随附的、基于它的 MSYS2 分支——会读取自己作业上的 JOB_OBJECT_LIMIT_BREAKAWAY_OK,并在设置了该标志时,为其派生的每一个子进程添加 CREATE_BREAKAWAY_FROM_JOB(spawn.cc,自 2011 年起一直如此),因此提供 breakaway 能力就等于把整棵进程树的逃生之路拱手相让。因此,每当 msys-2.0.dll 或 cygwin1.dll 位于 shell 的 DLL 搜索路径上时——即可执行文件旁边,或 Git 的 bin 启动器对应的 usr/bin 下——每个 PTY 的作业都会省略 BREAKAWAY_OK。原生 shell 则保留显式 breakaway。拒绝该能力对 Cygwin 没有任何代价,因为它会_预检_该限制而不是重试,所以不会有任何派生失败;但在这样的窗格内,一个自行传递 CREATE_BREAKAWAY_FROM_JOB 的_原生_程序现在会得到 ERROR_ACCESS_DENIED。nohup 和 disown 不受影响——它们是 Cygwin 的信号/会话概念,与作业成员身份无关。守护进程的宿主作业保持不变。
因此,收割已死亡守护进程的 shell(#9195、#10415)属于第二个嵌套作业,而不是这个作业。终端守护进程在启动时将自身分配到一个 kill-on-close 作业中(assignHostProcessToKillOnCloseJob);子进程继承成员身份,因此每个 pty 都被覆盖,而每个 PTY 的作业则嵌套在该作业内部。该句柄仅在守护进程死亡时才会被释放,因此崩溃的守护进程会收割其进程树,同时不会改变干净 shell 退出的含义。
这种拆分正是关键。一个作业回答“立刻精确杀死这个窗格的进程树”;另一个作业回答“如果宿主死亡,不要遗留任何东西”。试图从同一个作业中同时获得这两种能力,正是导致用户在干净 exit 时后台工作被收割的原因。
它属于守护进程,绝不属于应用:即使 app-main 崩溃,会话也必须保持存活,这正是 .github/workflows/win-crash-survival-e2e.yml 所断言的。应用以 detached 方式派生守护进程,且自身不隶属于任何作业,因此跨该边界不会继承任何东西。
由此导致的结果是,由应用而非守护进程托管的 PTY 会获得每个 PTY 的作业,但没有崩溃收割能力。这是有意为之——另一种选择是在应用上设置 kill-on-close 作业,而这正是崩溃存活保证所禁止的。
一旦 shell 退出,node-pty 会丢弃其句柄记录并关闭作业,因此一个已终止的进程树会报告 null 而不是 []。按照 ssh-execution-boundary.md 的含义,null 表示_无法验证_——没有作业支持、不是 ConPTY,或者不再被追踪。它绝不是进程已死亡的证据。
当 pty 没有作业时,两个函数都会报告 unavailable / null,而不是误报成功——外部作业若未设置 JOB_OBJECT_LIMIT_BREAKAWAY_OK(某些 EDR 和容器宿主)可能会拒绝该分配,而在此构建之前启动的 pty 则没有任何作业。调用方必须回退,而不能断定进程树已消失。这种混为一谈正是最初的 bug。
已知限制:baton 表未同步¶
node-pty 将其每个终端的句柄保存在一个普通的 std::vector 中,并在一个分离的退出线程上从其中擦除,而 get_pty_baton 则从主 JS 线程调用。该竞态在本改动之前就已存在——PtyResize、PtyClear 和 PtyKill 都以相同方式读取该表——但 terminatePtyJob 又增加了它的一个实例:退出线程可以在查找与 TerminateJobObject 之间关闭 hJob。
输掉这场竞态通常只会返回 FALSE,表现为 unavailable 并回退。而不良性的是 HANDLE 值被回收重用的情况,此时调用可能命中同一进程中的另一个作业。正确修复意味着要同步 node-pty 的句柄表,而不是在单个访问器周围加锁,因此这里有意不去动它。
补丁必须真正被编译¶
node-pty 优先使用其上游预构建产物,并且只有在设置了 npm_config_build_from_source 或该平台没有可用预构建产物时,才会从源码构建。Windows 预构建产物不包含此补丁,因此在 Windows 上直接执行 pnpm install 得到的 node-pty 没有 job-object 导出——于是 terminatePtyJob 每次调用都会报告 unavailable,这与正常降级后的构建无法区分。
打包不受影响:rebuild-native-deps.mjs 会为 Electron 从源码重建 node-pty,并恢复裸 node-gyp rebuild 会跳过的 ConPTY 运行时文件。缺口在于 node-runtime 测试环境,这正是 Windows CI 任务在运行 win32 套件之前从源码重建的原因。
isPtyJobOwnershipAvailable() 正是为此而存在的:win32 测试套件在断言任何其他内容之前,都会先断言它为 true,因此未打补丁的二进制文件会明显失败,而不是让每个用例都空泛地通过。正是这个守卫捕获了该问题。
ensure-native-runtime.mjs 中的 requiresPatchedNodePtySourceBuild() 现在也覆盖了 win32,并且 pnpm rebuild node-pty 会设置 npm_config_build_from_source,从而使打过补丁的源码构建真正替换上游预构建。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 orca48/orca