diff --git a/.changeset/kernel-ready-unified-failure.md b/.changeset/kernel-ready-unified-failure.md new file mode 100644 index 0000000000..aa1a26554d --- /dev/null +++ b/.changeset/kernel-ready-unified-failure.md @@ -0,0 +1,44 @@ +--- +"@objectstack/core": minor +--- + +fix(core)!: a throwing `kernel:ready` handler now fails the boot on **LiteKernel** too (#5170) + +**Behaviour change — read this if you run `LiteKernel` (vitest harnesses, +serverless functions, edge workers).** A `kernel:ready` handler that throws now +**rejects `bootstrap()`** on `LiteKernel`, exactly as it always has on +`ObjectKernel`. Before this change the throw was caught inside the kernel, +written out as one `Hook handler failed: kernel:ready` error log, and the boot +continued to "✅ Bootstrap complete". + +**Why it mattered.** The two kernels ran the same hook through two different +dispatchers: `ObjectKernel` used `context.trigger` (a bare awaited loop that +never catches), `LiteKernel` used `triggerHook` (per-handler try/catch, +"continue with other handlers even if one fails"). Same hook name, same plugin +code, opposite failure semantics — which is `declared ≠ enforced` in the +kernel's own lifecycle contract. + +`kernel:ready` is the only correct moment for a plugin to assert that a +precondition it *declared* was actually delivered: the service registry is +still filling during `init()`, so a boot gate has nowhere earlier to run. Every +"declare it and we refuse to start if we cannot honour it" gate in this repo +therefore lives there — and on `LiteKernel` those gates were being downgraded to +a log line while the process came up and served traffic without the guarantee it +had announced. `EmailServicePlugin`'s `queueDelivery: true` gate (#5160) is the +worked example: on `ObjectKernel` the boot failed, on `LiteKernel` the server +came up and quietly fell back to inline delivery. Serverless is exactly where +"do not start misconfigured" matters most. + +**Who is affected.** Any `LiteKernel` host whose `kernel:ready` handler throws +on a healthy boot. That boot previously "succeeded"; it now fails loudly with +the original error, and the kernel is left `stopped` rather than `running`. The +failure was never silent — it was already an `ERROR` line in your logs — so +check for `Hook handler failed: kernel:ready` in existing logs to find hosts +that will now refuse to start. If the handler's work is genuinely optional, +catch inside the handler and log there; the kernel no longer decides that for +you. The full test surface in this repo that boots `LiteKernel` (core, client, +runtime, http-conformance, the connectors, service-automation) passes unchanged +— nothing was relying on the swallow. + +Scope: **`kernel:ready` only.** `kernel:bootstrapped`, `kernel:listening` and +`kernel:shutdown` keep `LiteKernel`'s isolating dispatch, pinned by a test. diff --git a/content/docs/kernel/events.mdx b/content/docs/kernel/events.mdx index 5819dcba64..a83d07a518 100644 --- a/content/docs/kernel/events.mdx +++ b/content/docs/kernel/events.mdx @@ -33,6 +33,8 @@ ctx.hook('kernel:ready', async () => { `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. +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`. + ### Emitting Custom Events Plugins can trigger their own namespaced events for inter-plugin communication. Use `ctx.trigger()` (it is async and returns a `Promise`); handlers receive the positional arguments you pass to `trigger()`: diff --git a/packages/core/src/kernel-base.ts b/packages/core/src/kernel-base.ts index b479216f44..69ae687127 100644 --- a/packages/core/src/kernel-base.ts +++ b/packages/core/src/kernel-base.ts @@ -247,7 +247,15 @@ export abstract class ObjectKernelBase { } /** - * Trigger a hook with all registered handlers + * Trigger a hook with all registered handlers, ISOLATING failures: a + * handler that throws is logged and the remaining handlers still run. + * + * Use this for notification-style hooks, where one subscriber's failure + * must not deny the others their notification. It is the WRONG dispatcher + * for a hook that carries boot assertions — a swallowed throw there turns + * "this deployment refuses to start misconfigured" into a log line nobody + * reads. Those hooks use {@link triggerHookOrThrow} (#5170). + * * @param name - Hook name * @param args - Arguments to pass to handlers */ @@ -268,6 +276,43 @@ export abstract class ObjectKernelBase { } } + /** + * Trigger a hook with all registered handlers, PROPAGATING the first + * failure: the remaining handlers do not run and the original error + * reaches the caller unwrapped. + * + * This is the dispatch semantics `ObjectKernel` has always had for every + * lifecycle hook (its `context.trigger` is a bare awaited loop that never + * catches). `LiteKernel` used the isolating {@link triggerHook} for all of + * them, so one hook name meant two opposite things depending on which + * kernel booted the same plugin code (#5170). `kernel:ready` is the hook + * where that divergence bites: it is the only correct moment for a plugin + * to assert that the preconditions it declared were actually met (the + * registries are still filling during `init()`), so "declared but not + * deliverable ⇒ refuse to boot" gates live there — and on LiteKernel, + * which is what vitest/serverless/edge run, they were being downgraded to + * an error log while the process carried on serving traffic without the + * guarantee it claimed. + * + * Deliberately NOT applied to the other hook names: #5170 rules + * `kernel:ready` only, and a notification hook keeping fail-soft dispatch + * is a separate judgement per hook, not a side effect of this one. + * + * @param name - Hook name + * @param args - Arguments to pass to handlers + */ + protected async triggerHookOrThrow(name: string, ...args: any[]): Promise { + const handlers = this.hooks.get(name) || []; + this.logger.debug(`Triggering hook: ${name}`, { + hook: name, + handlerCount: handlers.length, + }); + + for (const handler of handlers) { + await handler(...args); + } + } + /** * Get current kernel state */ diff --git a/packages/core/src/kernel.test.ts b/packages/core/src/kernel.test.ts index 1f023c5eb9..e712a50c24 100644 --- a/packages/core/src/kernel.test.ts +++ b/packages/core/src/kernel.test.ts @@ -605,6 +605,37 @@ describe('ObjectKernel', () => { expect(order).toEqual(['kernel:ready', 'kernel:bootstrapped', 'kernel:listening']); }); + // #5170 — the ObjectKernel HALF of the cross-kernel pin. `kernel:ready` + // is where plugins assert that what they declared is actually + // deliverable, so a throw there must fail the boot. LiteKernel has the + // twin of this test (lite-kernel.test.ts); the pair is what keeps one + // hook name from meaning two opposite things again. + it('fails the boot when a kernel:ready handler throws (#5170)', async () => { + const reached: string[] = []; + const plugin: Plugin = { + name: 'ready-thrower-plugin', + version: '1.0.0', + init: async (ctx) => { + ctx.hook('kernel:ready', async () => { + throw new Error('declared a durable queue, got none'); + }); + ctx.hook('kernel:ready', async () => { reached.push('later-ready'); }); + ctx.hook('kernel:bootstrapped', async () => { reached.push('kernel:bootstrapped'); }); + ctx.hook('kernel:listening', async () => { reached.push('kernel:listening'); }); + }, + }; + + await kernel.use(plugin); + + // The ORIGINAL error surfaces — not a wrapped "bootstrap failed". + await expect(kernel.bootstrap()).rejects.toThrow('declared a durable queue, got none'); + + // Boot stopped AT the failing handler: no later ready handler, and + // neither of the anchors that promise "every ready handler settled". + expect(reached).toEqual([]); + expect(kernel.getState()).toBe('stopped'); + }); + it('should trigger shutdown hook', async () => { let hookCalled = false; diff --git a/packages/core/src/lite-kernel.test.ts b/packages/core/src/lite-kernel.test.ts index a27ba20bc5..c6d99097ac 100644 --- a/packages/core/src/lite-kernel.test.ts +++ b/packages/core/src/lite-kernel.test.ts @@ -270,5 +270,66 @@ describe('LiteKernel with Configurable Logger', () => { expect(order).toEqual(['kernel:ready', 'kernel:bootstrapped', 'kernel:listening']); }); + + // #5170 — the LiteKernel half of the cross-kernel pin. This is the + // behaviour the issue changed: the throw used to be caught inside + // `triggerHook`, logged as `Hook handler failed: kernel:ready`, and the + // boot carried on to report "✅ Bootstrap complete". A vitest / + // serverless / edge host therefore came up WITHOUT the guarantee its + // ready handler had just refused to give it. Same hook, same plugin + // code, same answer on both kernels now. + it('fails the boot when a kernel:ready handler throws (#5170)', async () => { + const reached: string[] = []; + const plugin: Plugin = { + name: 'ready-thrower-plugin', + init: async (ctx) => { + ctx.hook('kernel:ready', async () => { + throw new Error('declared a durable queue, got none'); + }); + ctx.hook('kernel:ready', async () => { reached.push('later-ready'); }); + ctx.hook('kernel:bootstrapped', async () => { reached.push('kernel:bootstrapped'); }); + ctx.hook('kernel:listening', async () => { reached.push('kernel:listening'); }); + }, + }; + + kernel.use(plugin); + + // The ORIGINAL error surfaces — not a wrapped "bootstrap failed". + await expect(kernel.bootstrap()).rejects.toThrow('declared a durable queue, got none'); + + // Boot stopped AT the failing handler: no later ready handler, and + // neither of the anchors that promise "every ready handler settled". + expect(reached).toEqual([]); + expect(kernel.getState()).toBe('stopped'); + }); + + // The other side of the same contract: #5170 rules `kernel:ready` ONLY. + // The notification-style hooks keep LiteKernel's isolating dispatch, so + // one failing subscriber does not deny the others their notification. + // Pinned so a future "unify everything" reading of #5170 has to be a + // deliberate change with its own issue, not a silent widening. + it('keeps fail-soft dispatch for hooks other than kernel:ready (#5170)', async () => { + const reached: string[] = []; + const plugin: Plugin = { + name: 'other-hook-thrower-plugin', + init: async (ctx) => { + ctx.hook('kernel:bootstrapped', async () => { throw new Error('bootstrapped boom'); }); + ctx.hook('kernel:bootstrapped', async () => { reached.push('later-bootstrapped'); }); + ctx.hook('kernel:listening', async () => { throw new Error('listening boom'); }); + ctx.hook('kernel:listening', async () => { reached.push('later-listening'); }); + ctx.hook('kernel:shutdown', async () => { throw new Error('shutdown boom'); }); + ctx.hook('kernel:shutdown', async () => { reached.push('later-shutdown'); }); + }, + }; + + kernel.use(plugin); + + await expect(kernel.bootstrap()).resolves.toBeUndefined(); + expect(kernel.getState()).toBe('running'); + expect(reached).toEqual(['later-bootstrapped', 'later-listening']); + + await expect(kernel.shutdown()).resolves.toBeUndefined(); + expect(reached).toContain('later-shutdown'); + }); }); }); diff --git a/packages/core/src/lite-kernel.ts b/packages/core/src/lite-kernel.ts index ddeae3fcb6..68d96cfd64 100644 --- a/packages/core/src/lite-kernel.ts +++ b/packages/core/src/lite-kernel.ts @@ -80,8 +80,23 @@ export class LiteKernel extends ObjectKernelBase { await this.runPluginStart(plugin); } - // Trigger ready hook (route/middleware registration phase) - await this.triggerHook('kernel:ready'); + // Trigger ready hook (route/middleware registration phase). + // + // PROPAGATING dispatch, identical to ObjectKernel's (#5170): a + // `kernel:ready` handler that throws FAILS THE BOOT on both kernels. + // This hook is where plugins assert that what they declared can + // actually be delivered — the registries are still filling during + // init(), so a boot gate has nowhere earlier to run — and a swallowed + // assertion means the process keeps serving without the guarantee it + // announced. The kernel is left 'stopped' rather than 'running', + // mirroring `ObjectKernel.bootstrap()`'s catch, so a failed boot never + // reads as a live kernel. + try { + await this.triggerHookOrThrow('kernel:ready'); + } catch (error) { + this.state = 'stopped'; + throw error; + } // Trigger bootstrapped hook — "all synchronous bootstrap has settled" // anchor, strictly after every kernel:ready handler has settled and // before any HTTP socket opens. NOTE: does not guarantee background app diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index a819d06a24..56cdbd2e1b 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -659,10 +659,13 @@ export class EmailServicePlugin implements Plugin { // // NOT awaited: a backlog of stranded mail must not hold the server off // its port, and in inline mode every row is a transport round trip. And - // self-catching rather than trusting the hook: a `kernel:ready` handler - // that throws is silently swallowed on LiteKernel (#5170), which for a - // durability sweep would mean losing the report of the very failure it - // exists to prevent. + // self-catching BECAUSE it is not awaited: a `kernel:ready` handler that + // throws now fails the boot on BOTH kernels (#5170 unified that), but + // this sweep is detached from the handler, so an escaping rejection + // would surface as an unhandled rejection instead — losing the report of + // the very failure it exists to prevent. A stranded-row report is also + // not grounds to refuse an otherwise healthy boot; the gate above is + // where this plugin refuses. if (persistence) { const svc = this.service; this.outboxSweepSettled = (async () => {