Skip to content

Commit 0f2fdcd

Browse files
os-zhuangclaude
andauthored
fix(core)!: a throwing kernel:bootstrapped / kernel:listening handler fails the boot on LiteKernel too (#5257) (#5275)
#5170 (PR #5258) unified `kernel:ready` across the two kernels and deliberately ruled that hook ONLY, pinning the rest as "still fail-soft on LiteKernel" so widening it would have to be a deliberate change. This is that change, for the two hooks that are still on the BOOT path. `kernel:bootstrapped` and `kernel:listening` now use the propagating dispatcher (`triggerHookOrThrow`) on LiteKernel, in the shape #5258 established: remaining handlers skipped, later boot hooks never fired, original error unwrapped, `state = 'stopped'`, and no "✅ Bootstrap complete". The failure this removes is the ugliest one available. HonoServerPlugin opens its socket inside a `kernel:listening` handler — `await server.listen(port)`, with no try/catch of its own, deliberately. When that rejected on LiteKernel (EACCES on a privileged port, a failure inside the port-fallback logic, an edge/serverless host where listen is unavailable) the throw was swallowed, `bootstrap()` resolved, and the process printed "✅ Bootstrap complete" with nothing listening — the health check after it was the first thing to notice, and it had already been told startup succeeded. The same plugin code on ObjectKernel failed the boot. Plain port-in-use never triggered it (server.listen falls back to a random port internally), which is why it stayed invisible. `kernel:shutdown` KEEPS fail-soft dispatch, now as an explicit per-hook judgement recorded at the dispatch site rather than an inherited default: on the teardown path the handlers queued behind a failing one — and the reverse-order destroy() pass after them — are what flush buffers and release resources. #5258's pin "keeps fail-soft dispatch for hooks other than kernel:ready" covered all three hooks in one assertion; it is split here into the three tests that are now true, per hook, so the flip is visible in the diff. Symmetric regressions added on BOTH kernels for bootstrapped and listening (incl. asserting the success line is never logged). Evidence: every non-test `kernel:bootstrapped` subscriber already guards itself (objectql's announceOpenMigrationGates catches internally — "an advisory must never be the reason a boot fails"; plugin-sharing wraps each of its five passes; plugin-pinyin-search wraps its backfill; service-automation's audit is in-memory reads behind an `if (!this.engine) return`). Suites: core 426, runtime 1313, client 222, http-conformance 46, service-automation 665, connector-{rest,mcp,slack,openapi} 79, plugin-hono-server 164, plugin-sharing 347, plugin-pinyin-search 14 — all pass. Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 08f93bc commit 0f2fdcd

6 files changed

Lines changed: 280 additions & 51 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
fix(core)!: a throwing `kernel:bootstrapped` / `kernel:listening` handler fails the boot on LiteKernel too (#5257)
6+
7+
**A failed `listen()` no longer yields a false "✅ Bootstrap complete".**
8+
9+
#5170 (PR #5258) unified `kernel:ready`: a handler that throws fails the boot on
10+
`ObjectKernel` and `LiteKernel` alike. It deliberately ruled that one hook only,
11+
leaving the other lifecycle hooks split — `ObjectKernel` propagates their
12+
failures (its `context.trigger` is a bare awaited loop that never catches) while
13+
`LiteKernel` routed them through the isolating dispatcher, logging
14+
`Hook handler failed: <name>` and carrying on. This closes the two boot-path
15+
hooks that were left: `kernel:bootstrapped` and `kernel:listening` now use the
16+
propagating dispatcher (`triggerHookOrThrow`) on `LiteKernel`, in the same shape
17+
#5258 established — the remaining handlers for that hook are skipped, the later
18+
boot hooks never fire, the original error reaches the caller **unwrapped**,
19+
`state` is left `'stopped'` rather than `'running'`, and the success line is
20+
never logged.
21+
22+
The concrete failure this removes: `HonoServerPlugin` opens its socket inside a
23+
`kernel:listening` handler — `await this.server.listen(port)`, with no try/catch
24+
of its own, deliberately. When that rejected on `LiteKernel` (EACCES on a
25+
privileged port, a failure inside the port-fallback logic itself, a serverless /
26+
edge host where `listen` is not available at all) the throw was swallowed,
27+
`bootstrap()` resolved normally, and the process printed
28+
`✅ Bootstrap complete` while **nothing was listening**. The same plugin code on
29+
`ObjectKernel` failed the boot. The health check that came next was the first
30+
thing to notice, and it had already been told startup succeeded. Plain "port is
31+
in use" was never affected — `server.listen` falls back to a random port
32+
internally — which is exactly why this stayed invisible.
33+
34+
`kernel:bootstrapped` carries reconcile and audit work (objectql's
35+
`announceOpenMigrationGates`, service-automation's node-type / trigger-binding
36+
audits, the sharing plugin's boot backfills); a swallowed failure there is a
37+
quieter version of the same lie — the audit silently does not run.
38+
39+
**`kernel:shutdown` keeps fail-soft dispatch**, now as an explicit per-hook
40+
judgement recorded in a comment at the dispatch site rather than an inherited
41+
default. On the teardown path there is no "refuse to proceed" left to buy, and
42+
the handlers queued behind a failing one — plus the reverse-order `destroy()`
43+
pass after them — are what flush buffers, close connections and release locks.
44+
Aborting that sequence would convert one bad handler into leaked resources and
45+
unflushed writes.
46+
47+
**Who is affected.** Hosts that boot through `LiteKernel` — vitest, serverless,
48+
edge (Workers) — and register a `kernel:bootstrapped` or `kernel:listening`
49+
handler that can throw. Such a host previously came up "successfully" with the
50+
work of that handler silently skipped; it now refuses to start and surfaces the
51+
original error. If a handler of yours performs best-effort work whose failure
52+
genuinely must not stop the boot, it needs its own `try/catch` — which is what
53+
the in-repo `kernel:bootstrapped` subscribers already do, per handler, with the
54+
reason written down. Nothing in this repo relied on the swallow: the core (426),
55+
client, runtime, http-conformance, connector-{rest,mcp,slack} and
56+
service-automation (665) suites pass unchanged.
57+
58+
Boot assertions still belong in `kernel:ready`: it is the earliest hook at which
59+
the service registry is finished filling.

‎content/docs/kernel/events.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ ctx.hook('kernel:ready', async () => {
3333

3434
`ctx.hook(name, handler)` takes exactly two arguments — there is no options/priority parameter. Kernel hooks run in **registration order**, and `ctx.trigger()` awaits each handler sequentially.
3535

36-
A `kernel:ready` handler that **throws fails the boot**, on `ObjectKernel` and `LiteKernel` alike: the remaining `kernel:ready` handlers are skipped, `kernel:bootstrapped` and `kernel:listening` never fire, and `bootstrap()` rejects with the original error. That is what makes `kernel:ready` the place to assert that a precondition your plugin *declared* was actually met — the service registry is still filling during `init()`, so nothing earlier can judge it, and a deployment that cannot honour what it announced must refuse to start rather than serve without the guarantee. The other lifecycle hooks do **not** share that contract: `ObjectKernel` propagates their failures too, while `LiteKernel` logs each one and runs the remaining handlers. Put boot assertions in `kernel:ready`.
36+
A handler on any of the three **boot-path** hooks — `kernel:ready`, `kernel:bootstrapped`, `kernel:listening` — that **throws fails the boot**, on `ObjectKernel` and `LiteKernel` alike: the remaining handlers for that hook are skipped, the later boot hooks never fire, `bootstrap()` rejects with the original error (unwrapped), the kernel is left `stopped` rather than `running`, and **no "✅ Bootstrap complete" is logged**. Everything dispatched before that line is a precondition of it, so swallowing a failure there would not rescue the boot — it would only hide it behind a process reporting success. That is most visible on `kernel:listening`, where HTTP server plugins open their socket: a `listen()` that rejects (EACCES on a privileged port, a host that cannot listen at all) takes the boot down instead of leaving a live process with nothing listening.
37+
38+
`kernel:ready` is still the right place for **boot assertions** specifically — the service registry is only finished filling by then, so nothing earlier can judge whether a precondition your plugin *declared* was actually met, and a deployment that cannot honour what it announced must refuse to start rather than serve without the guarantee.
39+
40+
`kernel:shutdown` is the deliberate exception: on `LiteKernel` a failing shutdown handler is logged and the remaining cleanup still runs, because the handlers queued behind it — and the `destroy()` pass after them — are what flush buffers and release resources, so aborting teardown only leaks what they were about to release. `ObjectKernel`'s shutdown path does not yet match that (tracked in [#5274](https://github.com/objectstack-ai/objectstack/issues/5274)); write shutdown handlers that handle their own errors either way.
3741

3842
### Emitting Custom Events
3943

‎packages/core/src/kernel-base.ts‎

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,17 @@ export abstract class ObjectKernelBase {
250250
* Trigger a hook with all registered handlers, ISOLATING failures: a
251251
* handler that throws is logged and the remaining handlers still run.
252252
*
253-
* Use this for notification-style hooks, where one subscriber's failure
254-
* must not deny the others their notification. It is the WRONG dispatcher
255-
* for a hook that carries boot assertions — a swallowed throw there turns
256-
* "this deployment refuses to start misconfigured" into a log line nobody
257-
* reads. Those hooks use {@link triggerHookOrThrow} (#5170).
253+
* Use this for hooks where one subscriber's failure must not deny the
254+
* others their turn — notification-style hooks, and `kernel:shutdown`,
255+
* where the handlers still queued behind the failing one are the cleanup
256+
* that flushes buffers and releases resources (#5257).
257+
*
258+
* It is the WRONG dispatcher for anything on the BOOT path. Every hook
259+
* dispatched before "✅ Bootstrap complete" is a precondition of that
260+
* claim, so swallowing a throw there does not rescue the boot — it only
261+
* hides the failure behind a process that reports success. Those hooks
262+
* (`kernel:ready`, `kernel:bootstrapped`, `kernel:listening`) use
263+
* {@link triggerHookOrThrow} (#5170, #5257).
258264
*
259265
* @param name - Hook name
260266
* @param args - Arguments to pass to handlers
@@ -285,18 +291,31 @@ export abstract class ObjectKernelBase {
285291
* lifecycle hook (its `context.trigger` is a bare awaited loop that never
286292
* catches). `LiteKernel` used the isolating {@link triggerHook} for all of
287293
* them, so one hook name meant two opposite things depending on which
288-
* kernel booted the same plugin code (#5170). `kernel:ready` is the hook
289-
* where that divergence bites: it is the only correct moment for a plugin
290-
* to assert that the preconditions it declared were actually met (the
291-
* registries are still filling during `init()`), so "declared but not
292-
* deliverable ⇒ refuse to boot" gates live there — and on LiteKernel,
293-
* which is what vitest/serverless/edge run, they were being downgraded to
294-
* an error log while the process carried on serving traffic without the
295-
* guarantee it claimed.
294+
* kernel booted the same plugin code (#5170).
295+
*
296+
* `LiteKernel` now uses this dispatcher for all three BOOT-path hooks:
297+
*
298+
* - `kernel:ready` (#5170) — the only correct moment for a plugin to
299+
* assert that the preconditions it declared were actually met (the
300+
* registries are still filling during `init()`), so "declared but not
301+
* deliverable ⇒ refuse to boot" gates live there. On LiteKernel, which
302+
* is what vitest/serverless/edge run, they were downgraded to an error
303+
* log while the process carried on serving traffic without the
304+
* guarantee it claimed.
305+
* - `kernel:bootstrapped` and `kernel:listening` (#5257) — the same
306+
* argument one hook later. `kernel:listening` is where HTTP server
307+
* plugins open their socket, so a swallowed failure there produced the
308+
* worst shape available: a live process printing "✅ Bootstrap complete"
309+
* with nothing listening. `kernel:bootstrapped` carries reconcile and
310+
* audit passes whose silent failure is a quieter version of the same
311+
* lie.
296312
*
297-
* Deliberately NOT applied to the other hook names: #5170 rules
298-
* `kernel:ready` only, and a notification hook keeping fail-soft dispatch
299-
* is a separate judgement per hook, not a side effect of this one.
313+
* Deliberately NOT applied to `kernel:shutdown`, which keeps
314+
* {@link triggerHook}: on the teardown path a failing handler must not
315+
* block the cleanup queued behind it. That is a per-hook judgement
316+
* recorded at the dispatch site in `lite-kernel.ts`, not an inherited
317+
* default — and it is the reason this dispatcher is chosen per hook rather
318+
* than swapped in wholesale.
300319
*
301320
* @param name - Hook name
302321
* @param args - Arguments to pass to handlers

‎packages/core/src/kernel.test.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,66 @@ describe('ObjectKernel', () => {
636636
expect(kernel.getState()).toBe('stopped');
637637
});
638638

639+
// #5257 — the ObjectKernel HALVES of the two pins this issue adds.
640+
// ObjectKernel already behaved this way (`context.trigger` never
641+
// catches); the tests exist so the pair is symmetric with
642+
// lite-kernel.test.ts and a regression on EITHER kernel is caught by a
643+
// named test rather than inferred from the other one still passing.
644+
// That symmetry is the whole point: the bug #5170 and #5257 closed was
645+
// one hook name meaning two opposite things depending on the kernel,
646+
// and only a matched pair of tests can hold that shut.
647+
it('fails the boot when a kernel:bootstrapped handler throws (#5257)', async () => {
648+
const reached: string[] = [];
649+
const plugin: Plugin = {
650+
name: 'bootstrapped-thrower-plugin',
651+
version: '1.0.0',
652+
init: async (ctx) => {
653+
ctx.hook('kernel:bootstrapped', async () => {
654+
throw new Error('node-type audit could not seal the vocabulary');
655+
});
656+
ctx.hook('kernel:bootstrapped', async () => { reached.push('later-bootstrapped'); });
657+
ctx.hook('kernel:listening', async () => { reached.push('kernel:listening'); });
658+
},
659+
};
660+
661+
await kernel.use(plugin);
662+
663+
await expect(kernel.bootstrap()).rejects.toThrow('node-type audit could not seal the vocabulary');
664+
665+
expect(reached).toEqual([]);
666+
expect(kernel.getState()).toBe('stopped');
667+
});
668+
669+
// The headline case of #5257, from the kernel that always got it
670+
// right: `HonoServerPlugin` awaits `server.listen(port)` inside a
671+
// `kernel:listening` handler with no try/catch of its own, so a listen
672+
// that rejects must fail the boot rather than resolve into a process
673+
// announcing "✅ Bootstrap complete" with nothing listening.
674+
it('fails the boot — and never logs "Bootstrap complete" — when a kernel:listening handler throws (#5257)', async () => {
675+
const reached: string[] = [];
676+
const plugin: Plugin = {
677+
name: 'listening-thrower-plugin',
678+
version: '1.0.0',
679+
init: async (ctx) => {
680+
ctx.hook('kernel:listening', async () => {
681+
throw new Error('listen EACCES: permission denied 0.0.0.0:80');
682+
});
683+
ctx.hook('kernel:listening', async () => { reached.push('later-listening'); });
684+
},
685+
};
686+
687+
await kernel.use(plugin);
688+
const infoSpy = vi.spyOn((kernel as unknown as { logger: { info: (...a: unknown[]) => void } }).logger, 'info');
689+
690+
await expect(kernel.bootstrap()).rejects.toThrow('listen EACCES: permission denied 0.0.0.0:80');
691+
692+
expect(reached).toEqual([]);
693+
expect(kernel.getState()).toBe('stopped');
694+
const logged = infoSpy.mock.calls.map((c) => String(c[0]));
695+
expect(logged.some((m) => m.includes('Bootstrap complete'))).toBe(false);
696+
infoSpy.mockRestore();
697+
});
698+
639699
it('should trigger shutdown hook', async () => {
640700
let hookCalled = false;
641701

0 commit comments

Comments
 (0)