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:shutdownLiteKernel 上保留 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 与两个分发器的分工)。

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions