Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/kernel-ready-unified-failure.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions content/docs/kernel/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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()`:
Expand Down
47 changes: 46 additions & 1 deletion packages/core/src/kernel-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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<void> {
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
*/
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
61 changes: 61 additions & 0 deletions packages/core/src/lite-kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
19 changes: 17 additions & 2 deletions packages/core/src/lite-kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions packages/plugins/plugin-email/src/email-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading