Merge remote-tracking branch 'origin/master' into fix/windows-coverage-align-linux

This commit is contained in:
Chinesezjc 2026-08-31 14:23:32 +08:00
commit faeb4e2218
14 changed files with 1171 additions and 50 deletions

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md
2026-07-06-parallel-pre-push-gates.md: 22d69478f0fe664b91c4ada2c5c97e7c61ee7deb
2026-07-06-parallel-pre-push-gates.zh.md: 98de527688399b8f6c09e91f55916361bf79d12d
2026-07-06-parallel-pre-push-gates.md: 54fb01f03de1d0d198e373d960e9bd68b8687d60
2026-07-06-parallel-pre-push-gates.zh.md: d7a949af649d3cf83da91358015f9196f71bc459

View file

@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-07-06-parallel-pre-push-gates.zh.md)
The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands.
The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands. The scheduler's fail-fast option is recorded in [Gate-runner fail-fast](2026-08-27-gate-runner-fail-fast.md).
## Problem

View file

@ -4,7 +4,7 @@ Status: implemented
[English](2026-07-06-parallel-pre-push-gates.md) | 中文
本记录中的本地钩子部分已由[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.zh.md) 取代。有界门禁调度器和包级 `publint` 并行机制仍用于 CI、`doc-sync` 和显式本地命令。
本记录中的本地钩子部分已由[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.zh.md) 取代。有界门禁调度器和包级 `publint` 并行机制仍用于 CI、`doc-sync` 和显式本地命令。调度器的快速失败选项记录在[门禁运行器快速失败](2026-08-27-gate-runner-fail-fast.zh.md)。
## 问题

View file

@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-27-gate-runner-fail-fast.md
2026-08-27-gate-runner-fail-fast.md: b11e3336aad01d2d4bf57c132e1aeaddfac5c258
2026-08-27-gate-runner-fail-fast.zh.md: 93e1b0314289afa17e89afea19c8b8c0563362f4

View file

@ -0,0 +1,37 @@
# Agent Note: Gate-runner fail-fast
Status: implemented
English | [中文](2026-08-27-gate-runner-fail-fast.zh.md)
[Parallel pre-push gates](2026-07-06-parallel-pre-push-gates.md) owns the bounded gate scheduler in `scripts/run-gates.ts`; this note adds one scheduling option to that scheduler.
## Problem
The gate scheduler in `scripts/run-gates.ts` runs every independent gate in an aggregate to completion and reports `run-gates: N passed, M failed`. A gate failure does not stop the remaining gates; only gates whose `needs` dependency failed are skipped. On an aggregate that is already red, the remaining gates keep consuming runner time and produce evidence that cannot change the verdict. The largest single cost is the instrumented coverage run in the `ci-coverage` aggregate, which has taken about 27 minutes; in `ci-consumers`, the Node compatibility smoke runs independently of the build, so it keeps running after a build failure that already settles the verdict.
GitHub Actions provides no native cross-job cancellation: `fail-fast` applies only inside a matrix, and the `all checks passed` aggregate settles only after every needed job finishes, so it cannot cancel siblings early. The only in-repository lever is the gate scheduler itself.
## Decision
`run-gates.ts` accepts a fail-fast scheduling option. When enabled, the first blocking gate failure (a gate whose `allowFailure` is not true) aborts the aggregate: the shared `AbortSignal` terminates every running gate's process tree, and every not-yet-run gate is recorded as `skipped` with the error `aborted by fail-fast: <label> failed` (or `aborted by fail-fast: host interruption` when the host signal aborted the run). The exit status remains 1, including when a killed child traps the signal and exits zero: such a result carries the abort mark and is recorded `skipped`, never passed. A gate that settled before the abort took effect keeps its real result, so the summary stays truthful about what produced evidence.
Termination covers the whole tree, not just the direct pnpm wrapper: POSIX signals the detached child's process group (`kill(-pid, SIGTERM)`, escalating unconditionally to `SIGKILL` after 5 seconds) and additionally signals every transitive descendant read from the process table, so the detached leaves of a nested run-gates (the `check:node-compat` and `check:ci:lint:contracts-ready` gates inside `ci-consumers`) are killed without relying on the inner scheduler's own escalation. The descendant list is primed at spawn and refreshed every 5 seconds while the child runs, each tick merging the fresh enumeration into the live-filtered cache (the enumeration is asynchronous — a slow WMI/CIM call is bounded by its own 10-second timeout and never blocks the gate's output draining or exit handling; an enumeration still in flight when the gate settles is cancelled rather than left holding stdio handles, and a snapshot that settles after the child exited is still merged, because the child may be gone while a grandchild keeps `close` pending — exactly when the abort needs the list) so a descendant reparented by an exited intermediate stays tracked across ticks; at abort the fresh enumeration is merged into the same list, then re-signalled on the escalation, because the group kill reaps the direct child and reparents its detached descendants, making them unreachable by parent id afterwards; settlement waits until the group and the captured descendants are gone. On the abort path only, a bounded pipe-drain timer force-closes the stdio streams 10 seconds after the abort, so a descendant holding the write ends (uninterruptible I/O included) cannot keep `close` pending to the job timeout; ordinary runs keep waiting rather than report passed over a live leak. Windows runs `taskkill /PID <pid> /T /F` immediately, because a taskkill without `/F` does not terminate console processes, which is what gate commands are; the same process-table enumeration as POSIX supplies a descendant list there, and each captured descendant is also taskkilled, because a `taskkill /T` rooted at a pid that already exited finds nothing. Windows never reparents, so an exited root's descendants keep it as their parent and remain reachable through the table; the same sampler cadence as POSIX keeps the cache crossing a vanished intermediate's table record (the enumeration is bounded by a 10-second PowerShell timeout so a hung WMI/CIM call cannot stall the abort path). Without this, Windows has no signal forwarding and a wrapper-only kill would orphan the script tree on the shared self-hosted pool. Children are detached into their own POSIX process group only when fail-fast is enabled; ordinary runs keep them in the host group so terminal Ctrl+C still reaches them. Host `SIGINT`/`SIGTERM` on a fail-fast run is forwarded to the abort path, so an interrupted or runner-cancelled run drains and kills its gate trees instead of orphaning them.
The option is enabled through `DSH_GATE_FAIL_FAST` (accepted values: `1` or unset; anything else fails loud through the existing `flagEnabled` contract) on every run-gates aggregate job in `ci.yml`: the three blocking Linux jobs (`node-24` static, `node-24-coverage`, `node-24-consumers`), the Node compatibility matrix (`node-compat`), and the two native Windows lanes that drive aggregates (`windows-build`, `windows-coverage`). `scripts/ci-workflow.spec.ts` pins the flag on those jobs and pins its absence on `windows-observational`, so removing it fails the CI gate.
The `windows-observational` lane stays complete: it is `continue-on-error` by design and exists to collect as much Windows-native evidence per run as possible, so the first failure must not truncate the rest. The `windows` Wine lane and `windows-native-tests` run a single script or Vitest command rather than a run-gates aggregate, so the scheduler option does not apply to them. The master serial standby lanes (`serial-linux-selfhosted`, `serial-windows`) and the manual runner benchmarks do not set the flag: they are completeness drills that must execute the full aggregate to prove pool readiness.
## Consequences
A red pull-request run ends sooner. The largest saving is in `ci-coverage`: a failing exempt-heavy gate aborts the multi-minute instrumented coverage gate instead of letting it run out.
The trade-off is diagnostic: one push returns only the first blocking failure instead of the full failure set, so resolving several independent failures may take more push-fix rounds. Killed gates are recorded as `skipped` with the fail-fast error, so the summary line `N passed, M failed, K skipped` remains truthful about what produced evidence and what did not. A gate that ignores `SIGTERM` is force-killed after the 5-second grace. A tree that survives both signals holds the aggregate only while its direct child's stdio stays open; once `close` fires, the group-liveness poll gives up after 8 seconds and the run settles with a loud `gate tree not quiescent` warning instead of reporting a clean tree.
## Alternatives considered
**Cross-job cancellation watchdog.** A job that polls sibling conclusions and calls the run-cancel API would stop all lanes on the first failure. It is not native, adds a polling dependency and token surface, and discards the parallel evidence other jobs have already produced. Rejected; fail-fast at the scheduler is orthogonal to the job topology and carries none of that.
**Consolidating the three Linux jobs into one check.** A single job could fail fast natively, but it would lose the independent runner allocation whose queue-delay overlap is documented in the [independent CI consumer build](2026-07-30-independent-ci-consumer-build.md) note, and it would make the coverage long tail the tail of the whole job. Rejected; fail-fast applies within the existing job split instead.
**Signaling only the direct child.** The first implementation sent `SIGTERM` to the pnpm wrapper and relied on pnpm forwarding it to the script child. A probe confirmed the forwarding on POSIX. Rejected: Windows has no signal forwarding, and a wrapper-only kill orphans the script tree on the shared self-hosted pool; the tree termination above covers both platforms.

View file

@ -0,0 +1,37 @@
# Agent Note: 门禁运行器快速失败
Status: implemented
[English](2026-08-27-gate-runner-fail-fast.md) | 中文
[并行推送前门禁](2026-07-06-parallel-pre-push-gates.zh.md)拥有 `scripts/run-gates.ts` 中有界的门禁调度器;本笔记为该调度器新增一个调度选项。
## 问题
`scripts/run-gates.ts` 中的门禁调度器会把一个聚合流程里的每个独立门禁都跑完,然后报告 `run-gates: N passed, M failed`。某个门禁失败并不会停止其余门禁;只有 `needs` 依赖失败的门禁会被跳过。当聚合流程已经确定失败时,剩余门禁仍在消耗运行器时间,产出的证据也无法改变结论。最大的单项成本是 `ci-coverage` 聚合流程里的插桩覆盖率运行,实测约 27 分钟;在 `ci-consumers` 中,Node 兼容性冒烟检查与构建相互独立,因此它会在构建失败、结论已定的情况下继续运行。
GitHub Actions 不提供原生的跨作业取消:`fail-fast` 只作用于矩阵内部,而 `all checks passed` 聚合流程要等所有必需作业结束后才落定,因此它无法提前取消兄弟作业。仓库内唯一可用的杠杆就是门禁调度器本身。
## 决策
`run-gates.ts` 接受一个快速失败调度选项。启用后,首个阻塞性门禁失败(`allowFailure` 不为 true 的门禁)即中止整个聚合流程:共享的 `AbortSignal` 终止每个运行中门禁的整个进程树,所有尚未运行的门禁记录为 `skipped`,错误信息为 `aborted by fail-fast: <label> failed`(当宿主信号中止运行时为 `aborted by fail-fast: host interruption`)。退出状态保持为 1——即使被终止的子进程捕获信号并以 0 退出,这类结果也带有中止标记、记为 `skipped`,绝不记为通过。在中止生效前已结算的门禁保留其真实结果,因此汇总行仍如实反映哪些门禁产出了证据。
终止覆盖整棵进程树,而不只是直接的 pnpm 包装进程:POSIX 向分离子进程所在的进程组发信号(`kill(-pid, SIGTERM)`,5 秒后无条件升级为 `SIGKILL`),并额外按进程表逐个信号所有传递性后代——嵌套 run-gates(`ci-consumers` 里的 `check:node-compat` 与 `check:ci:lint:contracts-ready` 门禁)分离出去的叶子进程因此不依赖内层调度器自己的升级也能被杀掉。后代列表在 spawn 时预填、随后在子进程运行期间每 5 秒刷新(枚举是异步的——慢的 WMI/CIM 调用以自身 10 秒超时封顶,绝不会阻塞门禁的输出排空或退出处理;门禁结算时仍在途的枚举会被取消,而不是留着持有 stdio 句柄;子进程退出后才结算的快照仍会被合并,因为子进程可能已消失而孙进程仍持有 `close` 未触发——这正是中止需要该列表的时刻),每个采样 tick 把新枚举结果并入存活过滤后的缓存,被已退出中间进程 reparent 的后代因此跨 tick 仍被跟踪;中止时把新枚举结果并入同一列表,随后在升级时再次信号:组杀会重收直接子进程并 reparent 其分离后代,之后按父 id 不可达,因此结算要等进程组与已捕获后代都消失。仅在中止路径上,有界 pipe-drain 定时器会在中止后 10 秒强制关闭 stdio 流,因此持有写端的后代进程(包括不可中断 I/O)无法让 `close` 一直挂到作业超时;普通运行保持等待,而不是在有存活泄漏时报告通过。Windows 立即运行 `taskkill /PID <pid> /T /F`,因为不带 `/F` 的 taskkill 无法终止控制台进程,而门禁命令正是控制台进程;与 POSIX 相同的进程表枚举在 Windows 上也提供后代列表,每个捕获的后代同样被 taskkill——因为以已退出 pid 为根执行 `taskkill /T` 找不到任何东西,而 Windows 从不 reparent,已退出根的子孙仍以它为父、通过进程表可达;与 POSIX 相同的采样节奏让缓存跨过已消失中间进程的进程表记录(枚举以 10 秒 PowerShell 超时封顶,挂起的 WMI/CIM 调用不会拖住中止路径)。没有这一步,Windows 上不存在信号转发,只杀包装进程会在共享自托管池上留下孤儿脚本树。只有在启用快速失败时,子进程才会分离到自己的 POSIX 进程组;普通运行保持子进程在宿主进程组内,终端 Ctrl+C 仍能送达它们。快速失败运行上的宿主 `SIGINT`/`SIGTERM` 会转发到中止路径,因此被中断或被运行器取消的运行会排干并杀死自己的门禁进程树,而不是留下孤儿。
该选项通过 `DSH_GATE_FAIL_FAST` 开启(可接受值:`1` 或未设置;其它值通过既有的 `flagEnabled` 契约响亮失败),作用于 `ci.yml` 中所有由 run-gates 驱动的聚合作业:三个阻塞性 Linux 作业(`node-24` 静态、`node-24-coverage`、`node-24-consumers`)、Node 兼容性矩阵(`node-compat`)以及两个驱动聚合流程的原生 Windows 车道(`windows-build`、`windows-coverage`)。`scripts/ci-workflow.spec.ts` 固定了这些作业上的该标志,并固定 `windows-observational` 上不存在该标志,移除它会令 CI 门禁失败。
`windows-observational` 车道保持完整执行:它按设计是 `continue-on-error`,存在的意义是每次运行尽量收集 Windows 原生证据,因此首个失败不应截断其余部分。`windows` Wine 车道和 `windows-native-tests` 运行的是单个脚本或 Vitest 命令,不是 run-gates 聚合流程,因此调度选项不适用于它们。master 串行备用车道(`serial-linux-selfhosted`、`serial-windows`)和手动运行器基准测试不设置该标志:它们是完整性演练,必须执行完整聚合流程以证明池的可用性。
## 结果
红色拉取请求运行会更早结束。最大节省在 `ci-coverage`:失败的豁免重型门禁会中止多分钟的插桩覆盖率门禁,而不是让它跑完。
代价在诊断侧:一次推送只返回第一条阻塞性失败,而不是完整失败集,因此解决多个独立失败可能需要更多次推送-修复往返。被终止的门禁记录为带快速失败错误的 `skipped`,因此 `N passed, M failed, K skipped` 汇总行仍然如实反映哪些门禁产出了证据、哪些没有。忽略 `SIGTERM` 的门禁会在 5 秒宽限期后被强制终止。同时扛过两种信号仍存活的进程树,只有在直接子进程的 stdio 保持打开时才会拖住聚合流程;一旦 `close` 触发,进程组存活轮询在 8 秒后放弃,运行以响亮的 `gate tree not quiescent` 警告结算,而不是报告一棵干净的树。
## 备选方案
**跨作业取消看门狗。** 一个轮询兄弟作业结论并调用运行取消 API 的作业可以在首个失败时停止所有车道。它不是原生能力,会增加轮询依赖和令牌暴露面,并丢弃其它作业已并行产出的证据。已拒绝;调度器层面的快速失败与作业拓扑正交,不带来上述任何负担。
**把三个 Linux 作业合并成一个检查。** 单个作业可以原生快速失败,但会失去独立的运行器分配——其排队延迟重叠的收益记录在[独立 CI 消费方构建](2026-07-30-independent-ci-consumer-build.zh.md)笔记中——并且会让覆盖率长尾成为整个作业的尾部。已拒绝;快速失败在既有作业拆分内生效即可。
**只向直接子进程发信号。** 第一版实现向 pnpm 包装进程发送 `SIGTERM`,依赖 pnpm 把它转发给脚本子进程。探针确认了 POSIX 上的转发。已拒绝:Windows 没有信号转发,只杀包装进程会在共享自托管池上留下孤儿脚本树;上述整树终止覆盖两个平台。

View file

@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md
2026-08-14-case-insensitive-path-round-trips.md: 5ff03a4fb55fc3dc136b22850f938685ed15a44f
2026-08-14-case-insensitive-path-round-trips.zh.md: 6ad6121653badd3f636dda822a95fa7672b22cc3

View file

@ -0,0 +1,37 @@
# Agent Note: Case-insensitive path round-trips in test expectations
Status: implemented
English | [中文](2026-08-14-case-insensitive-path-round-trips.zh.md)
## Problem
[`packages/session/session-persistence-jsonl/tests/jsonl.spec.ts`](../../../../packages/session/session-persistence-jsonl/tests/jsonl.spec.ts) proves that a relative `root` is resolved before a session is located. It handed the plugin `relative(process.cwd(), absoluteRoot)` and built its expectation from `resolve(absoluteRoot)` — two different starting points for the same directory.
On a case-insensitive filesystem those starting points can disagree on spelling. `path.relative()` on Windows compares case-insensitively and returns a path with the shared prefix removed, so the casing of that prefix is gone; `path.resolve()` then rebuilds it from `process.cwd()`. When the prefix `tmpdir()` and `process.cwd()` share is spelled with different casing in each, the plugin's resolved root carries the `cwd` spelling while the expectation carries the `tmpdir()` spelling, and `toEqual` compares two strings that name the same file.
A host reaches that state when `tmpdir()` and `process.cwd()` share a path prefix but spell it differently — for example when `TMP` is mapped into the runner work tree under one spelling while the workspace path uses another. Sharing the tree is not enough on its own: if both spell the prefix alike, the round-trip returns the same string. The case fails there and passes everywhere else, which reads as a flake rather than as a fixed disagreement between two spellings.
## Decision
The expectation resolves the same relative root the plugin receives. Both sides pass through one `resolve(cwd, relative)` call, so the case-insensitive round-trip cannot place two spellings on the two sides of the comparison.
This is a test-only change. The platform treats both spellings as the same file, so storage behaviour does not depend on which spelling `resolve()` produces. The string itself stays observable: hook payloads carry it as `transcript_path` and the shell contributor exports it as `DSH_SESSION_JSONL`, so a consumer that compares those strings can still see the difference. Composition fixtures such as [`apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml`](../../../../apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml) set a relative session root — but the plugin resolves whatever it receives before use, so a relative root reaches disk as one spelling rather than two.
The case still asserts what it names: with the plugin's `resolve(config.root)` reduced to `config.root`, so a relative root is no longer resolved, the case fails.
The neighbouring decision about constructing paths with the host `node:path` API lives in [cross-platform test fixtures](2026-07-22-cross-platform-test-fixtures.md); this note covers a different mechanism, the `relative()`/`resolve()` round-trip under a case-insensitive filesystem.
## Alternatives considered
**Compare the two paths case-insensitively.** This keeps the assertion green on the affected runners but accepts a real configuration disagreement as normal, and it would spread to every future path assertion rather than staying in the one case that round-trips through `relative()`.
**Re-register the runners so `workFolder` matches the directory casing.** That repairs the underlying inconsistency, but `.runner` also carries the agent identity, pool, and server URLs, so hand-editing it risks a registration mismatch, and the test would remain fragile for any other host whose temp directory and working directory disagree on casing.
**Normalize through `realpath()` in the expectation.** `realpath()` returns the on-disk casing, which is the `cwd` spelling here, so the case would pass; it also resolves symlinks, which changes what the assertion covers on hosts where the temp directory is a link.
## Consequences
The relative-root case now depends on `resolve()` alone rather than on the two spellings agreeing, so it passes on hosts whose temp directory and working directory disagree on casing. The underlying runner registration is untouched: a `workFolder` whose spelling differs from the directory on disk stays that way, so any future assertion that compares a `tmpdir()`-derived absolute path against a `cwd`-derived one will meet the same disagreement.
The changed case passes, and the whole `session-persistence-jsonl` suite passes at 242 cases. The mechanism was reproduced away from Windows with `path.win32`: `relative()` on two differently-cased spellings of one directory returns a prefix-free relative path, `resolve()` rebuilds it from the `cwd` spelling, and the two absolute strings differ; with both sides spelled alike the same code matches. The regression check above — removing `resolve()` from the plugin — turns the case red while the fixture root and the working directory share a drive letter. Across drives `relative()` returns an absolute path, so both spellings already agree and the check cannot go red; the fixture roots come from `tmpdir()`, so the check only goes red where that path and the working directory share a drive.

View file

@ -0,0 +1,37 @@
# Agent Note: 测试期望值里的大小写不敏感路径往返
Status: implemented
[English](2026-08-14-case-insensitive-path-round-trips.md) | 中文
## 问题
[`packages/session/session-persistence-jsonl/tests/jsonl.spec.ts`](../../../../packages/session/session-persistence-jsonl/tests/jsonl.spec.ts) 有一条用例验证「定位 session 之前会先解析相对 `root`」。它传给插件的是 `relative(process.cwd(), absoluteRoot)`,而期望值由 `resolve(absoluteRoot)` 算出——同一个目录、两个不同的起点。
在大小写不敏感的文件系统上,这两个起点的拼写可能不一致。Windows 的 `path.relative()` 按大小写不敏感比较,返回的是去掉公共前缀之后的相对路径,前缀的大小写信息随之丢失;随后 `path.resolve()` 用 `process.cwd()` 重新拼出前缀。当 `tmpdir()` 与 `process.cwd()` 共有的那段前缀在两者中拼写大小写不同时,插件解析出的 root 带的是 `cwd` 那种拼写,而期望值带的是 `tmpdir()` 那种拼写,于是 `toEqual` 比较的是指向同一个文件的两个字符串。
当 `tmpdir()` 与 `process.cwd()` 共享一段路径前缀、但两者对它的拼写不同时,主机就处在这个状态——例如把 `TMP` 以一种拼写映射进 runner 工作树、而 workspace 路径用另一种拼写。仅仅落在同一目录树内并不够:若两者的前缀拼写相同,往返会得到同一个字符串。该用例只在那里失败、别处都通过,看起来像 flake,实际是两种拼写之间一个固定的分歧。
## 决定
期望值改为解析「插件实际收到的那个相对 root」。两侧都经过同一次 `resolve(cwd, relative)`,大小写不敏感的往返就不可能把两种拼写分别放到比较的两边。
这是只改测试的变更。平台把两种拼写视为同一个文件,所以存储行为不依赖 `resolve()` 产出哪种拼写。字符串本身仍可被观察到:hook 载荷以 `transcript_path` 携带它,shell 贡献者以 `DSH_SESSION_JSONL` 导出它,因此比较这些字符串的消费方仍能看出差异。组合 fixture(测试前置数据)如 [`apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml`](../../../../apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml) 就设置了相对的会话 root——但插件会先解析收到的值再使用,因此相对 root 落盘时只有一种拼写而非两种。
该用例仍然在验证它声称的东西:把插件的 `resolve(config.root)` 降级成 `config.root`(即不再解析相对 root)后,用例转红。
关于「用宿主的 `node:path` API 构造路径」这一相邻决策,归属的 note 是[跨平台测试前置数据](2026-07-22-cross-platform-test-fixtures.zh.md);本 note 讲的是另一个机制——大小写不敏感文件系统上 `relative()`/`resolve()` 的往返。
## 考虑过的替代方案
**按大小写不敏感的方式比较两个路径。** 这能让受影响的 runner 上变绿,但等于把一个真实的配置分歧当成正常状态接受;而且这种写法会扩散到之后每一条路径断言,而不是留在唯一经由 `relative()` 往返的这一条里。
**重新注册 runner,让 `workFolder` 与目录大小写一致。** 这修的是底层的不一致,但 `.runner` 里同时存着 runner 的注册身份、pool 与 server URL,手工编辑有造成注册失配的风险;而且只要有别的宿主机的临时目录与工作目录大小写不一致,这条用例仍然是脆的。
**在期望值里用 `realpath()` 归一化。** `realpath()` 返回磁盘上的真实大小写,在这里就是 `cwd` 那种拼写,用例会通过;但它同时会解析符号链接,在临时目录本身是链接的宿主机上会改变该断言覆盖的内容。
## 后果
相对 root 那条用例现在只依赖 `resolve()` 本身,不再依赖两种拼写是否一致,因此在临时目录与工作目录大小写不一致的宿主机上也能通过。runner 注册本身未被改动:注册拼写与磁盘目录名不一致的状态会保持下去,所以今后任何拿 `tmpdir()` 派生的绝对路径去和 `cwd` 派生路径比较的断言,都会遇到同一个分歧。
改动后的用例通过,`session-persistence-jsonl` 整套 242 条用例通过。机制在非 Windows 环境用 `path.win32` 复现过:对同一目录的两种不同大小写拼写调用 `relative()` 会得到不含前缀的相对路径,`resolve()` 用 `cwd` 那种拼写重建,两个绝对字符串因此不同;把两侧拼写改成一致后,同一段代码即匹配。上面那条回归检查——把插件的 `resolve()` 去掉——在 fixture(测试前置数据)根与工作目录同盘符时会让用例转红。跨盘符时 `relative()` 返回绝对路径,两种拼写本就相同,该检查无法转红;本文件的 fixture 根来自 `tmpdir()`,所以只有该路径与工作目录同盘时该检查才会转红。

View file

@ -46,6 +46,9 @@ jobs:
name: node 24 / static
env:
DSH_GATE_CONCURRENCY: '8'
# Stop the aggregate at the first blocking gate failure so a red run
# does not keep burning enterprise runner time on the remaining gates.
DSH_GATE_FAIL_FAST: '1'
steps:
# Fetch complete history so the archive gate can read the trusted PR base from a reused shallow checkout.
- uses: actions/checkout@v6
@ -102,6 +105,9 @@ jobs:
DSH_COVERAGE_MAX_WORKERS: '6'
DSH_COVERAGE_PARTITIONS: '4'
DSH_GATE_CONCURRENCY: '3'
# A gate failure aborts the sibling gate instead of waiting out its
# multi-minute instrumented run.
DSH_GATE_FAIL_FAST: '1'
steps:
- uses: actions/checkout@v6
with:
@ -164,6 +170,10 @@ jobs:
DSH_OXLINT_THREADS: '8'
DSH_PUBLINT_CONCURRENCY: '8'
DSH_WEB_SNAPSHOT_WORKERS: '6'
# A failing gate aborts its running siblings: a failing build stops the
# independent Node compatibility smoke, and a failing reader (e.g.
# publint) stops the remaining artifact consumers.
DSH_GATE_FAIL_FAST: '1'
# Failover halves snapshot concurrency for the shared 64-core VM.
DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }}
steps:
@ -244,6 +254,9 @@ jobs:
env:
DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }}
DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
# A failed smoke aborts the remaining compatibility gates instead of
# letting the build-backed legs run against an already-red aggregate.
DSH_GATE_FAIL_FAST: '1'
strategy:
fail-fast: false
matrix:
@ -425,6 +438,9 @@ jobs:
|| 'dsh-windows-2025-16core' }}
name: windows node 24 / build
timeout-minutes: 60
env:
# A failing build or site aborts the sibling gate on the same runner.
DSH_GATE_FAIL_FAST: '1'
steps:
- uses: actions/checkout@v6
with:
@ -471,6 +487,9 @@ jobs:
DSH_COVERAGE_PARTITIONS: '4'
DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'
DSH_GATE_CONCURRENCY: '3'
# A gate failure aborts the sibling gate instead of waiting out its
# multi-minute instrumented run.
DSH_GATE_FAIL_FAST: '1'
steps:
- uses: actions/checkout@v6
with:

View file

@ -171,17 +171,22 @@ describe('JsonlSessionPersistence: format helpers', () => {
it('resolves a relative custom root before locating a session', async () => {
const absoluteRoot = await freshRoot()
// Resolve the same relative root the plugin receives, not the original absolute
// path, so both sides of the comparison pass through one `resolve()` call. See
// .agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md
// for why an absolute root can arrive under a different casing.
const relativeRoot = relative(process.cwd(), absoluteRoot)
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(JsonlSessionPersistence, {
root: relative(process.cwd(), absoluteRoot),
root: relativeRoot,
compression: 'none',
writeBatchMaxDelayMs: 1,
})
const m = meta('relative-location', '/work')
expect(ctx.sessionPersistence.locate(m)).toEqual({
kind: 'jsonl',
path: rawLogPath(resolve(absoluteRoot), '/work', m.id),
path: rawLogPath(resolve(relativeRoot), '/work', m.id),
})
await fiber.dispose()
})

View file

@ -67,11 +67,12 @@ describe('CI workflow', () => {
|| !isRecord(workflow.jobs['node-24'])
|| !isRecord(workflow.jobs['node-24-coverage'])
|| !isRecord(workflow.jobs['node-24-consumers'])
|| !isRecord(workflow.jobs['node-compat'])
|| !isRecord(workflow.jobs['all-checks-passed'])
|| !isRecord(masterWorkflow.jobs)
|| !isRecord(masterWorkflow.jobs['wine-apt-cache'])
|| !isRecord(masterWorkflow.jobs['serial-windows'])) {
throw new TypeError('CI workflow must define windows, windows-build, windows-coverage, windows-native-tests, windows-observational, node-24, node-24-coverage, node-24-consumers, and all-checks-passed; ci-master must define wine-apt-cache and serial-windows')
throw new TypeError('CI workflow must define windows, windows-build, windows-coverage, windows-native-tests, windows-observational, node-24, node-24-coverage, node-24-consumers, node-compat, and all-checks-passed; ci-master must define wine-apt-cache and serial-windows')
}
const windows = workflow.jobs.windows
@ -84,6 +85,7 @@ describe('CI workflow', () => {
const node24 = workflow.jobs['node-24']
const node24Coverage = workflow.jobs['node-24-coverage']
const node24Consumers = workflow.jobs['node-24-consumers']
const nodeCompat = workflow.jobs['node-compat']
const aggregate = workflow.jobs['all-checks-passed']
if (!Array.isArray(windows.steps) || !Array.isArray(aggregate.needs)) {
throw new TypeError('Windows job must define steps and the aggregate must define needs')
@ -230,6 +232,26 @@ describe('CI workflow', () => {
expect(aggregate['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
expect(aggregate['runs-on']).not.toContain('DSH_CI_FAILOVER_WINDOWS')
expect(aggregate['runs-on']).toContain('vm-backup')
// The run-gates aggregate lanes stop at the first blocking gate failure so
// a red aggregate does not keep burning runner time on the remaining
// gates. Removing the flag silently reverts to running every independent
// gate to completion.
for (const [jobName, job] of [['node-24', node24], ['node-24-coverage', node24Coverage], ['node-24-consumers', node24Consumers], ['node-compat', nodeCompat]] as const) {
expect(job.env, `${jobName} must enable fail-fast`).toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
}
// The native Windows lanes with run-gates aggregates fail fast for the
// same reason: a failing gate aborts the sibling gate instead of waiting
// out the multi-minute instrumented coverage run.
expect(windowsBuild.env, 'windows-build must enable fail-fast').toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
expect(windowsCoverage.env, 'windows-coverage must enable fail-fast').toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
// The observational lane stays complete: it is continue-on-error by design
// and exists to collect as much Windows-native evidence per run as
// possible, so the first failure must not truncate the rest.
expect(windowsObservational.env).toBeDefined()
expect(windowsObservational.env).not.toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
})
it('gives the Wine Host TypeScript compile the repository heap budget', () => {

View file

@ -1,14 +1,94 @@
import { describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { describe, expect, it, vi, type MockInstance } from 'vitest'
import {
cliGateOptions,
defaultConcurrency,
formatGateResultReason,
gatesForMode,
parsePidPpidLines,
runGate,
runGates,
taskkillArgs,
type Gate,
type GateResult,
} from './run-gates.ts'
/**
* Capture output a gate streams through runGate's streamOutput path.
* @returns the accumulated chunks and the stdout spy to restore in finally.
*/
function captureStreamedOutput(): { writes: string[]; write: MockInstance } {
const writes: string[] = []
const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writes.push(String(chunk))
return true
})
return { writes, write }
}
/**
* A process has stopped executing when its /proc entry is gone, or when it
* lingers as a zombie ('Z') — an un-reaped but dead entry still answers
* kill(pid, 0), so existence is not a liveness check. Non-Linux falls back to
* kill(pid, 0), whose ESRCH means the process is gone.
*/
function procStopped(pid: number): boolean {
if (process.platform === 'linux') {
try {
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
return /\)\s+Z\s/.test(stat)
} catch {
return true
}
}
try {
process.kill(pid, 0)
return false
} catch {
return true
}
}
/**
* Wait until the captured output contains `marker` and the grandchild pid the
* gate printed, then return that pid.
* @param writes - chunks captured from the gate's streamed stdout.
* @param marker - the output line that proves the gate reached the abort point.
* @param deadline - fail the wait when exceeded.
* @returns the grandchild pid printed by the gate script.
*/
async function waitForGrandchildPid(writes: string[], marker: string, deadline: number): Promise<number> {
let pid: number | undefined
while ((pid === undefined || !writes.join('').includes(marker)) && Date.now() < deadline) {
const match = writes.join('').match(/grandchild:(\d+)/)
if (match !== null) pid = Number(match[1])
await new Promise(resolve => setTimeout(resolve, 50))
}
expect(pid ?? 0).toBeGreaterThan(0)
expect(writes.join('')).toContain(marker)
return pid!
}
/**
* Abort the run and assert it settles marked aborted with the grandchild no
* longer executing — the abort path must have signalled it from the captured
* descendant list rather than settling over a live orphan.
* @param promise - the pending `runGate` promise.
* @param controller - the signal source to abort.
* @param pid - the grandchild pid the gate script printed.
*/
async function abortAndExpectTreeStopped(promise: Promise<GateResult>, controller: AbortController, pid: number): Promise<void> {
controller.abort()
const result = await promise
expect(result.aborted).toBe(true)
const stopDeadline = Date.now() + 8000
while (!procStopped(pid) && Date.now() < stopDeadline) {
await new Promise(resolve => setTimeout(resolve, 50))
}
expect(procStopped(pid)).toBe(true)
}
function gate(id: string, options: Partial<Gate> = {}): Gate {
return {
id,
@ -294,7 +374,7 @@ describe('gate graph validation', () => {
const results = await runGates([dependent, root], 1, execute)
expect(execute).toHaveBeenCalledOnce()
expect(execute).toHaveBeenCalledWith(root)
expect(execute).toHaveBeenCalledWith(root, undefined)
expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' })
})
@ -529,3 +609,300 @@ describe('gate process outcomes', () => {
expect(formatGateResultReason(result)).toBe('signal SIGTERM')
})
})
describe('fail-fast scheduling', () => {
it('aborts the aggregate at the first blocking failure', async () => {
const slow = gate('slow')
const fast = gate('fast')
const dependent = gate('dependent', { needs: ['slow'] })
const execute = vi.fn(async (subject: Gate, signal?: AbortSignal) => {
if (subject.id === 'fast') {
return new Promise<GateResult>((resolve) => {
signal?.addEventListener('abort', () => {
// The real runGate marks a gate the abort terminated; the drain
// must then record it skipped rather than keep the failure.
resolve({ ...resultFor(subject, 'failed'), aborted: true })
}, { once: true })
})
}
return resultFor(subject, subject.id === 'slow' ? 'failed' : 'passed')
})
const results = await runGates([slow, fast, dependent], 2, execute, () => {}, { failFast: true })
expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['slow', 'fast'])
expect(results.map(result => result.status)).toEqual(['failed', 'skipped', 'skipped'])
expect(results[1]).toMatchObject({
status: 'skipped',
error: 'aborted by fail-fast: slow failed',
})
expect(results[2]).toMatchObject({
status: 'skipped',
error: 'aborted by fail-fast: slow failed',
})
})
it('does not abort on a non-blocking gate failure', async () => {
const observational = gate('observational', { allowFailure: true })
const root = gate('root')
const execute = vi.fn(async (subject: Gate) => (
resultFor(subject, subject.id === 'observational' ? 'failed' : 'passed')
))
const results = await runGates([observational, root], 2, execute, () => {}, { failFast: true })
expect(execute).toHaveBeenCalledTimes(2)
expect(results.map(result => result.status)).toEqual(['failed', 'passed'])
})
it('runs independent gates to completion when fail-fast is disabled', async () => {
const root = gate('root')
const sibling = gate('sibling')
const execute = vi.fn(async (subject: Gate) => (
resultFor(subject, subject.id === 'root' ? 'failed' : 'passed')
))
const results = await runGates([root, sibling], 2, execute, () => {}, { failFast: false })
expect(execute).toHaveBeenCalledTimes(2)
expect(results.map(result => result.status)).toEqual(['failed', 'passed'])
})
it('kills the child when the abort signal fires', async () => {
const controller = new AbortController()
const promise = runGate(gate('killable', { args: ['-e', 'setInterval(() => {}, 1000)'] }), controller.signal)
controller.abort()
const result = await promise
expect(result.status).toBe('failed')
expect(result.aborted).toBe(true)
if (process.platform !== 'win32') expect(result.signalCode).toBe('SIGTERM')
})
it.skipIf(process.platform === 'win32')('marks a zero-exit child as aborted when the signal fired', async () => {
const { writes, write } = captureStreamedOutput()
try {
const controller = new AbortController()
const child = gate('traps-signal', {
args: ['-e', "process.stdout.write('ready\\n'); process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 1000)"],
streamOutput: true,
})
const promise = runGate(child, controller.signal)
// Wait for the child to register its SIGTERM trap before aborting, so
// the signal is caught and the child really exits zero.
const deadline = Date.now() + 5000
while (!writes.join('').includes('ready') && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 10))
}
controller.abort()
const result = await promise
// The child trapped the signal and exited zero; the drain must not
// report this gate passed, so the raw outcome carries the abort mark.
expect(result.status).toBe('passed')
expect(result.aborted).toBe(true)
} finally {
write.mockRestore()
}
})
it.skipIf(process.platform === 'win32')('kills the whole gate process tree when the abort signal fires', async () => {
const { writes, write } = captureStreamedOutput()
const controller = new AbortController()
let promise: Promise<GateResult> | undefined
try {
const script = [
"const { spawn } = require('node:child_process')",
// Detached, so the grandchild leads its own process group: the gate
// group signal cannot reach it, and only the descendant enumeration in
// treeKill does — the shape of a nested run-gates' leaf gates.
"const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true })",
"process.stdout.write('grandchild:' + grandchild.pid + '\\n')",
'setInterval(() => {}, 1000)',
].join(';')
promise = runGate(gate('tree', { args: ['-e', script], streamOutput: true }), controller.signal)
const deadline = Date.now() + 5000
let pid: number | undefined
while (pid === undefined && Date.now() < deadline) {
const match = writes.join('').match(/grandchild:(\d+)/)
if (match !== null) pid = Number(match[1])
else await new Promise(resolve => setTimeout(resolve, 20))
}
expect(pid ?? 0).toBeGreaterThan(0)
controller.abort()
const result = await promise
expect(result.status).toBe('failed')
// The descendant enumeration signals the detached grandchild at the same
// time as the group signal reaches the direct child; the direct child's
// own death closes the gate pipes, so poll for the grandchild to stop
// executing rather than asserting on a fixed instant.
const stopDeadline = Date.now() + 5000
while (!procStopped(pid!) && Date.now() < stopDeadline) {
await new Promise(resolve => setTimeout(resolve, 20))
}
expect(procStopped(pid!)).toBe(true)
} finally {
// A failed wait or assertion must not leave the forever-looping detached
// grandchild behind on the host: abort the gate and wait for the
// process tree to settle before restoring the spy.
controller.abort()
await promise
write.mockRestore()
}
})
it('forwards host interruption signals to the abort path', async () => {
const slow = gate('slow')
const sibling = gate('sibling')
const execute = vi.fn(async (subject: Gate, signal?: AbortSignal) => {
if (subject.id === 'slow') {
return new Promise<GateResult>((resolve) => {
signal?.addEventListener('abort', () => {
// A child can trap the signal and exit zero; the drain must still
// record the gate skipped so the interrupted run fails.
resolve({ ...resultFor(subject, 'passed'), aborted: true })
}, { once: true })
})
}
return resultFor(subject)
})
const promise = runGates([slow, sibling], 1, execute, () => {}, { failFast: true, forwardProcessSignals: true })
// The first loop iteration starts `slow` synchronously, so its abort
// listener is registered before the signal is emitted.
process.emit('SIGTERM')
const results = await promise
expect(execute).toHaveBeenCalledOnce()
expect(results.map(result => result.status)).toEqual(['skipped', 'skipped'])
expect(results[0]).toMatchObject({
status: 'skipped',
error: 'aborted by fail-fast: host interruption',
})
})
it('pairs host signal forwarding with fail-fast at the CLI entrypoint', () => {
expect(cliGateOptions(true)).toEqual({ failFast: true, forwardProcessSignals: true })
expect(cliGateOptions(false)).toEqual({ failFast: false, forwardProcessSignals: false })
})
it('rejects host signal forwarding without fail-fast', async () => {
const execute = vi.fn(async (subject: Gate) => resultFor(subject))
await expect(runGates([gate('subject')], 1, execute, () => {}, { forwardProcessSignals: true }))
.rejects.toThrow('forwardProcessSignals requires failFast')
expect(execute).not.toHaveBeenCalled()
})
it('leaves an un-aborted child running to completion', async () => {
const result = await runGate(gate('settles', { args: ['-e', ''] }), new AbortController().signal)
expect(result.status).toBe('passed')
expect(result.aborted).toBe(false)
})
it.skipIf(process.platform === 'win32')('kills a detached descendant that outlived the child when the abort arrives later', async () => {
const writes: string[] = []
const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writes.push(String(chunk))
return true
})
const controller = new AbortController()
let promise: Promise<GateResult> | undefined
try {
const script = [
"const { spawn } = require('node:child_process')",
// Detached with inherited stdio: the grandchild leads its own process
// group (the gate group signal misses it) and holds the gate's
// stdout write end (so `close` stays pending past the child exit).
"const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'inherit' })",
"process.stdout.write('grandchild:' + grandchild.pid + '\\n')",
// Outlive the first descendant-sampler tick with margin so the cache
// holds the grandchild even on a loaded runner, then exit normally
// before the abort arrives.
"setTimeout(() => { process.stdout.write('child-exit\\n'); process.exit(0) }, 8000)",
].join(';')
promise = runGate(gate('late-abort', { args: ['-e', script], streamOutput: true }), controller.signal)
const pid = await waitForGrandchildPid(writes, 'child-exit', Date.now() + 10000)
// terminate must not re-enumerate over the sampler cache now that the
// child is gone; the detached grandchild is killed from the cached list.
await abortAndExpectTreeStopped(promise, controller, pid)
} finally {
// A failed wait or assertion must not leave the forever-looping detached
// grandchild behind on the host: abort the gate and wait for the
// process tree to settle before restoring the spy.
controller.abort()
await promise
write.mockRestore()
}
}, 20000)
it.skipIf(process.platform === 'win32')('keeps a reparented detached descendant tracked across a sampler tick', async () => {
const { writes, write } = captureStreamedOutput()
const controller = new AbortController()
let promise: Promise<GateResult> | undefined
try {
const script = [
"const { spawn } = require('node:child_process')",
// Wrapper spawns a detached grandchild with inherited stdio (its own
// process group, holding the gate's stdout write end), prints the pid,
// then exits after 7 seconds — after the first sampler tick, before
// the second. From then on the grandchild is reparented and
// unreachable by parent id.
"const wrapper = spawn(process.execPath, ['-e', \"const { spawn } = require('node:child_process'); const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'inherit' }); process.stdout.write('grandchild:' + grandchild.pid + '\\\\n'); setTimeout(() => process.exit(0), 7000)\"], { stdio: 'inherit' })",
"wrapper.on('exit', () => process.stdout.write('wrapper-exited\\n'))",
// Keep the root child alive past the abort with a heartbeat so the
// test can abort while it is still running.
"setInterval(() => process.stdout.write('hb\\n'), 1000)",
].join(';')
promise = runGate(gate('sampler-merge', { args: ['-e', script], streamOutput: true }), controller.signal)
const pid = await waitForGrandchildPid(writes, 'wrapper-exited', Date.now() + 15000)
// Wait past the second sampler tick (t=10) with margin: a replacing tick
// would drop the reparented grandchild from the cache, after which the
// abort cannot reach it. The root child keeps running throughout.
const tickDeadline = Date.now() + 10000
const wrapperExitedAt = Date.now()
while (Date.now() - wrapperExitedAt < 5000 && Date.now() < tickDeadline) {
await new Promise(resolve => setTimeout(resolve, 50))
}
expect(Date.now() - wrapperExitedAt).toBeGreaterThanOrEqual(5000)
await abortAndExpectTreeStopped(promise, controller, pid)
} finally {
// A failed wait or assertion must not leave the forever-looping detached
// grandchild behind on the host: abort the gate and wait for the
// process tree to settle before restoring the spy.
controller.abort()
await promise
write.mockRestore()
}
}, 30000)
})
describe('process-table parsing', () => {
it('parses `pid ppid` rows from a POSIX ps dump', () => {
expect(parsePidPpidLines(' 123 1\n456 123\n 789 456\n')).toEqual([[123, 1], [456, 123], [789, 456]])
})
it('parses Windows PowerShell Get-CimInstance output of the same shape', () => {
expect(parsePidPpidLines(' 123 1\r\n456 123\r\n')).toEqual([[123, 1], [456, 123]])
})
it('drops blank and malformed lines', () => {
expect(parsePidPpidLines(' 123 1\n\ncommand not found\n999 abc\n')).toEqual([[123, 1]])
})
})
describe('Windows tree termination', () => {
it('targets the root first and each captured descendant after it', () => {
expect(taskkillArgs(100, [201, 302, 403])).toEqual([
['/PID', '100', '/T', '/F'],
['/PID', '201', '/T', '/F'],
['/PID', '302', '/T', '/F'],
['/PID', '403', '/T', '/F'],
])
})
it('terminates the root alone when no descendant was captured', () => {
expect(taskkillArgs(100, [])).toEqual([['/PID', '100', '/T', '/F']])
})
})

View file

@ -5,7 +5,8 @@
* dependency graphs, scheduler environment, and process diagnostics.
* @see ../.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md
*/
import { spawn } from 'node:child_process'
import { spawn, spawnSync } from 'node:child_process'
import { readdirSync, readFileSync } from 'node:fs'
import { availableParallelism } from 'node:os'
import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
@ -69,6 +70,10 @@ export interface GateResult {
exitCode: number | null
signalCode: NodeJS.Signals | null
error?: string
/** True when the shared abort signal terminated this gate before its outcome
* was observed; such a result must not be reported as passed, even if the
* child trapped the signal and exited zero. */
aborted?: boolean
}
interface GateOutputChunk {
@ -86,7 +91,7 @@ interface ConcurrencyDefault {
source: string
}
type GateExecutor = (gate: Gate) => Promise<GateResult>
type GateExecutor = (gate: Gate, signal?: AbortSignal) => Promise<GateResult>
type ResultObserver = (result: GateResult) => void
const root = resolve(import.meta.dirname, '..')
@ -103,16 +108,28 @@ async function main(args: string[]): Promise<number> {
const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === ''
? concurrencyDefault.source
: '$DSH_GATE_CONCURRENCY'
const failFast = flagEnabled('DSH_GATE_FAIL_FAST')
const startedAt = performance.now()
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`)
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}${failFast ? ', fail-fast after first blocking failure' : ''}.`)
const results = await runGates(gates, maxConcurrency, runGate, printResult)
const results = await runGates(gates, maxConcurrency, runGate, printResult, cliGateOptions(failFast))
printSummary(results, performance.now() - startedAt)
return results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))
? 1
: 0
}
/**
* The options the CLI entrypoint hands to the scheduler. Host signal
* forwarding always follows fail-fast: children are detached only then, so
* without it the forwarding would have no tree to drain.
* @param failFast - whether `DSH_GATE_FAIL_FAST` is enabled.
* @returns the scheduler options for the entrypoint.
*/
export function cliGateOptions(failFast: boolean): RunGatesOptions {
return { failFast, forwardProcessSignals: failFast }
}
function parseMode(raw: string | undefined): Mode {
switch (raw) {
case 'ci-primary':
@ -836,12 +853,30 @@ function findDependencyCycle(gates: readonly Gate[]): string[] | undefined {
return undefined
}
/**
* Scheduling options for one aggregate.
*/
export interface RunGatesOptions {
/** Stop the aggregate at the first blocking gate failure. */
failFast?: boolean
/** Forward host SIGINT/SIGTERM to the abort path so detached gate trees are
* terminated when the run itself is interrupted or the runner cancels it.
* Tree termination additionally requires failFast, because only then is the
* abort signal passed to the executor and children detached. */
forwardProcessSignals?: boolean
}
/**
* Validate and run one aggregate before the injected executor can start a child.
* @param gates - complete aggregate to execute.
* @param maxActive - maximum concurrent child count.
* @param execute - child-process executor.
* @param execute - child-process executor; receives the abort signal only when
* fail-fast is enabled, so ordinary runs keep their children in the host
* process group.
* @param observe - result observer invoked when each gate settles.
* @param options - scheduling options; fail-fast aborts the aggregate at the
* first blocking gate failure by killing running children and skipping every
* not-yet-run gate.
* @returns results in aggregate order.
*/
export async function runGates(
@ -849,54 +884,105 @@ export async function runGates(
maxActive: number,
execute: GateExecutor,
observe: ResultObserver = () => {},
options: RunGatesOptions = {},
): Promise<GateResult[]> {
validateGateGraph(gates)
if (!Number.isSafeInteger(maxActive) || maxActive < 1) {
throw new Error(`run-gates: max concurrency must be a positive integer, got ${JSON.stringify(maxActive)}.`)
}
if (options.forwardProcessSignals === true && options.failFast !== true) {
throw new Error('run-gates: forwardProcessSignals requires failFast, otherwise no child is detached or killed.')
}
const states = new Map<string, GateState>(gates.map(gate => [gate.id, 'pending']))
const results = new Map<string, GateResult>()
const running: RunningGate[] = []
for (;;) {
let madeProgress = false
while (running.length < maxActive) {
const ready = gates.find(gate => states.get(gate.id) === 'pending' && predecessorsReady(gate, states))
if (ready === undefined) break
states.set(ready.id, 'running')
running.push({ gate: ready, promise: execute(ready) })
console.log(`run-gates: start ${ready.label}`)
madeProgress = true
const abort = new AbortController()
let abortCause: string | undefined
// Host interruption (terminal Ctrl+C, runner cancellation) drains through
// the same abort path as a gate failure, so detached trees are killed and
// never orphaned. Handlers are removed before returning.
const hostSignals = options.forwardProcessSignals === true ? ['SIGINT', 'SIGTERM'] as const : []
const hostHandlers = hostSignals.map((name) => {
const handler = () => {
abortCause = abortCause ?? 'host interruption'
abort.abort()
}
process.on(name, handler)
return { name, handler }
})
const failFastSignal = options.failFast === true ? abort.signal : undefined
if (running.length === 0) {
const pending = gates.filter(gate => states.get(gate.id) === 'pending')
if (pending.length === 0) break
const gate = pending.find(item => (item.needs ?? []).some(id => gateFailed(states.get(id))))
if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.')
const failedDeps = (gate.needs ?? []).filter(id => gateFailed(states.get(id)))
const result: GateResult = {
gate,
status: 'skipped',
durationMs: 0,
output: [],
exitCode: null,
signalCode: null,
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
try {
for (;;) {
let madeProgress = false
if (abortCause === undefined) {
while (running.length < maxActive) {
const ready = gates.find(gate => states.get(gate.id) === 'pending' && predecessorsReady(gate, states))
if (ready === undefined) break
states.set(ready.id, 'running')
running.push({ gate: ready, promise: execute(ready, failFastSignal) })
console.log(`run-gates: start ${ready.label}`)
madeProgress = true
}
}
states.set(gate.id, 'skipped')
results.set(gate.id, result)
observe(result)
continue
}
if (!madeProgress) {
const settled = await Promise.race(running.map(async item => ({ item, result: await item.promise })))
running.splice(running.indexOf(settled.item), 1)
states.set(settled.item.gate.id, settled.result.status)
results.set(settled.item.gate.id, settled.result)
observe(settled.result)
if (running.length === 0) {
if (abortCause !== undefined) {
for (const gate of gates) {
if (states.get(gate.id) !== 'pending') continue
const skipped = skippedByFailFast(gate, abortCause)
states.set(gate.id, 'skipped')
results.set(gate.id, skipped)
observe(skipped)
}
break
}
const pending = gates.filter(gate => states.get(gate.id) === 'pending')
if (pending.length === 0) break
const gate = pending.find(item => (item.needs ?? []).some(id => gateFailed(states.get(id))))
if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.')
const failedDeps = (gate.needs ?? []).filter(id => gateFailed(states.get(id)))
const result: GateResult = {
gate,
status: 'skipped',
durationMs: 0,
output: [],
exitCode: null,
signalCode: null,
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
}
states.set(gate.id, 'skipped')
results.set(gate.id, result)
observe(result)
continue
}
if (!madeProgress) {
const settled = await Promise.race(running.map(async item => ({ item, result: await item.promise })))
running.splice(running.indexOf(settled.item), 1)
const observed = abortCause === undefined || settled.result.aborted !== true
? settled.result
: skippedByFailFast(settled.item.gate, abortCause)
states.set(settled.item.gate.id, observed.status)
results.set(settled.item.gate.id, observed)
observe(observed)
if (abortCause === undefined && options.failFast === true
&& observed.status === 'failed' && settled.item.gate.allowFailure !== true) {
abortCause = `${observed.gate.label} failed`
abort.abort()
console.error(`run-gates: fail-fast aborting: ${abortCause}.`)
for (const gate of gates) {
if (states.get(gate.id) !== 'pending') continue
const skipped = skippedByFailFast(gate, abortCause)
states.set(gate.id, 'skipped')
results.set(gate.id, skipped)
observe(skipped)
}
}
}
}
} finally {
for (const { name, handler } of hostHandlers) process.removeListener(name, handler)
}
return gates.map((gate) => {
@ -906,6 +992,31 @@ export async function runGates(
})
}
/**
* The result of a gate that produced no evidence because fail-fast aborted.
* A gate whose process settled before the abort took effect keeps its real
* result instead: it did produce evidence, and the summary must say so. Any
* result settling after the abort — including a genuine independent failure
* in the race window, and a child that trapped the signal and exited zero —
* is recorded skipped with its partial output discarded, because on Windows a
* killed process is indistinguishable from a failed one by exit code alone.
* @param gate - the gate that produced no evidence.
* @param cause - the full clause naming what aborted the aggregate, e.g.
* `typecheck failed` or `host interruption`.
* @returns the skipped record with the fail-fast error.
*/
function skippedByFailFast(gate: Gate, cause: string): GateResult {
return {
gate,
status: 'skipped',
durationMs: 0,
output: [],
exitCode: null,
signalCode: null,
error: `aborted by fail-fast: ${cause}`,
}
}
function predecessorsReady(gate: Gate, states: Map<string, GateState>): boolean {
return (gate.needs ?? []).every(id => states.get(id) === 'passed')
&& (gate.after ?? []).every(id => gateSettled(states.get(id)))
@ -922,12 +1033,17 @@ function gateFailed(state: GateState | undefined): boolean {
/**
* Execute one gate through the real shell-free child-process boundary.
* @param gate - command and scheduler environment to execute.
* @param signal - abort signal that terminates the whole gate process tree when
* the aggregate fails fast; an already-aborted signal terminates it
* immediately. A provided signal spawns the child detached so POSIX can signal
* its process group and Windows can reach its tree through taskkill.
* @returns the complete process outcome.
*/
export async function runGate(gate: Gate): Promise<GateResult> {
export async function runGate(gate: Gate, signal?: AbortSignal): Promise<GateResult> {
const started = performance.now()
const output: GateOutputChunk[] = []
let spawnError: string | undefined
let aborted = false
const outcome = await new Promise<{
exitCode: number | null
@ -937,6 +1053,7 @@ export async function runGate(gate: Gate): Promise<GateResult> {
cwd: root,
env: { ...process.env, ...gate.env },
stdio: ['pipe', 'pipe', 'pipe'],
detached: signal !== undefined && process.platform !== 'win32',
})
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
@ -948,11 +1065,187 @@ export async function runGate(gate: Gate): Promise<GateResult> {
if (gate.streamOutput === true) process.stderr.write(chunk)
else output.push({ stream: 'stderr', text: chunk })
})
// Deliver one signal to the entire gate tree: the negative pid targets the
// POSIX process group the detached child leads; Windows has no groups, so
// taskkill walks the tree rooted at the child and force-terminates (a
// taskkill without `/F` does not terminate console processes, which is
// what gate commands are). Outcomes are deliberately unchecked because
// delivery races tree exit, and a missing taskkill binary is as tolerable
// as ESRCH. Mirrors the subprocess package's teardown contract
// (packages/subprocess/subprocess-local/src/spawn.ts).
const treeKill = (signalToSend: 'SIGTERM' | 'SIGKILL') => {
const pid = child.pid
if (pid === undefined) return
if (process.platform === 'win32') {
for (const args of taskkillArgs(pid, descendants)) {
spawnSync('taskkill', args, { stdio: 'ignore' })
}
return
}
try {
process.kill(-pid, signalToSend)
} catch {
// The group is gone; the direct child may still be alive alone.
child.kill(signalToSend)
}
// The captured list stays valid after the group kill reparents the
// detached descendants of a nested run-gates (the `check:node-compat`
// and `check:ci:lint:contracts-ready` gates in ci-consumers): pids do
// not change on reparenting, so the escalation reaches leaves that
// ignored SIGTERM without re-enumerating.
for (const descendantPid of descendants) {
try {
process.kill(descendantPid, signalToSend)
} catch {
// The descendant exited between the enumeration and the signal.
}
}
}
let escalation: ReturnType<typeof setTimeout> | undefined
let terminatedAt = 0
// Captured once at terminate and re-signalled on escalation: the group
// kill reaps the direct child, after which its detached descendants are
// reparented and unreachable by parent id, so the escalation cannot
// re-enumerate them.
let descendants: number[] = []
let pipeDrain: ReturnType<typeof setTimeout> | undefined
const terminate = () => {
aborted = true
const pid = child.pid
// Merge while the child is still alive: re-enumerating alone would drop
// a descendant that an exited intermediate reparented out of the parent
// chain, and replacing the list entirely would lose the sampler's
// last-known entries when the child already exited. Union preserves both.
// The sampler runs on every platform (including Windows, where an
// exited intermediate's table record vanishes and a fresh enumeration
// cannot cross the gap), so the cache is the source of truth once the
// child is gone.
if (pid !== undefined && child.exitCode === null && child.signalCode === null) {
descendants = [...new Set([...descendants, ...descendantPids(pid)])]
}
treeKill('SIGTERM')
if (escalation === undefined) {
terminatedAt = Date.now()
// Force-kill at the deadline regardless of the direct child's exit
// state: when the wrapper dies but a grandchild ignores SIGTERM and
// still holds the stdio pipes, `close` has not fired and the tree must
// still be killed. treeKill swallows an already-absent group.
escalation = setTimeout(() => { treeKill('SIGKILL') }, 5000)
}
if (pipeDrain === undefined) {
// `close` can stay pending past the direct child's exit when a
// descendant holds the stdio write ends (escaped process group, or
// uninterruptible I/O that keeps the SIGKILL pending). Bound the wait
// past the 5-second SIGKILL grace and force the streams closed so
// fail-fast settles instead of hanging to the job timeout. Only the
// abort path arms it: on an ordinary run a gate that outlives its
// descendants must keep waiting rather than report passed over a live
// leak. Armed in terminate (not only at `exit`) so the window where
// the child already exited before the abort is covered too.
pipeDrain = setTimeout(() => {
child.stdout.destroy()
child.stderr.destroy()
child.stdin.destroy()
}, 10000)
}
}
if (signal !== undefined) {
if (signal.aborted) terminate()
else signal.addEventListener('abort', terminate, { once: true })
}
// Refresh the descendant cache while the child runs, so an abort that
// arrives after the child already exited can still reach a detached
// descendant the child left behind: once the child is gone, its
// descendants are reparented (POSIX) or their intermediate's table record
// is gone (Windows), so a fresh enumeration cannot cross the gap. The
// cache is primed at spawn and refreshed every 5 seconds, so a descendant
// is captured once it appears in any enumeration whose parent chain is
// still fully present in the table; the residual window is a descendant
// that never appears in such a snapshot — created after one enumeration
// and orphaned before the next. Enumeration is asynchronous (a slow
// WMI/CIM call is bounded by its own 10-second timeout), so a gate's
// output draining and exit handling are never blocked while the sampler
// reads the process table. Fail-fast runs only; ordinary runs never
// abort.
let descendantSampler: ReturnType<typeof setInterval> | undefined
if (signal !== undefined) {
let enumerationInFlight: { cancel: () => void } | undefined
const refreshDescendants = () => {
const pid = child.pid
if (pid === undefined || child.exitCode !== null || child.signalCode !== null) return
if (enumerationInFlight !== undefined) return
const handle = descendantPidsAsync(pid, process.platform)
enumerationInFlight = handle
void handle.promise.then((fresh) => {
if (enumerationInFlight === handle) enumerationInFlight = undefined
// Merge regardless of the child's exit state: the enumeration
// started while the child was alive, so its snapshot is the last
// reliable view of the tree. The child may exit (its intermediate
// gone, its table record vanished) before the promise settles while
// a grandchild still holds the stdio write ends and keeps `close`
// pending — exactly when terminate needs this list.
// Merge instead of replacing, like terminate: an intermediate that
// exited since the last tick reparented its detached descendants
// out of the parent chain, so a fresh enumeration alone would drop
// them. Filter the cache to the still-executing so a long gate
// does not accumulate stale pids; while sampler ticks still run the
// live filter also keeps the escalation from signalling a reused
// pid, but once ticks stop (child exited) the cache can go stale,
// and a pid reused after that is the accepted sampling window.
descendants = [...new Set([...descendants.filter(processAlive), ...fresh])]
})
}
const cancelInFlightEnumeration = () => {
if (enumerationInFlight !== undefined) enumerationInFlight.cancel()
enumerationInFlight = undefined
}
refreshDescendants()
descendantSampler = setInterval(refreshDescendants, 5000)
// A gate that settles while an enumeration is still running must not
// leave the PowerShell subprocess holding stdio handles until its own
// timeout: stop it as soon as the child's outcome is known.
child.once('close', cancelInFlightEnumeration)
child.once('error', cancelInFlightEnumeration)
}
child.on('error', (error) => {
if (escalation !== undefined) clearTimeout(escalation)
if (pipeDrain !== undefined) clearTimeout(pipeDrain)
if (descendantSampler !== undefined) clearInterval(descendantSampler)
if (signal !== undefined) signal.removeEventListener('abort', terminate)
spawnError = `failed to start command: ${error.message}`
resolveExit({ exitCode: null, signalCode: null })
})
child.on('close', (exitCode, signalCode) => {
if (pipeDrain !== undefined) clearTimeout(pipeDrain)
if (descendantSampler !== undefined) clearInterval(descendantSampler)
if (signal !== undefined) signal.removeEventListener('abort', terminate)
if (escalation !== undefined && process.platform !== 'win32') {
// `close` only means the direct child's stdio closed; a grandchild
// that ignored SIGTERM and redirected its stdio can outlive it. Do
// not settle until the process group and the captured descendants are
// confirmed gone — the deadline SIGKILL covers members still alive at
// the grace end — so runGate returns only once the tree is quiescent.
const confirmGroupGone = () => {
if (!groupAlive(child.pid) && descendants.every(descendantPid => !processAlive(descendantPid))) {
clearTimeout(escalation)
resolveExit({ exitCode, signalCode })
return
}
if (Date.now() - terminatedAt < 8000) {
setTimeout(confirmGroupGone, 50)
return
}
// The grace ended with members still alive (e.g. uninterruptible
// I/O that even SIGKILL cannot cut). Fail loud instead of reporting
// a quiescent tree: the gate is recorded failed either way.
console.error(`run-gates: gate tree not quiescent after 8s (${gate.label}).`)
clearTimeout(escalation)
resolveExit({ exitCode, signalCode })
}
confirmGroupGone()
return
}
if (escalation !== undefined) clearTimeout(escalation)
resolveExit({ exitCode, signalCode })
})
child.stdin.end()
@ -968,10 +1261,255 @@ export async function runGate(gate: Gate): Promise<GateResult> {
exitCode,
signalCode,
}
result.aborted = aborted
if (spawnError !== undefined) result.error = spawnError
return result
}
/**
* Parse the state, parent, and process-group fields from a `/proc/<pid>/stat`
* line. The comm field may contain spaces and parentheses, so the state starts
* after the last closing parenthesis.
* @param stat - one `/proc/<pid>/stat` line.
* @returns state, parent pid, and process-group pid; undefined when truncated.
*/
function procStatFields(stat: string): { state: string; ppid: number; pgrp: number } | undefined {
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
const state = fields[0]
const ppid = fields[1]
const pgrp = fields[2]
if (state === undefined || ppid === undefined || pgrp === undefined) return undefined
return { state, ppid: Number(ppid), pgrp: Number(pgrp) }
}
/**
* Whether one process is still executing. Zombies (state `Z`) do not count:
* they are dead records awaiting reaping, and kill(pid, 0) would report them
* as alive. Linux reads /proc/<pid>/stat to distinguish; other platforms fall
* back to the signal probe.
* @param pid - the process to probe.
*/
function processAlive(pid: number): boolean {
if (process.platform === 'linux') {
try {
const parsed = procStatFields(readFileSync(`/proc/${pid}/stat`, 'utf8'))
return parsed !== undefined && parsed.state !== 'Z'
} catch {
return false
}
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
/**
* Whether any member of the child's POSIX process group is still executing.
* Zombie entries (state `Z`) do not count: they are dead records awaiting
* reaping, and the kill(-pid, 0) group probe would report them as alive.
* Linux enumerates /proc to distinguish after a fast-path group probe; other
* POSIX platforms fall back to the probe alone.
* @param pid - the group leader's pid; undefined or non-positive means the
* spawn failed and nothing is alive.
*/
function groupAlive(pid: number | undefined): boolean {
if (pid === undefined || pid <= 0) return false
if (process.platform === 'linux') {
try {
process.kill(-pid, 0)
} catch {
// ESRCH: the group has no entries at all.
return false
}
try {
for (const entry of readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue
try {
const parsed = procStatFields(readFileSync(`/proc/${entry}/stat`, 'utf8'))
if (parsed !== undefined && parsed.pgrp === pid && parsed.state !== 'Z') return true
} catch {
// The process exited mid-scan; it is not a live member.
}
}
return false
} catch {
return false
}
}
try {
process.kill(-pid, 0)
return true
} catch {
return false
}
}
/**
* The pids of every transitive descendant of `root`, read from the live
* process table. Linux walks /proc/<pid>/stat parent fields; other platforms
* parse `ps` (POSIX) or the CIM process table (Windows) output. This is one
* snapshot, not the full tree-ownership mechanism: terminate and the sampler
* rely on the 5-second cache to cross an intermediate that exited between
* ticks (reparented on POSIX, table record gone on Windows), so a single
* enumeration reaches only the descendants whose parent chain is still fully
* present in the table.
* @param root - the pid whose descendants are wanted.
* @returns descendant pids in breadth-first order; empty on enumeration failure.
*/
function descendantPids(root: number): number[] {
if (root <= 0) return []
if (process.platform === 'linux') {
const rows: Array<[number, number]> = []
try {
for (const entry of readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue
try {
const parsed = procStatFields(readFileSync(`/proc/${entry}/stat`, 'utf8'))
if (parsed !== undefined) rows.push([Number(entry), parsed.ppid])
} catch {
// The process exited mid-scan; skip it.
}
}
} catch {
return []
}
return collectDescendants(root, rows)
}
let ps: { error?: Error; stdout: string }
if (process.platform === 'win32') {
// taskkill /T covers the tree only while the root is alive; once the
// direct child exits (a descendant still holding the stdio write ends
// keeps `close` pending), abort must reach the survivors from a fresh
// enumeration. Windows keeps the exited parent's pid in its descendants'
// parent column, so this walk still finds the whole tree. A hung
// PowerShell (WMI/CIM service trouble) must not stall the abort path
// indefinitely, so the enumeration is bounded.
ps = spawnSync('powershell', processTableArgs('win32'), { encoding: 'utf8', timeout: 10000 })
} else {
ps = spawnSync('ps', processTableArgs('posix'), { encoding: 'utf8' })
}
if (ps.error !== undefined) return []
return collectDescendants(root, parsePidPpidLines(ps.stdout))
}
/**
* The process-table enumeration command for one platform. Windows queries the
* CIM provider through PowerShell (each line `pid ppid`); other platforms use
* `ps -axo pid=,ppid=`.
* @param platform - the target platform.
* @returns the command arguments to enumerate every live process's pid/ppid.
*/
function processTableArgs(platform: 'win32' | 'posix'): string[] {
if (platform === 'win32') {
return ['-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }']
}
return ['-axo', 'pid=,ppid=']
}
/**
* Asynchronous descendant enumeration, so a slow WMI/CIM call (bounded by a
* 10-second timeout) cannot block the event loop: the sampler runs it while
* the gate's output streams and exit handling must keep flowing. Returns the
* same descendant list as {@link descendantPids}; used by the fail-fast
* sampler only, never on the abort path (which needs the synchronous walk to
* capture the tree before any member exits).
* @param root - the pid whose descendants are wanted.
* @param platform - the platform whose table the enumeration reads.
* @returns a promise of descendant pids in breadth-first order; empty on
* enumeration failure.
*/
function descendantPidsAsync(root: number, platform: NodeJS.Platform): { promise: Promise<number[]>; cancel: () => void } {
if (root <= 0 || platform === 'linux') {
// The /proc walk is synchronous inside the async wrapper so the sampler
// keeps the same contract on every platform; /proc reads are fast and
// need no subprocess, and a completed enumeration needs no cancellation.
return { promise: Promise.resolve(descendantPids(root)), cancel: () => {} }
}
const [command, args] = platform === 'win32'
? ['powershell', processTableArgs('win32')]
: ['ps', processTableArgs('posix')]
const child = spawn(command, args, {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: platform === 'win32' ? 10000 : undefined,
})
child.stdout.setEncoding('utf8')
let stdout = ''
let settled = false
let settle!: (value: number[]) => void
const promise = new Promise<number[]>((resolve) => { settle = resolve })
const finish = (value: number[]) => {
if (settled) return
settled = true
// The enumeration completed (or was cancelled): stop the subprocess so
// the gate does not wait on its stdio handles.
child.kill('SIGTERM')
settle(value)
}
child.stdout.on('data', (chunk: string) => { stdout += chunk })
child.on('error', () => { finish([]) })
child.on('close', () => { finish(collectDescendants(root, parsePidPpidLines(stdout))) })
return {
promise,
cancel: () => { finish([]) },
}
}
/** Parse `pid ppid` rows from a process-table dump. Both the POSIX `ps -axo
* pid=,ppid=` output and the Windows PowerShell `Get-CimInstance Win32_Process`
* projection emit one `pid ppid` pair per line.
* @param output - the raw dump text.
* @returns the parsed pid/ppid rows in line order; blank and malformed lines
* are dropped.
*/
export function parsePidPpidLines(output: string): Array<[number, number]> {
const rows: Array<[number, number]> = []
for (const line of output.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\d+)$/)
if (match !== null) rows.push([Number(match[1]), Number(match[2])])
}
return rows
}
/**
* The taskkill invocations that terminate one Windows gate tree. The direct
* child leads, because a live `taskkill /T` walks its whole subtree in one
* call; each captured descendant follows individually, because when the root
* already exited (a descendant holding the stdio write ends keeps `close`
* pending) `taskkill /T` rooted at the dead pid finds nothing — Windows never
* reparents, so the ppid chain captured at terminate still reaches the whole
* tree, and `/T` lets a surviving intermediate carry its own subtree. A pid
* that exited between capture and termination is as tolerable as ESRCH on
* POSIX: taskkill reports a nonzero status that is deliberately unchecked.
* @param rootPid - the direct child's pid.
* @param descendants - the captured descendant pids.
* @returns one `taskkill` argument list per pid, in termination order.
*/
export function taskkillArgs(rootPid: number, descendants: number[]): string[][] {
return [rootPid, ...descendants].map(pid => ['/PID', String(pid), '/T', '/F'])
}
/** Breadth-first walk of the pid/ppid rows starting at `root`. */
function collectDescendants(root: number, rows: Array<[number, number]>): number[] {
const byParent = new Map<number, number[]>()
for (const [pid, ppid] of rows) {
const children = byParent.get(ppid) ?? []
children.push(pid)
byParent.set(ppid, children)
}
const result: number[] = []
const queue = byParent.get(root) ?? []
for (let index = 0; index < queue.length; index += 1) {
const pid = queue[index]
if (pid === undefined) continue
result.push(pid)
queue.push(...(byParent.get(pid) ?? []))
}
return result
}
/**
* Format every independently observed failure fact for the aggregate summary.
* @param result - unsuccessful gate result.