跳转至

为什么 MSYS 窗格的子进程会逃离每 PTY 作业

从 Git Bash / MSYS2 / Cygwin 窗格启动的每个子进程都会离开该窗格的作业对象,除非该作业是在没有 JOB_OBJECT_LIMIT_BREAKAWAY_OK 的情况下创建的。 随后 terminatePtyJob 会报告 terminated,但子进程仍会继续运行——这个孤儿进程会一直占用一个 worktree 目录。

该拒绝逻辑已经存在于 config/patches/node-pty@1.1.0.patch (usesCygwinRuntime,在 #19068 中添加)。本页记录其背后的测量结果,因为它所防止的故障模式与过时的原生插件无法区分,而当时的门禁也无法将二者区分开来。

机制

MSYS/Cygwin 运行时在其 spawn/exec 路径中的 CreateProcessW 上请求 CREATE_BREAKAWAY_FROM_JOB。带有 JOB_OBJECT_LIMIT_BREAKAWAY_OK 的作业会授予该请求,因此子进程会在作业之外创建;没有该限制的作业会以 ERROR_ACCESS_DENIED 拒绝它,而 运行时会在不设置该标志的情况下重试,而不是让 spawn 失败。fork 不受影响——通过 fork 创建的 Cygwin 进程无论如何都会留在作业中。

在 Windows 11 10.0.26200.9168、Git 2.55.0.windows.3、 bash 5.3.15(1)-release、node v24.18.0、useConptyDll: true 下,针对 node-pty.spawn('C:\Program Files\Git\bin\bash.exe', ['--noprofile','--norc','-i']) 进行了测量——+J / -J 表示是否属于每 PTY 作业,通过 QueryInformationJobObject(JobObjectBasicProcessIdList) 读取:

bin\bash.exe            +J   ConPTY shell (assigned by node-pty)
 └ ..\usr\bin\bash.exe  +J   launcher hand-off, plain CreateProcess
    └ usr\bin\bash.exe  +J   Cygwin fork for the typed command
       └ node.exe       -J   Cygwin exec -- ESCAPES HERE

bin\bash.exe 是一个 47 KB 的启动器,而不是 MSYS 二进制文件:C:\Program Files\Git\bin 只包含 bash.exe、git.exe 和 sh.exe,没有 msys-2.0.dll。它向 bin\..\usr\bin\bash.exe 的交接是一次普通的 CreateProcess,并保持作业成员身份。只有 MSYS 运行时自身的 spawn 才会脱离作业。

shell 替换形式(bash -c 'exec "$BASH" --noprofile --norc -i')会提前一步失去成员身份,即在 exec 处失去,其下方的所有内容都会继承这种丢失:

bin\bash.exe            +J
 └ ..\usr\bin\bash.exe  +J
    └ usr\bin\bash.exe  -J   Cygwin exec -- ESCAPES HERE
       └ usr\bin\bash   -J
          └ node.exe    -J

两种形式都会泄漏。exec 不是原因;它只是让逃逸提前发生。

用于固定该问题的 A/B 测试

一个源码树、一个工具链、一个变量——将 usesCygwinRuntime 强制设为 false,使每 PTY 作业保留 JOB_OBJECT_LIMIT_BREAKAWAY_OK:

每 PTY 作业限制 listPtyJobProcessIds 由 terminatePtyJob 回收的子进程 运行次数
BREAKAWAY_OK 已设置 2 个 pid,子进程不存在 否 0/2
BREAKAWAY_OK 已清除 5 个 pid,子进程存在 是 4/4

作业就是正确的边界。在拒绝脱离后,它会保留整个 MSYS 进程树,包括已从控制台脱离的子进程,一次 terminateJob 就能回收全部。不需要其他跟踪机制。

拒绝脱离并没有破坏从窗格进行的普通启动:git、 cmd //c、绝对路径的 node、使用 disown 的 & 后台作业,以及 where.exe 都返回 0,没有 Access is denied,与允许脱离的对照组完全一致。未测试:非 Cygwin 程序自身传递 CREATE_BREAKAWAY_FROM_JOB(安装程序、更新程序),因此没有运行时可以为它重试。这需要调用 CreateProcess 并带上该标志的辅助程序; start /b 不会触发它(它使用 CREATE_NEW_CONSOLE)。

过时的插件看起来与缺陷完全一样

config/scripts/node-pty-job-ownership.cjs 过去只断言 terminateJob、listJobProcessIds 和 assignCurrentProcessToJob 已被导出。这三者都早于 #19068,因此在它之前构建的 conpty.node 能通过所有门禁:isPtyJobOwnershipAvailable() 返回 true, windows-pty-job.win32.test.ts 通过 6/6,而 windows-msys-job.win32.test.ts 因一个包含两个 pid 的作业列表而失败,这看起来像是源码缺陷,而不是构建新鲜度问题。

当该测试失败时,先检查二进制文件,再检查代码:

// UTF-16LE, because usesCygwinRuntime holds the literals
readFileSync(conptyNodePath).includes(Buffer.from('msys-2.0.dll', 'utf16le'))

False 表示该插件早于修复;请从打过补丁的源码重新构建 node-pty。注意,与其主检出共享 node_modules 的 git worktree 也会共享该检出的 build/Release/conpty.node,因此将 源码 固定到某个提交并不会固定 插件。

该门禁会断言该标记,就像 src/main/windows/windows-process-table.ts 中的 stagedRelayAddonIsUnpatched() 已经通过二进制导入名称来嗅探打过补丁的插件一样。符号存在性无法区分补丁版本;标记可以。

由于该标记是 conpty.cc 中的一个字面量,而门禁中的副本是一个单独的常量, ensure-native-runtime-job-ownership.test.mjs 会断言补丁仍然向该文件添加 L"msys-2.0.dll"。如果没有这一点,编辑补丁会使门禁变成永久误报,导致每个正确重建的插件都失败,并告诉开发者去做那件无济于事的事。

加载器可能落入的所有路径

loadNativeModule 会先尝试 build/Release,然后 build/Debug,再然后 prebuilds/win32-<arch>,每个路径都相对于 node-pty 根目录,然后相对于 lib/,并吞掉中间的所有失败。加载错误架构的 .node 就是这些失败之一,因此实际运行的候选项是目标架构能够真正加载的第一个候选项。发布的 prebuild 总是最后一个候选项,并且从不携带补丁:

包 build/Release prebuild 是否已裁剪? 应用加载的内容
相同主机、相同架构 已打补丁 是 build/Release
包 build/Release 预构建是否已清理 应用加载的内容
跨主机 缺失,无法交叉构建 否 预构建
跨架构,已构建 已修补,目标架构 是 build/Release
跨架构,失败 主机架构 否 预构建

beforeBuild 会运行 rebuild-native-deps.mjs --platform=win32 --arch=<target>,因此跨架构切片通常确实会为获得针对目标修补后的 build/Release——第三行是一个正确的包。prunePackagedNodePty 会提出与加载器相同的问题,它读取 build/Release 的 PE machine,而不是比较 electronArch 与 process.arch,因此第三行残留的预构建会被移除。若以主机架构作为判断依据,则会保留它:正常情况下不会到达,但它仍然是当 build/Release 因无关原因加载失败时加载器会采用的二进制文件——例如防病毒软件隔离、缺少依赖项——而这正是整个门禁旨在消除的静默回退。第二行和第四行保留预构建,因为它是目标端唯一可能加载的内容。在 Windows 11 x64 上使用 VS 2022 ARM64 交叉工具集实测:node-gyp rebuild --arch=arm64 确实会生成 machine 为 0xaa64 的 conpty.node,因此第三行是一种真实的包形态——但截至本文撰写时,没有任何发布版本会生成它,因为 electron-builder --win 运行时未指定架构,只打包 x64。

校验器仍然不以存在性作为判断依据:它检查的是清理步骤,而 build/Debug 永远不会被清理,因此,一个未标记的文件位于正确的 build/Release 旁边,本身无法区分第三行和第四行;如果因其中一个而失败,就会拒绝一个正确的包,并给出其构建者无法据此采取行动的提示。verifyPackagedConptyBreakawayMarker 改为以加载器的方式解析附加组件——第一个 PE IMAGE_FILE_HEADER 的 machine 与目标匹配的候选项——并检查该候选项上的标记。完全没有候选项,或没有目标架构的包会被拒绝:它没有可加载的 ConPTY 后端。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 orca48/orca