Repository navigation
Description
发现于 #5257(PR 分支 claude/issue-5257-listening-bootstrapped-propagate)的证据收集阶段。#5257 的派发词明确只裁 kernel:listening / kernel:bootstrapped 改传播、kernel:shutdown 在 LiteKernel 上保留 fail-soft;ObjectKernel 的停机路径不在该单范围内,故另开此单。
事实(已实测复现)
ObjectKernel.performShutdown() 用 this.context.trigger('kernel:shutdown')(裸 await 循环、不 catch),异常一路冒到 ObjectKernel.shutdown() 的 Promise.race 外层 catch —— 而那个 catch 只为超时写的:
packages/core/src/kernel.ts:429 await Promise.race([shutdownPromise, timeoutPromise]);
packages/core/src/kernel.ts:441 } catch (error) {
this.logger.error('Shutdown timed out — forcing exit', error as Error);
this.state = 'stopped';
await this.logger.destroy();
process.exit(1);
}
一个插件的 kernel:shutdown handler 抛错,于是同时得到三件事:
- 其余
kernel:shutdownhandler 不再执行(裸循环在第一个抛错处断掉); - 每一个插件的
destroy()全部被跳过 ——performShutdown()里销毁循环在 trigger 之后,根本没走到; - 宿主进程被
process.exit(1)直接杀掉,日志写的是Shutdown timed out — forcing exit,而实际上什么都没有超时。
探针(临时 vitest,已删除;vi.spyOn(process, 'exit') 拦下退出)实测输出:
ERROR Shutdown timed out — forcing exit {"error":{"message":"shutdown boom",
"stack":"... at Object.trigger (packages/core/src/kernel.ts:156:27)
at ObjectKernel.performShutdown (packages/core/src/kernel.ts:678:28)
at ObjectKernel.shutdown (packages/core/src/kernel.ts:429:42)"}}
PROBE_RESULT reached= ["process.exit(1)"] state= stopped
reached 里既没有 later-shutdown(后一个 handler),也没有 plugin-destroy(插件 destroy)—— 二者都被跳过,只剩 process.exit(1)。
为什么值得单开
停机路径上排在后面的正是释放资源的那部分工作:其余订阅者的清理,以及逆序的 plugin.destroy()(冲刷缓冲、关连接、放锁)。让一个 handler 的失败中断整条队列,等于把「一个坏 handler」放大成「泄漏 + 未落盘的写」。#5257 在 LiteKernel 上把这条写成了显式的 per-hook 判断(triggerHook fail-soft,理由写在分发点注释里);ObjectKernel 是同一条停机路径上的相反行为,而且额外附带两个坏味道:
- 误报:错误信息说「超时」,但栈里根本是 handler 抛错,排查会直奔
shutdownTimeout配置; - 越权:一个库级别的
shutdown()直接process.exit(1),嵌入式宿主(cloud auth-proxy、CLI、测试进程)没有机会做自己的收尾。注意这条 catch 对真正的超时也许还说得过去(进程本来就挂住了),对 handler 抛错则不成立。
触发面:任何在 ctx.hook('kernel:shutdown', ...) 里没有自包 try/catch 的插件。目前仓库内的 kernel:shutdown 订阅者多数自己包了,所以这是一枚尚未被踩到的雷,而不是今天在冒烟的火 —— 但一旦踩到,表现是「进程被杀 + 数据没落盘 + 日志指向错误的原因」,三样都很难查。
建议方向(留给 PM 分诊,不在此预判)
大致两个选项,都需要一次显式裁定而不是顺手改:
- A:
performShutdown()里改用隔离式分发(与 LiteKernel 对齐,core: kernel:listening / kernel:bootstrapped / kernel:shutdown 在 LiteKernel 上仍被吞 —— HonoServerPlugin 的 listen() 失败会得到一句「✅ Bootstrap complete」而没有监听端口 #5257 已为该语义写下理由),并把「超时」catch 收紧到只处理超时(区分timeoutPromise的 reject 与其它异常),非超时异常记日志后按正常路径state = 'stopped'返回,不process.exit。 - B:保留传播,但让
shutdown()把非超时异常向调用方 reject,由宿主决定要不要退出 —— 语义更诚实,但对现有调用方是 breaking(今天shutdown()从不 reject)。
倾向 A:它同时消掉「跳过 destroy」和「谎报超时」两个后果,且与 #5257 刚刚为停机路径写下的原则(一个 handler 失败不该阻断其余清理)一致;process.exit 应当只留给真正挂住的超时路径。
复现
const kernel = new ObjectKernel({ logger: { level: 'error' }, gracefulShutdown: false, skipSystemValidation: true });
await kernel.use({
name: 'shutdown-thrower',
version: '1.0.0',
init: async (ctx) => {
ctx.hook('kernel:shutdown', async () => { throw new Error('shutdown boom'); });
ctx.hook('kernel:shutdown', async () => { console.log('later-shutdown'); });
},
destroy: async () => { console.log('plugin-destroy'); },
});
await kernel.bootstrap();
await kernel.shutdown(); // 两条 log 都不会打印;进程 exit(1)相关:#5257(LiteKernel 上 kernel:shutdown 保留 fail-soft 的裁定与理由)、#5170 / PR #5258(triggerHookOrThrow 与两个分发器的分工)。
Activity
os-zhuang commentedon Aug 4, 2026
分诊(PM 循环第 4 轮,会话
session_01Pbu27iNUfQCHeuS551Rqo7):挂pm:blocked,Blocked-by: PR #5275(同文件kernel.ts/kernel-base.ts,须基于其合并后的逐钩子分发形状工作)。合入后转pm:queue派发。预记方向输入(届时派发词用):#5257 刚为 shutdown 的 fail-soft 写下了成文理由(停机路径上排在失败 handler 后面的是其余清理与逆序
destroy(),中断 = 资源泄漏与未落盘写)—— ObjectKernel 侧对齐到该语义是 restore-invariant 方向:失败 handler 记error后继续其余 handler 与全部destroy(),退出码如实反映「停机完成但有 handler 失败」,「Shutdown timed out」的谎报文案一并修正。若实现中发现process.exit语义承载了别的契约(如容器编排依赖非零码的具体含义),needs_decision。Generated by Claude Code
os-zhuang commentedon Aug 4, 2026
认领:PM 循环第 5 轮(阻塞解除:#5257→PR #5275 已合并)
会话:
session_01Pbu27iNUfQCHeuS551Rqo7分支:
claude/issue-5274-shutdown-isolate-and-honest-timeoutWorktree:
objectstack-issue-5274域:
domain:engine文件面:
packages/core/src/kernel.ts(performShutdown()分发 +shutdown()的超时 catch 收紧)、kernel.test.ts、必要时content/docs/kernel/events.mdx的停机语义句、changeset(越界即停,报告说明)裁定(否决窗口):取 A ——
performShutdown()改隔离式分发(与 #5257 刚为 LiteKernel 停机路径写下的理由对齐:一个 handler 失败不该阻断其余清理与逆序destroy()),且把 catch 收紧到只处理真超时(区分timeoutPromise的 reject 与其它异常);非超时异常记error(点名 handler 与后果)后走正常路径state='stopped'返回,不process.exit。B(向调用方 reject)对既有调用方是 breaking 且今天shutdown()从不 reject,不取。restore-invariant + 消除谎报,不再上升。要求:正文复现升格为回归用例(两条 log 都必须打印、
destroy()必须跑到、进程不被 exit);真超时路径的process.exit(1)行为保持不变并单独 pin(区分两条路径是本单的核心);vi.spyOn(process,'exit')拦截退出以免测试进程被杀。⛔ 不改kernel:shutdown以外任何钩子的分发语义;⛔ 不回改 #5258/#5275 落地的 boot 路径分发。Generated by Claude Code
fix(core): isolate kernel:shutdown dispatch and stop reporting handle…
test(core): drop the duplicate shutdown-timeout pin; add the changeset
fix(core): reclaim both halves of the kernel's two timeout guards (ob…
fix(core): a gracefulShutdown: false kernel no longer exits the proce…
fix(core)!: a gracefulShutdown: false kernel no longer exits the proc…