// ── Boot and shutdown dispatch ───────────────────────────────────────────── // // Phase 2, PR 5. The contract is MODULE_API.md §2.5 (when the hooks run, in what // order, with what budget), §4.4 (a failure after mounting is a 503, not a // crash), §4.5 (a disabled module is guarded, never unmounted) and // MODULE_SYSTEM.md §2.4 (what a boot does to installed_modules). // // The property under test throughout, as in moduleLoader.test.js and // moduleSchema.test.js: **the failing module fails alone.** A hook that throws, // a hook that hangs, a row that will not write — none of them may cost the site // its boot or the next module its start. // // No database is involved: `boot()` takes the model as an injectable dependency // for exactly the reason `replayFragments` takes its query, and the fake below // records every call so the ORDER of the reconcile can be asserted — which is // the whole design, not an implementation detail. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const fs = require('fs') const os = require('os') const path = require('path') const { test, beforeEach, after } = require('node:test') const assert = require('node:assert/strict') const express = require('express') const db = require('../src/utils/db') const registries = require('../src/modules/registries') const lifecycle = require('../src/modules/lifecycle') const { startApp } = require('./_helper') after(() => db.close()) let tmpRoot const emptyTiers = () => ({ public: express.Router(), admin: express.Router(), player: express.Router(), }) function freshLoader(dir, tiers = emptyTiers()) { process.env.MODULES_DIR = dir registries._reset() delete require.cache[require.resolve('../src/modules/loader')] // eslint-disable-next-line global-require const loader = require('../src/modules/loader') loader.load(tiers) return loader } /** * A module whose hooks report themselves into a file. * * A file rather than a shared array because the module is `require`d from disk * and cannot close over anything this file owns — the same trick the ctx probe * in moduleLoader.test.js uses. */ function writeModule(id, { boot, shutdown, mounts, log: logFile } = {}) { const dir = path.join(tmpRoot, id) fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({ id, name: `Module ${id}`, version: '1.2.3', coreApi: '^1.0.0', server: 'index.js', ...(mounts === undefined ? {} : { mounts }), })) const note = logFile ? `const note = (what) => require('fs').appendFileSync(${JSON.stringify(logFile)}, what + '\\n')` : 'const note = () => {}' const register = mounts === undefined ? '' : ` const r = ctx.express.Router() r.get('/', (req, res) => res.json({ ok: true })) api.registerRoutes({ public: { '${(mounts.public || [])[0]}': r } })` // `boot: ''` means "registers a hook that does nothing", which is a different // module from one that registers no hook at all — hence the undefined check // rather than a truthiness test. fs.writeFileSync(path.join(dir, 'index.js'), `${note} module.exports = (ctx, api) => {${register} ${boot === undefined ? '' : `api.onBoot(async (c) => { note('boot:${id}' + (c && c.moduleId === '${id}' ? ':ctx' : ':NOCTX')); ${boot} })`} ${shutdown === undefined ? '' : `api.onShutdown(async () => { note('shutdown:${id}'); ${shutdown} })`} }`) return dir } /** An in-memory stand-in for model/modules/modules.model.js. */ function fakeModel(seed = []) { const rows = new Map(seed.map((r) => [r.id, { failureStage: null, failureReason: null, ...r }])) const calls = [] const model = { rows, calls, async beginBoot() { calls.push('beginBoot') for (const row of rows.values()) { if (row.state === 'disabled') continue Object.assign(row, { state: 'enabled', failureStage: null, failureReason: null }) } }, async recordInstalled({ id, name, version }) { calls.push(`recordInstalled:${id}`) const row = rows.get(id) // Metadata is refreshed; state is deliberately left alone (§2.4). if (row) Object.assign(row, { name, version }) else rows.set(id, { id, name, version, state: 'installed', failureStage: null, failureReason: null }) }, async list() { calls.push('list') return [...rows.values()] }, async markStarted(id) { calls.push(`markStarted:${id}`) Object.assign(rows.get(id), { state: 'started', failureStage: null, failureReason: null }) }, async markStartupFailed(id, { stage, reason }) { calls.push(`markStartupFailed:${id}`) const row = rows.get(id) // The model's own softening, reproduced because tests below depend on it: // a disabled row is a no-op, or an outcome would overwrite the operator's // decision and silently re-enable the module next boot. if (!row || row.state === 'disabled') return Object.assign(row, { state: 'startup_failed', failureStage: stage, failureReason: reason }) }, async disable(id) { calls.push(`disable:${id}`) const row = rows.get(id) if (row) Object.assign(row, { state: 'disabled', failureStage: null, failureReason: null }) }, } return model } const stateOf = (loader, id) => loader.list().find((m) => m.id === id) const noted = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n') : []) beforeEach(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-lifecycle-')) }) // ── The happy path ───────────────────────────────────────────────────────── test('the site\'s module is recorded, booted with its ctx and marked started', async () => { const file = path.join(tmpRoot, 'log.txt') writeModule('aaa', { boot: '', log: file }) const loader = freshLoader(tmpRoot) const model = fakeModel() await lifecycle.boot({ modules: loader, model }) // The reconcile order IS the design (§2.4): clear the last boot's outcomes // first, so what is on display afterwards is what this boot did. assert.equal(model.calls[0], 'beginBoot') assert.equal(model.calls[1], 'recordInstalled:aaa') assert.deepEqual(noted(file), ['boot:aaa:ctx']) assert.equal(stateOf(loader, 'aaa').state, 'started') assert.equal(model.rows.get('aaa').state, 'started') // §2.4's metadata refresh: the row carries what the admin screen shows. assert.equal(model.rows.get('aaa').name, 'Module aaa') assert.equal(model.rows.get('aaa').version, '1.2.3') }) test('a second module on the volume gets a failed row and is never booted', async () => { // W19. Hand-copied beside the site's module, it still needs a row: without one // Admin → Modules could not say why it is not running. const file = path.join(tmpRoot, 'log.txt') writeModule('aaa', { boot: '', log: file }) writeModule('bbb', { boot: '', log: file }) const loader = freshLoader(tmpRoot) const model = fakeModel() await lifecycle.boot({ modules: loader, model }) assert.deepEqual(noted(file), ['boot:aaa:ctx']) assert.equal(model.rows.get('aaa').state, 'started') const refused = model.rows.get('bbb') assert.equal(refused.state, 'startup_failed') assert.equal(refused.failureStage, 'manifest') assert.match(refused.failureReason, /already runs the module "aaa", and a site runs one module/) assert.equal(refused.name, 'Module bbb') }) test('a hand-placed directory gets a row with no provenance', async () => { // §2.5 keeps a directory dropped on the volume by hand a supported install. // Without a row it could never be disabled, and nothing could report it. writeModule('byhand', { boot: '' }) const model = fakeModel() await lifecycle.boot({ modules: freshLoader(tmpRoot), model }) const row = model.rows.get('byhand') assert.equal(row.state, 'started') assert.equal(row.source ?? null, null) assert.equal(row.sha256 ?? null, null) }) test('a module with no onBoot still reaches started', async () => { writeModule('quiet', {}) const loader = freshLoader(tmpRoot) const model = fakeModel() await lifecycle.boot({ modules: loader, model }) // Nothing to warm up is not the same as never having started: the guard lets // its routes through, so the row has to agree that it is serving. assert.equal(stateOf(loader, 'quiet').state, 'started') assert.equal(model.rows.get('quiet').state, 'started') }) // ── Failure is a state ───────────────────────────────────────────────────── test('an onBoot that throws is a failed state, not a failed boot', async () => { const file = path.join(tmpRoot, 'log.txt') writeModule('bbb', { boot: 'throw new Error("cache warm-up failed")', log: file }) const loader = freshLoader(tmpRoot) const model = fakeModel() await assert.doesNotReject(() => lifecycle.boot({ modules: loader, model })) assert.deepEqual(noted(file), ['boot:bbb:ctx']) assert.equal(stateOf(loader, 'bbb').state, 'startup_failed') assert.equal(stateOf(loader, 'bbb').stage, 'boot') assert.match(stateOf(loader, 'bbb').reason, /cache warm-up failed/) assert.equal(model.rows.get('bbb').failureStage, 'boot') }) test('a module whose onBoot failed keeps its URLs and answers 503', async () => { // §4.4's right-hand column, reached by the real mechanism rather than a // hand-moved state: routes.manifest.json must not depend on whether a boot // hook happened to succeed on the machine that generated it. writeModule('svc', { boot: 'throw new Error("no")', mounts: { public: ['/widgets'] } }) const tiers = emptyTiers() const loader = freshLoader(tmpRoot, tiers) const app = await startApp((a) => a.use('/public', tiers.public)) try { assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200) await lifecycle.boot({ modules: loader, model: fakeModel() }) assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503) } finally { await app.close() } }) test('a failure from load or schema replay is written down with its stage', async () => { // Both happen before the database is reachable — load() at require time, the // replay inside ensureSchema — so the boot reconcile is where they land. writeModule('bad', {}) fs.writeFileSync(path.join(tmpRoot, 'bad', 'module.json'), JSON.stringify({ id: 'bad', name: 'bad', version: '1.0.0', coreApi: '^99.0.0', })) const loader = freshLoader(tmpRoot) const model = fakeModel() await lifecycle.boot({ modules: loader, model }) const row = model.rows.get('bad') assert.equal(row.state, 'startup_failed') assert.equal(row.failureStage, 'core_api') assert.match(row.failureReason, /needs core API \^99\.0\.0/) }) // ── The operator's switch ────────────────────────────────────────────────── test('a disabled row guards the module, skips its hook and is not overwritten', async () => { const file = path.join(tmpRoot, 'log.txt') writeModule('off', { boot: '', shutdown: '', mounts: { public: ['/widgets'] }, log: file }) const tiers = emptyTiers() const loader = freshLoader(tmpRoot, tiers) const model = fakeModel([{ id: 'off', name: 'Module off', version: '1.2.3', state: 'disabled' }]) const app = await startApp((a) => a.use('/public', tiers.public)) try { await lifecycle.boot({ modules: loader, model }) // §4.5's 404 leg, unreachable until this reconcile existed: mounted and // guarded, never unmounted, so the URL surface stays a property of the // volume rather than of a database row. assert.equal((await fetch(`${app.url}/public/widgets`)).status, 404) assert.equal(stateOf(loader, 'off').state, 'disabled') assert.deepEqual(noted(file), [], 'a disabled module must not be booted') // Still disabled: an outcome must never overwrite a decision, or the next // boot would silently switch it back on. assert.equal(model.rows.get('off').state, 'disabled') // And nothing to tear down, because it never started. await lifecycle.shutdown({ modules: loader }) assert.deepEqual(noted(file), []) } finally { await app.close() } }) test('a row whose directory is gone is marked failed rather than left claiming enabled', async () => { writeModule('here', { boot: '' }) const model = fakeModel([ { id: 'here', name: 'Module here', version: '1.2.3', state: 'started' }, { id: 'gone', name: 'Module gone', version: '0.9.0', state: 'started' }, // An uninstall leaves `disabled`, which beginBoot never touches — so this // one is not an anomaly and must be left exactly as the operator left it. { id: 'uninstalled', name: 'Module uninstalled', version: '0.1.0', state: 'disabled' }, ]) await lifecycle.boot({ modules: freshLoader(tmpRoot), model }) assert.equal(model.rows.get('here').state, 'started') assert.equal(model.rows.get('gone').state, 'startup_failed') assert.match(model.rows.get('gone').failureReason, /not present on the volume/) assert.equal(model.rows.get('uninstalled').state, 'disabled') }) // ── The site comes up regardless ─────────────────────────────────────────── test('a database that will not take the bookkeeping still boots the modules', async () => { // A row that will not update is bad — the admin panel shows the wrong thing — // but it is strictly less bad than a site that will not start. const file = path.join(tmpRoot, 'log.txt') writeModule('aaa', { boot: '', log: file }) const loader = freshLoader(tmpRoot) const model = fakeModel() for (const name of ['beginBoot', 'recordInstalled', 'list', 'markStarted']) { model[name] = async () => { throw new Error('ER_LOCK_WAIT_TIMEOUT') } } await assert.doesNotReject(() => lifecycle.boot({ modules: loader, model })) assert.deepEqual(noted(file), ['boot:aaa:ctx']) assert.equal(stateOf(loader, 'aaa').state, 'started') }) test('a process that never scanned writes nothing at all', async () => { // `npm run seed` is exactly this: it calls ensureSchema() without ever // requiring app.js. Reconciling against an empty scan would mark every // installed module as missing from the volume. process.env.MODULES_DIR = tmpRoot delete require.cache[require.resolve('../src/modules/loader')] // eslint-disable-next-line global-require const unscanned = require('../src/modules/loader') const model = fakeModel([{ id: 'real', name: 'Module real', version: '1.0.0', state: 'started' }]) await lifecycle.boot({ modules: unscanned, model }) assert.deepEqual(model.calls, []) assert.equal(model.rows.get('real').state, 'started') }) // ── Shutdown ─────────────────────────────────────────────────────────────── test('shutdown runs a started module and skips one whose boot failed', async () => { for (const [boot, expected] of [ ['', ['shutdown:aaa']], // Its onBoot threw, so it has a half-built world that its onShutdown was // never written to tear down. ['throw new Error("no")', []], ]) { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-lifecycle-')) const file = path.join(tmpRoot, 'log.txt') writeModule('aaa', { boot, shutdown: '', log: file }) const loader = freshLoader(tmpRoot) await lifecycle.boot({ modules: loader, model: fakeModel() }) fs.writeFileSync(file, '') // only the shutdown half is under test await lifecycle.shutdown({ modules: loader }) assert.deepEqual(noted(file).filter(Boolean), expected) } }) test('a hook that hangs costs its budget, not the shutdown', async () => { const file = path.join(tmpRoot, 'log.txt') writeModule('zzz', { boot: '', shutdown: 'await new Promise(() => {})', log: file }) const loader = freshLoader(tmpRoot) await lifecycle.boot({ modules: loader, model: fakeModel() }) fs.writeFileSync(file, '') const started = Date.now() await lifecycle.shutdown({ modules: loader, budgetMs: 50 }) // zzz never returns, and is abandoned. The alternative is a host where // `systemctl stop` hangs until SIGKILL. assert.deepEqual(noted(file), ['shutdown:zzz']) assert.ok(Date.now() - started < 2000, 'shutdown must not wait on a hung hook') }) test('a hook that throws does not fail the shutdown', async () => { const file = path.join(tmpRoot, 'log.txt') writeModule('zzz', { boot: '', shutdown: 'throw new Error("close failed")', log: file }) const loader = freshLoader(tmpRoot) await lifecycle.boot({ modules: loader, model: fakeModel() }) fs.writeFileSync(file, '') await assert.doesNotReject(() => lifecycle.shutdown({ modules: loader })) assert.deepEqual(noted(file), ['shutdown:zzz']) }) // ── stop(): the admin panel's Disable ────────────────────────────────────── // // Phase 4, §2.7.2 decision 3. Phase 2's disable moved a record and left the // module running; the whole point of these tests is that it no longer does. test('stop runs the module\'s onShutdown and disables it', async () => { const file = path.join(tmpRoot, 'log.txt') writeModule('aaa', { boot: '', shutdown: '', log: file }) const loader = freshLoader(tmpRoot) const model = fakeModel() await lifecycle.boot({ modules: loader, model }) fs.writeFileSync(file, '') const result = await lifecycle.stop('aaa', { modules: loader, model }) assert.deepEqual(result, { stopped: true, error: null }) assert.deepEqual(noted(file), ['shutdown:aaa']) assert.equal(stateOf(loader, 'aaa').state, 'disabled') assert.equal(model.rows.get('aaa').state, 'disabled') }) test('stop moves the record only after the hook has run', async () => { // While onShutdown runs, the module is still `started` — the only state in // which its routes and the world it is tearing down agree with each other. The // module reports its own view of itself, so a record moved too early shows up // here as `disabled` instead of `started`. const file = path.join(tmpRoot, 'log.txt') const dir = path.join(tmpRoot, 'aaa') fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({ id: 'aaa', name: 'A', version: '1.0.0', coreApi: '^1.0.0', server: 'index.js', })) fs.writeFileSync(path.join(dir, 'index.js'), ` module.exports = (ctx, api) => { api.onBoot(async () => {}) api.onShutdown(async () => { const loader = require(${JSON.stringify(require.resolve('../src/modules/loader'))}) const me = loader.list().find((m) => m.id === 'aaa') require('fs').appendFileSync(${JSON.stringify(file)}, 'state-during-hook:' + me.state + ${JSON.stringify('\n')}) }) }`) const loader = freshLoader(tmpRoot) await lifecycle.boot({ modules: loader, model: fakeModel() }) await lifecycle.stop('aaa', { modules: loader, model: fakeModel([{ id: 'aaa', state: 'started' }]) }) assert.deepEqual(noted(file), ['state-during-hook:started']) assert.equal(stateOf(loader, 'aaa').state, 'disabled') }) test('a hook that throws does not prevent the disable', async () => { // The opposite of the boot path's rule, deliberately. There, a failure means // the module never became safe to use; here, the operator has asked for it to // stop answering and a module that could not close cleanly is a reason to log // loudly, not a reason to leave it serving. writeModule('aaa', { boot: '', shutdown: 'throw new Error("socket stuck")' }) const loader = freshLoader(tmpRoot) const model = fakeModel() await lifecycle.boot({ modules: loader, model }) const result = await lifecycle.stop('aaa', { modules: loader, model }) assert.equal(result.stopped, false) assert.match(result.error, /socket stuck/) assert.equal(stateOf(loader, 'aaa').state, 'disabled', 'disabled anyway') assert.equal(model.rows.get('aaa').state, 'disabled') }) test('a hook that hangs costs its budget, and the module is still disabled', async () => { writeModule('aaa', { boot: '', shutdown: 'await new Promise(() => {})' }) const loader = freshLoader(tmpRoot) const model = fakeModel() await lifecycle.boot({ modules: loader, model }) const started = Date.now() const result = await lifecycle.stop('aaa', { modules: loader, model, budgetMs: 50 }) assert.equal(result.stopped, false) assert.match(result.error, /budget/) assert.equal(stateOf(loader, 'aaa').state, 'disabled') assert.ok(Date.now() - started < 2000) }) test('stopping a module with no onShutdown still disables it', async () => { // A hookless module has nothing to run and must still stop answering, or the // guard and the row disagree about what is serving. writeModule('aaa', { boot: '' }) const loader = freshLoader(tmpRoot) const model = fakeModel() await lifecycle.boot({ modules: loader, model }) const result = await lifecycle.stop('aaa', { modules: loader, model }) assert.deepEqual(result, { stopped: false, error: null }) assert.equal(stateOf(loader, 'aaa').state, 'disabled') assert.equal(model.rows.get('aaa').state, 'disabled') }) test('stopping a module whose onBoot failed does not run its onShutdown', async () => { // Same rule shutdownHooks() applies: a module that never finished warming up // has a half-built world its onShutdown was not written for. It is still // disabled — it just is not asked to tear anything down. const file = path.join(tmpRoot, 'log.txt') writeModule('aaa', { boot: 'throw new Error("no")', shutdown: '', log: file }) const loader = freshLoader(tmpRoot) const model = fakeModel() await lifecycle.boot({ modules: loader, model }) const before = noted(file) const result = await lifecycle.stop('aaa', { modules: loader, model }) assert.equal(result.stopped, false) // Compared against what was already there rather than against an empty file: // `noted` splits, so a blanked file reads as [''] and an emptiness assertion // would pass for the wrong reason. assert.deepEqual(noted(file), before, 'no new hook output') assert.ok(!noted(file).includes('shutdown:aaa')) assert.equal(stateOf(loader, 'aaa').state, 'disabled') }) test('stopping an unknown id writes the row and does not throw', async () => { // A row can exist for a module that is not on the volume, and disabling it is // exactly what an operator would do about that. writeModule('aaa', { boot: '' }) const loader = freshLoader(tmpRoot) const model = fakeModel([{ id: 'ghost', state: 'startup_failed' }]) await assert.doesNotReject(() => lifecycle.stop('ghost', { modules: loader, model })) assert.equal(model.rows.get('ghost').state, 'disabled') }) test('a row that will not write does not stop the module from being disabled', async () => { // The same rule the boot path follows: bookkeeping failure is not the // operation failing. The guard is what stops traffic, and it has already moved. writeModule('aaa', { boot: '', shutdown: '' }) const loader = freshLoader(tmpRoot) const model = fakeModel() await lifecycle.boot({ modules: loader, model }) model.disable = async () => { throw new Error('database is gone') } await assert.doesNotReject(() => lifecycle.stop('aaa', { modules: loader, model })) assert.equal(stateOf(loader, 'aaa').state, 'disabled') })