fix(core): 插件 init/start 超时守卫在 race 落定时被清除 —— 进程不再空转 startupTimeout (#4813) - #4874
Conversation
…ettles (#4813) `initPluginWithTimeout()` and `startPluginWithTimeout()` each armed a `setTimeout` guard and then abandoned it. When the plugin won the race the timer stayed ref'd in the event loop for the full `startupTimeout`, so every process idled that long after its work was finished — 120s for `ObjectQLPlugin`, held open by 8 orphaned guards (4 init + 4 start). Both guards now go through one private `raceStartupTimeout()` helper that clears the timer in a `finally`. Clearing on settle is deliberate rather than `unref()`-ing at arm time: an unref'd guard also stops pinning the loop, but it stops being a guard as well — if the hook never settles and nothing else keeps the loop alive, Node exits before the timer fires and the timeout is silently swallowed. The guard must stay ref'd exactly while the race is undecided. `operation` is typed `T | PromiseLike<T>` because the Plugin contract permits a synchronous hook (`init`/`start` return `void | Promise<void>`). No `startupTimeout` value changed — the problem was never the duration. Measured on examples/app-crm, same build chain, `migrate recorded-by --json`: 122.4s before, 3.1s after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
复核通过 —— ACCEPT⏳ 标 ready + 入队要等 GraphQL 配额重置(约 12:16),届时立即执行。复核结论先行。 1. 它证伪了我在派发文里给的理由,并给出了更硬的一条我写的是:「倾向 dev 验证了,我的理由不成立 ——
这正是本仓一直在关的那类缺陷:一个在最需要它的场景里静默失效的守卫。
2. 测试钉的是可观察后果,而且专门防住了「半修」我要求「给出一条能在修复前失败的测试,并附上修复前的失败输出」。给到的是:
两处设计值得点名:
只测「代码里调了 clearTimeout」是同义反复,这份测试没有落进去。 3. 真实 CLI 的单变量实验为拿干净的 before,把 4. ⭐ 派发时我最担心的那件事,回答得完整我要求核实 #4747 是否已独立修复,理由是:修好本单后 #4747 的现象会因进程提前退出而不再复现,但根因没动 —— 别让一个真实缺陷因时序变了被误认为已解决。 回答:
两件事都说了:结论(安全)与反事实(若顺序反过来会怎样)。第二句才是这条要求真正想要的东西 —— 它把一个只在特定合并顺序下才安全的判断,变成了写下来的知识。 两条范围外发现#4873 —— 它又纠正了 issue 正文的一处归因错误(我写的)。 我把不稳定退出码归给「挂太久被外部杀掉」。实测:修好挂起后没有任何东西杀它、3 秒自行退出,退出码依旧随机(62/169/208/171/176)。同一个 #4875 —— 同一个漏法的第三个实例。 关于那个开放问题(helper 该不该提成共享工具)dev 推荐 A(提到 我同意这个判断,而且它与本 PR 第 1 点是同一个论证:理由比代码更容易丢失。但按 issue 边界,本 PR 正确地没有自行推广 —— 收编范围属 #4875 的裁定,已记录在那里。 Generated by Claude Code |
Fixes #4813
initPluginWithTimeout()/startPluginWithTimeout()各自setTimeoutarmed 一根超时守卫,然后把它扔了。插件赢下 race 之后那根定时器既没clearTimeout也没unref(),带着 ref 一直挂到startupTimeout走完 —— 每个进程在活干完之后还要空转整整一个startupTimeout。两处守卫合并成一个私有 helper
raceStartupTimeout(),在finally里clearTimeout。startupTimeout的取值一个都没动。为什么是
clearTimeout而不是unref()—— 我验证过再决定的issue 提到「
finally { clearTimeout(t) }比unref()更彻底」,并猜测理由是 unref 的定时器仍会触发那个 reject,产生一个无人处理的 rejection。这个猜测是错的,我实测否定了它 ——Promise.race会给每个 arm 都挂上 handler,所以输掉的 timeout promise 后来 reject 时是被处理过的,不会有 unhandledRejection:真正的理由是另一条,而且更硬:
unref()让定时器不再钉住事件循环,同时也让它不再是一个守卫。实际输出:什么都没打印,Node 直接退出(exit 13,
unsettled top-level await)。守卫从来没有触发,超时被静默吞掉。若 hook 永不 settle 且没有别的东西撑着事件循环,unref()的守卫就是个摆设 —— 这正是它要防的那种故障。所以正确语义是:守卫在 race 未决期间保持 ref'd(它必须能触发),在 race 落定的那一刻被回收。
finally { clearTimeout(guard) }恰好表达这个,unref()表达不了。关于「保持同一种写法」:
shutdown()自己那处if (t.unref) t.unref();本 PR 没有动 —— issue 的边界写明只碰这两个守卫,而且改它会在一个边缘情形上改变行为(shutdown 卡死且事件循环空转时,现在静默退出 0,改成 clear 后会触发Shutdown timeout exceeded→process.exit(1))。那是一个独立的判断,不该搭本 PR 的车。理由与取舍写在raceStartupTimeout()的 doc comment 里,下一个读到shutdown()的人能看到两种写法各自的适用面。验收:真实 CLI,同一个构建链,唯一差别是本改动
两次 JSON 与
✅ Graceful shutdown complete都在 ~3 秒出现;后面那 119 秒纯粹是 8 根孤儿定时器钉着事件循环。(为了拿到干净的 before,我把kernel.tsstash 掉、只重建@objectstack/core、跑同一条命令,再还原 —— 所以这一对数字之间没有别的变量。)测试:钉的是「定时器不再把进程钉住」,不是「代码里调了 clearTimeout」
后者是同义反复,任何重构都能满足它而照旧漏定时器。新增 4 条,前 3 条修复前全红:
getActiveResourcesInfo()探针(issue 自己的思路)。它只报告当前正在维持事件循环存活的资源,正是「进程为什么不退出」这个问题的直接答案。1 个插件 ⇒ 修前多出 2 根(init + start);4 个插件 ⇒ 修前多出 8 根,与 issue 里 probe 抓到的8 ref'd timers still alive一模一样。断言的是 delta 为 0,不是绝对值。getActiveResourcesInfo()不计 unref'd 定时器,所以只有它无法分辨「守卫被回收了」和「守卫只是被摘下了循环」;vi.getTimerCount()两者都计,unref()方案过不了这一条。Plugin hanging-plugin init timeout after 50ms)。回收守卫不等于解除守卫 —— 没有这条,「把两个setTimeout删掉」也能让前 3 条变绿。@objectstack/core没有typecheckscript,走check-type-check-coverage.mjs的 DEBT 棘轮,所以我按「改动前/改动后同一条命令」测了两次而不是只看绝对数。中途 helper 的形参一度写成Promise< T >而多出 2 个 TS2345 —— Plugin 契约允许同步 hook(init/start返回void | Promise< void >),已改为T | PromiseLike< T >,回到 baseline。#4747 已经独立修好了(PR #4815,
127f09125,已在 main 上),修的是生命周期契约那一侧:ObjectQLPlugin的关停逻辑从内核根本不调用的stop()挪到destroy(),bootSchemaStack().shutdown()从(runtime as any).stop?.()改走kernel.shutdown()。所以本 PR 不会掩盖 #4747 —— 它已经不是靠时序活着的了。但这件事仍然值得写下来,因为本 PR 确实会让 #4747 的现象即使在未修状态下也不再复现:#4551 的巡检首次 sweep 排在启动后 60 秒,而正是这 120 秒空转把一次性 CLI 进程留到了 60 秒定时器触发。修好之后进程 3 秒就退出,那条巡检路径不再被触发。假如 #4815 没有先落地,本 PR 会让一个真实缺陷「看起来消失了」而根因原封不动。#4813 与 #4747 必须各自被各自的修复关闭,这是其中一半。
推而广之:只要这些守卫还在漏,任何「启动后 N 秒才做的事」都会在一个自以为已经结束的进程里醒来 —— 反过来,修好之后,任何依赖「进程反正还活着」的周期性工作都会失去那个隐式的宽限期。目前仓里只有 ADR-0057 巡检这一处踩到,已由 #4815 从正确的一侧解决。
超范围产出(未在本 PR 修)
os migrate成功时退出码是随机非零值(208/171/176/163/62…),--version/--help却干净退出 0 #4873(新开,未认领):os migrate成功时退出码是随机非零值。这是我为了拿 before/after 数字才看清的:issue 内核的插件 init/start 超时守卫定时器从不清除也不 unref —— 每个进程在工作结束后还要空转 startupTimeout(CLI 挂 ~120s 才退出) #4813 把不稳定退出码(214/238)归因于「挂太久被外部杀掉」,这个归因是错的 —— 修复后没有任何东西杀它,它 3 秒自行退出,退出码依旧随机(实测 62 / 169 / 208 / 171 / 176;修复前是 163)。同一个bin/run.js的--version/--help干净退出 0,所以问题在bootSchemaStack系子命令的退出路径上。对按退出码判断成败的 CI 是直接的假失败,且因为随机而无法特判兜底。packages/core/src/health-monitor.ts:315的timeout()是同一个漏法:setTimeout(() => reject(...), ms)进Promise.race(:112的健康检查),赢下 race 之后同样不清除。它今天没造成 内核的插件 init/start 超时守卫定时器从不清除也不 unref —— 每个进程在工作结束后还要空转 startupTimeout(CLI 挂 ~120s 才退出) #4813 的现象,因为健康监控不在 bootstrap 路径上启动;但它是同一个形状,应当用同一个 helper 收编。issue 的边界写明只碰 kernel.ts 的两个守卫,故本 PR 未动 —— 如果维护者希望一并收,我可以在同一条分支上补。🤖 Generated with Claude Code
https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
Generated by Claude Code