Skip to content

ci: stall guard 自证仍会触发,并修复从未真正执行的 SIGKILL 升级 (#4250) - #4960

Merged
xuyushun441-sys merged 2 commits into
mainfrom
claude/issue-4250-test-core-stall
Aug 3, 2026
Merged

ci: stall guard 自证仍会触发,并修复从未真正执行的 SIGKILL 升级 (#4250)#4960
xuyushun441-sys merged 2 commits into
mainfrom
claude/issue-4250-test-core-stall

Conversation

@xuyushun441-sys

@xuyushun441-sys xuyushun441-sys commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #4250

先核实前提:本单描述的症状已被覆盖,但覆盖它的那个机制本身没人验证

先读了仓内四个既有 changeset(test-core-stall-guardstall-guard-rollouttemporal-conformance-stall-guardstall-forensics-and-kernel-test-hygiene)和 .github/workflows/ci.ymlTest Core 的现状。结论:

议题原始症状(日志冻结、job 一直 in_progress、等人工取消)现在不会再发生。 Test Core 已分片、job timeout 收到 30 分钟,两个测试步骤都走 run-with-stall-guard.mjs:静默超过 10 分钟就判定停摆,打印最后一行、做取证、杀进程组、退出 75。议题里「建议的最小动作」(给一个显著短于兜底值的 timeout,把停摆变成一条明确的红)不仅已落地,而且被取证机制超额完成 —— 第四、五次停摆就是靠它在 10 分钟内给出带包名的红,几分钟内定位到 driver-mongodb 的无上界下载等待(#4322)。

所以本 PR 不是再加一个 timeout。 剩下没被覆盖的是另一类东西:

run-with-stall-guard.mjs 现在被五个 workflow 的六个 job 依赖,但它只在真实停摆时才执行到有意思的那条路径 —— 一个大约 5-10% 命中率、且本机无法复现的事件。两次真实停摆之间,没有任何东西断言它还有效。

一个从没被触发过的 guard,和一个不存在的 guard 无法区分。一次重构可以悄悄解除 CI 唯一的停摆探测器,而所有 run 依旧全绿 —— 下一次真的卡住时,我们会退回到本议题描述的原始状态,外加一个「已经修好了」的错误信念。

交付物一:--self-test,用合成停摆驱动 guard 自身

pnpm check:stall-guard(接入 lint.yml,约 50 秒,不构建、不联网)。四类人为构造的卡死:

合成用例 断言
idle 挂起(promise 永不 settle) 判 exit 75、判词引用最后一行、分类为 idle、SIGUSR2 取到栈
同步自旋挂起(事件循环被阻塞) 判 exit 75、分类为 ON-CPU没有报告这一沉默被当成判词
一行输出都没有的挂起 仍被抓到(计时从 spawn 起算,不是从首行输出起算)
截获 SIGTERM 的后代 exit 75、后代没有活过 guard、SIGKILL 升级被点名

反方向同样断言:健康的 run 退出 0、失败的套件透传自己的退出码(取代 | tee + pipefail 的那条不变量,回归了的话每个失败套件都会报绿)、持续输出永远不会被误判成停摆。

交付物二:一个真实缺陷 —— SIGKILL 升级从来没有执行过

写 harness 的过程中定位到的,不是猜的。证据链:

原来的形状是 killGroup 先发 SIGTERM,再挂一个 setTimeout(SIGKILL, 10s).unref(),并在直接子进程的 exit 回调里 process.exit()。但直接子进程(pnpmturbo,或 sh)对 SIGTERM 立即死亡,于是 guard 先退出了,那个定时器永远不会 fire。任何截获 SIGTERM 的后代都会活过 guard。

复现(合成一个 sh 父进程 + 一个截获 SIGTERM 的 node 后代):

GUARD EXIT=75 (guard has now exited)
  t=2s: pid 13702 STILL ALIVE state=S
  t=15s: pid 13702 STILL ALIVE state=S
RESULT: descendant survived the guard -- SIGKILL escalation never ran

这不是假想的形状:ObjectKernelConfig.gracefulShutdown 默认 true,在每个测试 boot 的 kernel 里装的正是这样一个 handler —— 本议题自己的根因排查数到一次 objectql 套件运行 boot 47 个 kernel、截获 48 次 SIGTERM。也就是说,最可能泄漏 worker 的停摆,恰好就是这个 guard 存在的理由。

修法:guard 现在自己拥有停摆后的收尾退出时机 —— SIGTERM 之后轮询 /proc 等进程组真正清空,对赖着不走的进程 SIGKILL,并在日志里点名它们(一个卡死拒绝 SIGTERM 的进程,本身就是关于这次卡死的线索)。

怎么验证这些断言真的会红:变异测试

一个只会绿的自检,和它要防的问题是同一个毛病。所以对 guard 逐个注入缺陷,确认 self-test 会红:

注入的缺陷 结果
M1 恢复修复前的收尾(子进程一死就退出) exit=1,2 条红(后代仍在运行 / SIGKILL 升级没被报告)
M2 永不判定停摆 exit=1,15 条红
M3 吞掉子进程退出码 exit=1,failing suite propagates its exit code — got 0
M4 输出不再重置计时 exit=1,steady output past the stall window is not a stall — exit 75
M5 子进程不独立进程组 exit=1,5 条红
M6 关掉取证 exit=1,4 条红

变异测试当场抓到我自己的两个 bug,都已修:

  1. self-test 里 sleep 的 TDZ,绿色路径从没走到过那一行;
  2. 更要紧的一个 —— runGuard 收尾时顺手按 marker 清理残留进程,把 M1 要找的那个泄漏擦掉了(M1 一度只红 1 条而不是 2 条)。清理因此改到全部用例判完之后才做一次。这条教训写进了代码注释。

顺带:M2(探测失效)最初让 self-test 自己挂死了 —— 在验证工具里复现了本议题的病。每个用例因此加了上界超时,超时就是一条带标签的红,并按 marker(本次 mkdtemp 路径,只匹配自己起的进程)回收残留;不按进程名杀,以免误伤并行 agent 的套件。

验证

  • pnpm check:stall-guard:21/21 通过
  • ESLint(--no-inline-config)干净
  • lint.yml / ci.yml YAML 解析通过
  • 生产路径回归:用 CI 的完整参数(--stall-minutes 10--report-dirNODE_OPTIONS)包住真实套件 @objectstack/lint,966 通过 / 4 skipped,guard 退出 0,无误判;下游 check-test-completeness.mjs 读该 log 仍 OK (970 declared, all 970 accounted for)

范围与共享文件

改动三个文件加一个 changeset。共享文件请注意:.github/workflows/lint.yml(在既有 guard 步骤末尾新增一步)和根 package.json(在 check:* 块末尾新增一行)—— 同批有 agent 在 packages/lintpackages/cli 以及一个可能触及 lint.yml 的替身闸门脚本上作业。没有动 ci.yml

这是取证与自检的加固,不是对原始三次停摆的根因修复。 那三次的根因仍未知,#4341 的策略(等下一次真实停摆自己交出栈)依旧成立;本 PR 保证的是那套取证在被需要的时候确实还在工作。

scripts/run-with-stall-guard.mjs 是 Test Core 停摆变成一条明确红的唯一机制,
现已被五个 workflow 的六个 job 依赖。但它的触发路径只在真实停摆时执行 ——
一个罕见且无法按需复现的事件,所以两次停摆之间没有任何东西断言它还有效。
一次重构可以悄悄解除 CI 唯一的停摆探测器,而所有 run 依旧全绿。

新增 --self-test(pnpm check:stall-guard),用合成停摆驱动 guard 自身:
idle 挂起、同步自旋挂起、一行输出都没有的挂起、以及一个截获 SIGTERM 的后代。
断言 exit 75 判词、idle/ON-CPU 分类、SIGUSR2 取栈(含「没有报告 = 事件循环被
阻塞」这一反向推断)、进程组彻底收尾,以及反方向:健康的 run 仍然透传自己的
退出码,持续输出永远不会被误判成停摆。

写这个 harness 的过程中定位到一个真实缺陷,已一并修复:SIGKILL 升级挂在一个
unref 的定时器上,而 guard 在直接子进程的 'exit' 回调里就退出了 —— 直接子进程
(pnpm -> turbo,或 sh)对 SIGTERM 立即死亡,于是定时器永远不会 fire。任何
截获 SIGTERM 的**后代**都会活过 guard,而这正是 ObjectKernelConfig.gracefulShutdown
在每个测试 boot 的 kernel 里安装的形状(#4250 自己的根因排查数到一次 objectql
套件运行 boot 47 个 kernel、截获 48 次 SIGTERM)。现在 guard 会等待进程组真正
清空,再对赖着不走的进程 SIGKILL,并在日志里点名它们。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 3, 2026 7:54pm

Request Review

@xuyushun441-sys
xuyushun441-sys added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit 12fa938 Aug 3, 2026
23 of 24 checks passed
@xuyushun441-sys
xuyushun441-sys deleted the claude/issue-4250-test-core-stall branch August 3, 2026 20:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI: Test Core stalls mid-suite with frozen log output — three occurrences in one day, each costing a manual diagnosis + rerun

2 participants