Skip to content

core: ObjectKernel 上一个抛错的 kernel:shutdown handler 会跳过所有插件 destroy() 并 process.exit(1),日志还谎报「Shutdown timed out」 #5274

Description

@os-zhuang

发现于 #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 抛错,于是同时得到三件事:

  1. 其余 kernel:shutdown handler 不再执行(裸循环在第一个抛错处断掉);
  2. 每一个插件的 destroy() 全部被跳过 —— performShutdown() 里销毁循环在 trigger 之后,根本没走到;
  3. 宿主进程被 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 与两个分发器的分工)。

Activity

  1. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    分诊(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

  2. self-assigned this
    on Aug 4, 2026
  3. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 5 轮(阻塞解除:#5257→PR #5275 已合并)
    会话:session_01Pbu27iNUfQCHeuS551Rqo7
    分支:claude/issue-5274-shutdown-isolate-and-honest-timeout
    Worktree: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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions