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
13 changes: 13 additions & 0 deletions .changeset/22272-kernel-boot-phase-hooks-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/core": minor
---

feat(core): `ObjectKernelConfig.bootPhaseHooks`, a declared option to bootstrap without dispatching `kernel:ready`, `kernel:bootstrapped` and `kernel:listening`

Clause-②: yes (widening)

- **What is new.** `new ObjectKernel({ bootPhaseHooks: false })` runs dependency ordering, every plugin's `init()`, the core-service fallbacks, every plugin's `start()` and the system-requirement check exactly as before. It then stops without dispatching the three boot-phase hooks, and the kernel is `running`. Only an explicit `false` withholds them. An absent option or `undefined` keeps today's boot, unchanged. `Runtime` forwards the option through its existing `kernel` config.
- **Who it is for.** A host that needs a kernel's registered definitions and started services, and nothing the boot phase does. The example is a repair kernel for an environment whose normal boot cannot finish because a boot-phase handler throws, never settles, or outlasts the host's timeout. Such a host no longer has to wrap the kernel's private `context.hook` to drop those names.
- **What it guarantees.** Every service and every definition a plugin registers in `init()` or `start()` is present, a driver it connects there included. `shutdown()` is unchanged: `kernel:shutdown`, every `destroy()` and every `onShutdown()` handler run.
- **What it does not guarantee.** That the definitions equal a full boot's. A definition a plugin registers in a boot-phase handler is absent, and nothing a plugin does in those handlers runs: seeds, reconciles, schedulers, audits, and the HTTP listener a server plugin opens on `kernel:listening`. Whether a given set of plugins registers anything there is a reading of that set, not a property of the option. The option withholds the kernel's own dispatch, not the hook names.
- **Loud, once.** A withholding boot logs one `warn` naming each withheld hook and how many registered handlers it left uncalled, with the counts also in the line's `withheldHandlers` field. Its completion line says the hooks were withheld.
240 changes: 240 additions & 0 deletions packages/core/src/kernel.boot-phase-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { Mock } from 'vitest';
import { ObjectKernel } from './kernel';
import type { ObjectKernelConfig } from './kernel';
import type { Plugin } from './types';

/**
* `ObjectKernelConfig.bootPhaseHooks` — a kernel bootstrapped WITHOUT the three
* boot-phase hooks, for a host that needs the registered definitions and the
* started services and nothing the boot phase does (a repair kernel for an
* environment whose normal boot cannot finish).
*
* Both halves of the option's docblock are pinned here, against one
* composition:
*
* - what it GUARANTEES: a definition a plugin registers in `init()` or
* `start()` is present, and the kernel is running;
* - what it does NOT: a definition a plugin registers in a `kernel:ready`
* handler is absent under the option and present without it. "No ready
* handler registers a definition" is a reading of one set of plugins,
* never a property of the option, so the absent half is a pin, not a
* footnote.
*
* The controls run the same composition with the option absent, `true` and
* `undefined`: today's boot, unchanged.
*/

const BOOT_PHASE_HOOKS = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const;
type BootPhaseHook = (typeof BOOT_PHASE_HOOKS)[number];

/** The "definition registry" the composition's plugins write to. */
type Definitions = Map<string, { registeredIn: string }>;

const REGISTRY = 'test.definition-registry';

function newKernel(extra: ObjectKernelConfig = {}): ObjectKernel {
return new ObjectKernel({
logger: { level: 'error' },
gracefulShutdown: false,
skipSystemValidation: true,
...extra,
});
}

/**
* A registry plugin plus a declarer that registers one definition per place a
* plugin can register one — `init()`, `start()`, a `kernel:ready` handler —
* and a spy on each of the three boot-phase hooks.
*/
function composition() {
const spies: Record<BootPhaseHook, Mock<() => void>> = {
'kernel:ready': vi.fn<() => void>(),
'kernel:bootstrapped': vi.fn<() => void>(),
'kernel:listening': vi.fn<() => void>(),
};
const order: string[] = [];

const registry: Plugin = {
name: REGISTRY,
version: '1.0.0',
init: async (ctx) => {
ctx.registerService('definitions', new Map() as Definitions);
},
};

const declarer: Plugin = {
name: 'test.declarer',
version: '1.0.0',
dependencies: [REGISTRY],
init: async (ctx) => {
const definitions = ctx.getService<Definitions>('definitions');
definitions.set('obj_from_init', { registeredIn: 'init' });
ctx.hook('kernel:ready', async () => {
definitions.set('obj_from_ready', { registeredIn: 'kernel:ready' });
});
for (const hook of BOOT_PHASE_HOOKS) {
ctx.hook(hook, async () => {
order.push(hook);
spies[hook]();
});
}
},
start: async (ctx) => {
ctx.getService<Definitions>('definitions').set('obj_from_start', { registeredIn: 'start' });
},
};

return { plugins: [registry, declarer], spies, order };
}

async function boot(kernel: ObjectKernel, plugins: Plugin[]): Promise<Definitions> {
for (const plugin of plugins) await kernel.use(plugin);
await kernel.bootstrap();
return kernel.getService<Definitions>('definitions');
}

const spyOnLog = (k: ObjectKernel, level: 'warn' | 'info') =>
vi.spyOn((k as unknown as { logger: Record<'warn' | 'info', (...a: unknown[]) => void> }).logger, level);

/** The `withheldHandlers` meta of every `warn` the withholding boot emitted. */
function withheldReports(warnSpy: ReturnType<typeof spyOnLog>): unknown[] {
return warnSpy.mock.calls
.map((call) => (call[1] as { withheldHandlers?: unknown } | undefined)?.withheldHandlers)
.filter((meta) => meta !== undefined);
}

describe('ObjectKernel bootPhaseHooks', () => {
const kernels: ObjectKernel[] = [];
const track = (k: ObjectKernel) => { kernels.push(k); return k; };

afterEach(async () => {
for (const k of kernels.splice(0)) {
if (k.isRunning()) await k.shutdown();
}
vi.restoreAllMocks();
});

describe('bootPhaseHooks: false', () => {
it('keeps every definition registered in init() and start(), and the kernel runs', async () => {
const { plugins } = composition();
const kernel = track(newKernel({ bootPhaseHooks: false }));

const definitions = await boot(kernel, plugins);

expect(definitions.get('obj_from_init')).toEqual({ registeredIn: 'init' });
expect(definitions.get('obj_from_start')).toEqual({ registeredIn: 'start' });
expect(kernel.isRunning()).toBe(true);
expect(kernel.getState()).toBe('running');
});

it('leaves a definition registered in a kernel:ready handler absent', async () => {
const { plugins } = composition();
const kernel = track(newKernel({ bootPhaseHooks: false }));

const definitions = await boot(kernel, plugins);

expect(definitions.has('obj_from_ready')).toBe(false);
expect([...definitions.keys()].sort()).toEqual(['obj_from_init', 'obj_from_start']);
});

it('dispatches none of kernel:ready, kernel:bootstrapped, kernel:listening', async () => {
const { plugins, spies, order } = composition();
const kernel = track(newKernel({ bootPhaseHooks: false }));

await boot(kernel, plugins);

for (const hook of BOOT_PHASE_HOOKS) {
expect(spies[hook], hook).not.toHaveBeenCalled();
}
expect(order).toEqual([]);
});

// The case the option exists for: a boot-phase handler that throws, or
// never settles, no longer decides whether the definitions are reachable.
it('completes the boot when a kernel:ready handler would throw or never settle', async () => {
const kernel = track(newKernel({ bootPhaseHooks: false }));
const stuck: Plugin = {
name: 'test.stuck-boot-phase',
version: '1.0.0',
init: async (ctx) => {
ctx.hook('kernel:ready', async () => { throw new Error('seed heal failed'); });
ctx.hook('kernel:bootstrapped', () => new Promise<void>(() => { /* never settles */ }));
},
};

await kernel.use(stuck);
await expect(kernel.bootstrap()).resolves.toBeUndefined();
expect(kernel.getState()).toBe('running');
});

it('reports the withheld handlers once, per hook', async () => {
const { plugins } = composition();
const kernel = track(newKernel({ bootPhaseHooks: false }));
const warnSpy = spyOnLog(kernel, 'warn');

await boot(kernel, plugins);

// The declarer registers two kernel:ready handlers (the definition
// and the spy) and one each on the other two.
expect(withheldReports(warnSpy)).toEqual([
{ 'kernel:ready': 2, 'kernel:bootstrapped': 1, 'kernel:listening': 1 },
]);
});

it('shuts down cleanly: kernel:shutdown, destroy() and onShutdown run, nothing waits on a withheld hook', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
const { plugins, spies } = composition();
const reached: string[] = [];
const teardown: Plugin = {
name: 'test.teardown',
version: '1.0.0',
init: async (ctx) => {
ctx.hook('kernel:shutdown', async () => { reached.push('kernel:shutdown'); });
},
destroy: async () => { reached.push('destroy'); },
};
// A short guard: a teardown that waited on something a withheld
// hook would have resolved trips it and calls process.exit(1).
const kernel = newKernel({ bootPhaseHooks: false, shutdownTimeout: 2_000 });
kernel.onShutdown(async () => { reached.push('onShutdown'); });

await boot(kernel, [...plugins, teardown]);
await kernel.shutdown();

expect(reached).toEqual(['kernel:shutdown', 'destroy', 'onShutdown']);
expect(kernel.getState()).toBe('stopped');
expect(exitSpy).not.toHaveBeenCalled();
for (const hook of BOOT_PHASE_HOOKS) {
expect(spies[hook], hook).not.toHaveBeenCalled();
}
});
});

// Controls: the same composition on today's boot. Only an explicit `false`
// withholds; an absent option and an explicit `undefined` (a host
// forwarding an unset value) are the default.
describe.each([
['absent', {}],
['true', { bootPhaseHooks: true }],
['undefined', { bootPhaseHooks: undefined }],
] as Array<[string, ObjectKernelConfig]>)('bootPhaseHooks %s (control)', (_label, extra) => {
it('fires all three hooks in order and keeps every definition, the kernel:ready one included', async () => {
const { plugins, spies, order } = composition();
const kernel = track(newKernel(extra));
const warnSpy = spyOnLog(kernel, 'warn');

const definitions = await boot(kernel, plugins);

expect(order).toEqual([...BOOT_PHASE_HOOKS]);
for (const hook of BOOT_PHASE_HOOKS) {
expect(spies[hook], hook).toHaveBeenCalledTimes(1);
}
expect([...definitions.keys()].sort()).toEqual(['obj_from_init', 'obj_from_ready', 'obj_from_start']);
expect(withheldReports(warnSpy)).toEqual([]);
expect(kernel.isRunning()).toBe(true);
});
});
});
89 changes: 89 additions & 0 deletions packages/core/src/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ import { raceWithTimeout } from './timeout-guard.js';
*/
const DEGRADED_CAPABILITIES_SERVICE = 'kernel.degraded-capabilities';

/**
* The three hooks `bootstrap()` dispatches once every plugin's `init()` and
* `start()` has run, in this order — the hooks
* {@link ObjectKernelConfig.bootPhaseHooks} withholds. Read only to NAME them in
* the line a withholding boot logs; the dispatch sites in `bootstrap()` keep
* their string literals, which is what `scripts/check-kernel-hook-pairs.mjs`
* reads.
*/
const BOOT_PHASE_HOOKS = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const;

/**
* Enhanced Kernel Configuration
*/
Expand All @@ -85,6 +95,51 @@ export interface ObjectKernelConfig {

/** Whether to skip strict system requirement validation (Critical for testing) */
skipSystemValidation?: boolean;

/**
* Whether `bootstrap()` dispatches the three BOOT-PHASE hooks —
* `kernel:ready`, `kernel:bootstrapped` and `kernel:listening` — once every
* plugin's `init()` and `start()` has run. Default `true`: the boot is
* exactly what it has always been. Only an explicit `false` withholds them;
* absent or `undefined` is the default.
*
* `false` is for a host that needs a kernel's registered DEFINITIONS and
* its started services, and nothing the boot phase does — for example a
* repair kernel built for an environment whose normal boot cannot finish,
* because a boot-phase handler throws, never settles, or outlasts the
* host's own timeout.
*
* **What `false` withholds:** the kernel's dispatch of those three hooks,
* and nothing else. `PluginContext.hook` still accepts a handler for them;
* the handler is never called. So whatever a plugin does in such a handler
* does not happen on this kernel: a definition it registers there is
* ABSENT, the seeds, heals, reconciles, replays, schedulers and audits it
* runs there do not run, and an HTTP server that opens its socket on
* `kernel:listening` never listens. The boot logs one `warn` naming each
* withheld hook and how many registered handlers it left uncalled, and its
* completion line says the hooks were withheld.
*
* **What it guarantees:** the rest of `bootstrap()` runs unchanged —
* dependency ordering, every plugin's `init()`, the core-service
* fallbacks, every plugin's `start()` under the same timeout and rollback
* rules, and the system-requirement check. So every service and every
* definition a plugin registers in `init()` or `start()` is present, a
* driver it connects there included, and the kernel is `running`.
* `shutdown()` is unchanged: `kernel:shutdown` is dispatched, and every
* plugin's `destroy()` and every `onShutdown()` handler runs. The kernel
* itself waits on nothing a withheld hook would have done.
*
* **What it does NOT guarantee:** that the definitions equal a full boot's.
* A plugin may register a definition in a boot-phase handler, and that
* definition is absent here. That no handler does so on a given set of
* plugins is a reading of that set, not a property of this option —
* measure it for the plugins you compose. A `destroy()` or
* `kernel:shutdown` handler that assumes its boot-phase handler ran must
* tolerate that it did not. And it withholds the KERNEL's dispatch, not
* the names: a plugin that calls `PluginContext.trigger` with one of them
* itself still runs that hook's handlers.
*/
bootPhaseHooks?: boolean;
}

/**
Expand Down Expand Up @@ -507,6 +562,17 @@ export class ObjectKernel {

// Phase 3: Trigger kernel:ready hook
this.validateSystemRequirements(); // Final check before ready

// Phases 3 to 4 are the boot-phase hooks, and nothing follows them
// but the completion line, so a host that asked for them withheld
// stops here. What that does and does not guarantee is stated on
// `ObjectKernelConfig.bootPhaseHooks`.
if (this.config.bootPhaseHooks === false) {
this.reportWithheldBootPhaseHooks();
this.logger.info('✅ Bootstrap complete (boot-phase hooks withheld: bootPhaseHooks is false)');
return;
}

this.logger.debug('Triggering kernel:ready hook');
await this.context.trigger('kernel:ready');

Expand Down Expand Up @@ -542,6 +608,29 @@ export class ObjectKernel {
}
}

/**
* The one line a `bootPhaseHooks: false` boot owes: which boot-phase hooks
* this kernel did not dispatch, and how many registered handlers each one
* left uncalled. A boot that skips work silently reads as a full boot to
* whoever reads its log, so it says so once, here.
*
* `warn`, not `error`: what is missing is functional (handlers not run),
* visible in this very line, and asked for by the host.
*/
private reportWithheldBootPhaseHooks(): void {
const withheldHandlers: Record<string, number> = {};
for (const hook of BOOT_PHASE_HOOKS) {
withheldHandlers[hook] = this.hooks.get(hook)?.length ?? 0;
}
const counts = BOOT_PHASE_HOOKS.map((hook) => `${hook}: ${withheldHandlers[hook]}`).join(', ');
this.logger.warn(
`Boot-phase hooks withheld (bootPhaseHooks is false); registered handlers not called: ${counts}. `
+ 'Nothing a plugin does in those hooks ran on this kernel, so a definition registered there is absent '
+ 'and a server that listens on kernel:listening is not listening.',
{ withheldHandlers },
);
}

/**
* Graceful shutdown with timeout
*/
Expand Down
Loading