Skip to content

fix(core): 隔离 kernel:shutdown 分发,并让「超时」只报真超时 (#5274) - #5281

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5274-shutdown-isolate-and-honest-timeout
Aug 4, 2026
Merged

fix(core): 隔离 kernel:shutdown 分发,并让「超时」只报真超时 (#5274)#5281
os-zhuang merged 1 commit into
mainfrom
claude/issue-5274-shutdown-isolate-and-honest-timeout

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

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 同时造成三件事:

  1. 其余 kernel:shutdown handler 不再执行;
  2. 每一个插件的 destroy() 被跳过 —— 逆序销毁循环排在 trigger 之后,根本没走到;
  3. 宿主进程被 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 不该放大成泄漏和未落盘的写。

⚠️ boot 路径分发未动:kernel:ready / kernel:bootstrapped / kernel:listening 仍然传播、仍然让 boot 失败(#5170 / #5258#5257 / #5275)。

关于「复用 triggerHook」的取舍(裁定里让我说明选择):代码证据不支持直接复用。ObjectKernel 并不继承 ObjectKernelBase(只有 LiteKernel 继承),它有自己的私有 hooks map 和自己的 context.trigger,所以 protected triggerHook 从这里够不着。把共享实现抽成自由函数需要动 kernel-base.ts —— 超出本单声明的文件面,且与该文件今天的两次改动(#5258/#5275)有冲突风险。因此在 kernel.ts 内以私有方法镜像同一语义,日志行逐字相同(Hook handler failed: kernel:shutdown),并在方法注释里写明「为什么不能直接复用」。两侧各有具名回归测试,正是 #5170/#5257 关掉的「同一个钩子名在两个 kernel 上含义相反」那类 bug 的防线。

2. 超时 catch 收紧到只处理真超时,靠对象身份区分,而不是消息匹配或 instanceof:

const shutdownTimeoutError = new Error('Shutdown timeout exceeded');
// …setTimeout(() => reject(shutdownTimeoutError), …)
} catch (error) {
    this.state = 'stopped';
    if (error === shutdownTimeoutError) { /* 原样保留:日志 + process.exit(1) */ }
    else { /* 记 error,走正常路径返回,不 exit */ }
}

只有那个 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 throws issue 正文的复现:reached 必须是 ['later-shutdown', 'plugin-destroy', 'shutdown-handler'],进程未被 exit,state === 'stopped'
names the failing handler and never reports a timeout that did not happen Hook handler failed: kernel:shutdown;出现 Shutdown timed out;停机确实完成,所以 ✅ Graceful shutdown complete 照常打
still logs the timeout and still forces exit(1) when shutdown genuinely times out 真超时(shutdownTimeout: 20 + 永不 settle 的 handler)仍记超时文案、仍 process.exit(1)

反证(把 kernel.ts 暂时 stash 回改前状态跑同三个用例):

× runs the remaining kernel:shutdown handlers … (#5274)
  → expected [] to deeply equal [ 'later-shutdown', …(2) ]
× names the failing handler and never reports a timeout … (#5274)
  → expected [ 'Shutdown timed out — forcing exit' ] to include 'Hook handler failed: kernel:shutdown'
✓ still logs the timeout and still forces exit(1) … (#5274)   23ms

改前的日志与 issue 探针输出逐字一致:ERROR Shutdown timed out — forcing exit {"error":{"message":"shutdown boom"…}}。注意第三个用例在改前改后通过 —— 这正是它作为「行为未变」之 pin 的意义。

影响面验证

分支已 merge 当时的 origin/main(含 #5270#5277,均未触及 packages/core),在容器验证锁下跑:

@objectstack/core:               27 files / 429 tests   passed
@objectstack/runtime:            89 files / 1313 tests  passed
@objectstack/client:             17 files / 222 tests   passed
@objectstack/service-automation: 55 files / 665 tests   passed
@objectstack/http-conformance:    2 files / 46 tests    passed
connector-{mcp,rest,openapi,slack}: 11 files / 79 tests passed
Tasks: 40 successful, 40 total

node scripts/check-durability-degradation-log-level.mjs ✓、check-startup-registry-verdict.mjs ✓(本 PR 新增了 catch 块,故一并跑)。

typecheck:@objectstack/coretypecheck script(#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.tskernel.test.tscontent/docs/kernel/events.mdx(一句)、一个 changeset。未动 packages/spec/**content/docs/releases/**,也未碰在飞的 metadata-protocol/src/protocol.tsmetadata/src/metadata-manager.tsobjectql/**triggers/**、showcase。

🤖 Generated with Claude Code

https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7


Generated by Claude Code

…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
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 3:32pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core.

24 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx (via @objectstack/core)
  • content/docs/ai/knowledge-rag.mdx (via @objectstack/core)
  • content/docs/ai/natural-language-queries.mdx (via @objectstack/core)
  • content/docs/automation/webhooks.mdx (via @objectstack/core)
  • content/docs/concepts/north-star.mdx (via packages/core)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/core)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/core)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/core)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/core)
  • content/docs/kernel/services.mdx (via @objectstack/core)
  • content/docs/permissions/authentication.mdx (via @objectstack/core)
  • content/docs/permissions/authorization.mdx (via packages/core)
  • content/docs/plugins/anatomy.mdx (via @objectstack/core)
  • content/docs/plugins/development.mdx (via @objectstack/core)
  • content/docs/plugins/index.mdx (via @objectstack/core)
  • content/docs/plugins/packages.mdx (via @objectstack/core)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/core)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/core)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/core)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/core)
  • content/docs/releases/implementation-status.mdx (via @objectstack/core)
  • content/docs/releases/v12.mdx (via @objectstack/core)
  • content/docs/releases/v15.mdx (via @objectstack/core)
  • content/docs/releases/v17.mdx (via @objectstack/core)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 16:06
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 674ac99 Aug 4, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5274-shutdown-isolate-and-honest-timeout branch August 4, 2026 16:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants