发现于 #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:shutdown handler 不再执行(裸循环在第一个抛错处断掉);
- 每一个插件的
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:它同时消掉「跳过 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 与两个分发器的分工)。
发现于 #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 只为超时写的:一个插件的
kernel:shutdownhandler 抛错,于是同时得到三件事:kernel:shutdownhandler 不再执行(裸循环在第一个抛错处断掉);destroy()全部被跳过 ——performShutdown()里销毁循环在 trigger 之后,根本没走到;process.exit(1)直接杀掉,日志写的是Shutdown timed out — forcing exit,而实际上什么都没有超时。探针(临时 vitest,已删除;
vi.spyOn(process, 'exit')拦下退出)实测输出:reached里既没有later-shutdown(后一个 handler),也没有plugin-destroy(插件 destroy)—— 二者都被跳过,只剩process.exit(1)。为什么值得单开
停机路径上排在后面的正是释放资源的那部分工作:其余订阅者的清理,以及逆序的
plugin.destroy()(冲刷缓冲、关连接、放锁)。让一个 handler 的失败中断整条队列,等于把「一个坏 handler」放大成「泄漏 + 未落盘的写」。#5257 在 LiteKernel 上把这条写成了显式的 per-hook 判断(triggerHookfail-soft,理由写在分发点注释里);ObjectKernel是同一条停机路径上的相反行为,而且额外附带两个坏味道:shutdownTimeout配置;shutdown()直接process.exit(1),嵌入式宿主(cloud auth-proxy、CLI、测试进程)没有机会做自己的收尾。注意这条 catch 对真正的超时也许还说得过去(进程本来就挂住了),对 handler 抛错则不成立。触发面:任何在
ctx.hook('kernel:shutdown', ...)里没有自包 try/catch 的插件。目前仓库内的kernel:shutdown订阅者多数自己包了,所以这是一枚尚未被踩到的雷,而不是今天在冒烟的火 —— 但一旦踩到,表现是「进程被杀 + 数据没落盘 + 日志指向错误的原因」,三样都很难查。建议方向(留给 PM 分诊,不在此预判)
大致两个选项,都需要一次显式裁定而不是顺手改:
performShutdown()里改用隔离式分发(与 LiteKernel 对齐,core: kernel:listening / kernel:bootstrapped / kernel:shutdown 在 LiteKernel 上仍被吞 —— HonoServerPlugin 的 listen() 失败会得到一句「✅ Bootstrap complete」而没有监听端口 #5257 已为该语义写下理由),并把「超时」catch 收紧到只处理超时(区分timeoutPromise的 reject 与其它异常),非超时异常记日志后按正常路径state = 'stopped'返回,不process.exit。shutdown()把非超时异常向调用方 reject,由宿主决定要不要退出 —— 语义更诚实,但对现有调用方是 breaking(今天shutdown()从不 reject)。倾向 A:它同时消掉「跳过 destroy」和「谎报超时」两个后果,且与 #5257 刚刚为停机路径写下的原则(一个 handler 失败不该阻断其余清理)一致;
process.exit应当只留给真正挂住的超时路径。复现
相关:#5257(LiteKernel 上
kernel:shutdown保留 fail-soft 的裁定与理由)、#5170 / PR #5258(triggerHookOrThrow与两个分发器的分工)。