fix(core): 隔离 kernel:shutdown 分发,并让「超时」只报真超时 (#5274) - #5281
Merged
os-zhuang merged 1 commit intoAug 4, 2026
Conversation
…r throws as timeouts (#5274) `ObjectKernel.performShutdown()` dispatched `kernel:shutdown` through `context.trigger` — a bare awaited loop that never catches — so the first handler that threw propagated out to `shutdown()`'s `Promise.race` catch. That catch was written for the timeout race alone and treated every exception as one, so a single bad subscriber produced three consequences at once: the remaining `kernel:shutdown` handlers never ran, EVERY plugin's `destroy()` was skipped (the reverse-order pass sits after the trigger), and the host process was killed by `process.exit(1)` under the log line `Shutdown timed out — forcing exit` while nothing had timed out. Two changes, matching the reasoning #5257 recorded at LiteKernel's shutdown dispatch site: - `kernel:shutdown` now dispatches ISOLATING on ObjectKernel too. A throwing handler is logged as `Hook handler failed: kernel:shutdown` and the remaining handlers still run, followed by the reverse-order `destroy()` pass and the `onShutdown()` handlers — both of which already isolated per plugin and per handler. What is queued behind a failing shutdown handler is the cleanup that flushes buffers, closes connections and releases locks, so one bad handler must not amplify into leaks and unflushed writes. The boot-path hooks are untouched: `kernel:ready`, `kernel:bootstrapped` and `kernel:listening` still propagate and still fail the boot (#5170, #5257). ObjectKernel does not extend ObjectKernelBase (only LiteKernel does) and owns its own `hooks` map, so `triggerHook` is not reachable from it; the semantics are mirrored in a private dispatcher that logs the identical line, with the reason recorded at the method. - The timeout catch now handles ONLY a genuine timeout, discriminated by identity on the timer's own rejection — not by message, not by `instanceof`, so nothing a plugin throws can impersonate it. A genuine `shutdownTimeout` overrun is unchanged: still logs `Shutdown timed out — forcing exit`, still calls `process.exit(1)`, because teardown really is hung. Any other exception is logged at `error` and follows the normal path (`state = 'stopped'`, return) with no `process.exit`, leaving an embedding host its own chance to finish cleanly. `shutdown()` still never rejects, so no caller changes. Three named per-behavior tests: the issue's reproduction (remaining handlers, destroy() and onShutdown all run; process not exited; state `stopped`), the false-timeout report, and the genuine timeout still exiting 1. `process.exit` is intercepted with `vi.spyOn` — the reason this pin could not land in #5257. The first two fail on the pre-change kernel with exactly the issue's probe output; the third passes on both sides, which is what makes it a pin on unchanged behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 24 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 4, 2026 16:06
os-zhuang
deleted the
claude/issue-5274-shutdown-isolate-and-honest-timeout
branch
August 4, 2026 16:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5274
按 issue 上的裁定取 A(隔离式分发 + 收紧超时 catch);B(向调用方 reject)未实现 ——
shutdown()今天从不 reject,改这一点对既有调用方是 breaking。问题
ObjectKernel.performShutdown()用this.context.trigger('kernel:shutdown')(裸 await 循环、从不 catch),第一个抛错的 handler 一路冒到shutdown()的Promise.race外层 catch;而那个 catch 只为超时写的,把所有异常一视同仁。于是一个插件的坏 handler 同时造成三件事:kernel:shutdownhandler 不再执行;destroy()被跳过 —— 逆序销毁循环排在 trigger 之后,根本没走到;process.exit(1)杀掉,日志却写Shutdown timed out — forcing exit,而实际什么都没超时 —— 读到这行的人会直奔shutdownTimeout配置去查一个 handler 的 bug。改动
与 #5257 刚在 LiteKernel 停机分发点写下的理由对齐:
1.
kernel:shutdown在 ObjectKernel 上也改隔离式分发。 抛错的 handler 记Hook handler failed: kernel:shutdown,其余 handler 照常跑,随后是逆序destroy()与onShutdown()handler —— 后两个循环本来就已经逐插件/逐 handler 隔离了,这行是停机路径上唯一没隔离的一步。排在失败 handler 后面的正是冲刷缓冲、关连接、放锁的清理,所以一个坏 handler 不该放大成泄漏和未落盘的写。kernel:ready/kernel:bootstrapped/kernel:listening仍然传播、仍然让 boot 失败(#5170 / #5258、#5257 / #5275)。关于「复用
triggerHook」的取舍(裁定里让我说明选择):代码证据不支持直接复用。ObjectKernel并不继承ObjectKernelBase(只有LiteKernel继承),它有自己的私有hooksmap 和自己的context.trigger,所以protected triggerHook从这里够不着。把共享实现抽成自由函数需要动kernel-base.ts—— 超出本单声明的文件面,且与该文件今天的两次改动(#5258/#5275)有冲突风险。因此在kernel.ts内以私有方法镜像同一语义,日志行逐字相同(Hook handler failed: kernel:shutdown),并在方法注释里写明「为什么不能直接复用」。两侧各有具名回归测试,正是 #5170/#5257 关掉的「同一个钩子名在两个 kernel 上含义相反」那类 bug 的防线。2. 超时 catch 收紧到只处理真超时,靠对象身份区分,而不是消息匹配或
instanceof:只有那个
setTimeout能产生这个对象,所以插件抛什么都冒充不了它 —— 连 handler 自己throw new Error('Shutdown timeout exceeded')也不行。Shutdown timed out — forcing exit,仍process.exit(1)。此时performShutdown()确实还在跑且不再推进,进程本来就会挂住并攥着没释放的东西。error(说明后果:内核已 stopped、进程不会被退出、可能有清理没跑完),按正常路径返回,不process.exit。嵌入式宿主(cloud auth-proxy、CLI、测试进程)因此保住了自己收尾的机会。shutdown()依旧从不 reject,调用方无需改动。区分这两条路径是本单的核心,所以两条都单独 pin 住了。
测试
三个具名的逐行为用例(
packages/core/src/kernel.test.ts,Graceful Shutdown块内),用vi.spyOn(process, 'exit')拦截退出 —— 这正是 #5257 的开发者当时无法把该用例落在那个 PR 里的原因。runs the remaining kernel:shutdown handlers, every destroy() and every onShutdown handler when one handler throwsreached必须是['later-shutdown', 'plugin-destroy', 'shutdown-handler'],进程未被 exit,state === 'stopped'names the failing handler and never reports a timeout that did not happenHook handler failed: kernel:shutdown;不出现Shutdown timed out;停机确实完成,所以✅ Graceful shutdown complete照常打still logs the timeout and still forces exit(1) when shutdown genuinely times outshutdownTimeout: 20+ 永不 settle 的 handler)仍记超时文案、仍process.exit(1)反证(把
kernel.ts暂时 stash 回改前状态跑同三个用例):改前的日志与 issue 探针输出逐字一致:
ERROR Shutdown timed out — forcing exit {"error":{"message":"shutdown boom"…}}。注意第三个用例在改前改后都通过 —— 这正是它作为「行为未变」之 pin 的意义。影响面验证
分支已 merge 当时的
origin/main(含 #5270、#5277,均未触及packages/core),在容器验证锁下跑:node scripts/check-durability-degradation-log-level.mjs✓、check-startup-registry-verdict.mjs✓(本 PR 新增了 catch 块,故一并跑)。typecheck:
@objectstack/core无typecheckscript(#4311 DEBT 台账)。在同一棵合并树上两侧实测tsc --noEmit -p packages/core/tsconfig.json:改前 95 → 改后 98(+3)。三个新增全部是kernel.test.ts里新用例的init: async (ctx)报 TS7006 implicit-any,成因是该文件既有的 TS2835(import … from './kernel'缺.js扩展名)—— 正是 AGENTS.md 记的那个陷阱:导入不解析 ⇒ 符号退化成any⇒ 其上的回调逐个报 TS7006。kernel.ts(源码)零新增错误,零 code-tier 新增。该 config-tier 债已由 #4311 台账中 core 条目明文记载(code-tier 3; the rest is config-tier (TS2835/TS2347) and noise (TS7006)),故未在本 PR 顺手修 —— 修它会重排整个文件的错误读数,属于另一件事。台账只要求条目存在、不比对数值(main 自身已从 91 漂到 95)。文档
content/docs/kernel/events.mdx里那句「ObjectKernel的停机路径尚未与之一致(见 #5274)」被本改动证伪,改为如实描述两个 kernel 现在一致,并写明process.exit(1)只留给真正的shutdownTimeout超时。最小改动,只动这一句。范围
严格限于认领时声明的文件面:
packages/core/src/kernel.ts、kernel.test.ts、content/docs/kernel/events.mdx(一句)、一个 changeset。未动packages/spec/**、content/docs/releases/**,也未碰在飞的metadata-protocol/src/protocol.ts、metadata/src/metadata-manager.ts、objectql/**、triggers/**、showcase。🤖 Generated with Claude Code
https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
Generated by Claude Code