feat(modules): boot/shutdown hook dispatch and the installed_modules reconcile
All checks were successful
PR Checks / bot-install (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 1m44s
PR Checks / client-build (pull_request) Successful in 9m1s

Phase 2, PR 5 of docs/website/MODULE_SYSTEM.md 2.7. api.onBoot/api.onShutdown
stop throwing, server.js gains one call on each side, and the 2.4 state machine
finally runs against real outcomes -- which is what makes 4.5's `disabled` 404
leg reachable for the first time.

Dispatch and reconcile live in src/modules/lifecycle.js rather than in the
loader, for the reason the schema replay does: routeManifest.js and swagger.js
both require app.js against a dead pool, so the loader may not reach the
database. The two halves meet at exactly one function, loader.setState(), so the
in-memory record the dispatch guard reads and the row the admin panel reads are
moved together and cannot disagree.

Four decisions, all recorded in MODULE_API.md 2.5 and 4.4:

- The loader classifies its failures by 4.3 step, so failure_stage says where a
  module broke instead of being a column nothing ever filled. The four steps
  readManifest covers in one pass label themselves; the rest are inferred from
  how far load() had got, and an unlabelled throw is recorded against the step
  that was running rather than guessed at.
- A row whose directory is gone is marked startup_failed rather than left
  claiming `enabled` -- the boot reset has just moved it there, and a row
  claiming to be enabled for a module that is not on the volume is the one state
  that is simply untrue. An uninstall leaves `disabled`, which the reset never
  touches, so this catches only a hand-deleted directory.
- Core's eight UO boot call sites stay in server.js until Phase 3. Unlike a
  registered announce leg, a boot call site already has somewhere to live, so
  moving it now would be extraction done early in a phase whose exit criterion
  is that nothing changes.
- onBoot gets no timeout. Shutdown races a SIGKILL and boot does not, and a slow
  onBoot delaying the listener is the contract's promise to a module that must
  warm up before it serves.

The operator's switch wins over everything: a disabled module is guarded, not
booted, and does not have its failure re-recorded, or an outcome would silently
switch it back on next boot. Every database write in the reconcile is
individually caught -- a row that will not update is worse reporting, never a
failed boot.

900 tests pass (17 new). routes.manifest.json is unchanged at 229 routes and the
OpenAPI spec regenerates byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-10 20:32:00 -05:00
parent 39eaae90a8
commit 21196466ed
6 changed files with 802 additions and 51 deletions

View File

@@ -0,0 +1,208 @@
// ── Module lifecycle dispatch ──────────────────────────────────────────────
//
// Phase 2, PR 5 of docs/website/MODULE_SYSTEM.md §2.7. Normative contract:
// docs/website/MODULE_API.md §2.5 (the hooks and when they run) and §4.4
// (failure is a state), plus MODULE_SYSTEM.md §2.4 (what a boot does to
// `installed_modules`).
//
// This is the database half of the loader, and it is a separate file for the
// same reason modules/schema.js is: `scripts/routeManifest.js` and
// `swagger/swagger.js` both require app.js with the pool pointed at a dead port,
// so loader.js may not reach the database. Everything here runs from server.js,
// after ensureSchema() has proved the database is up.
//
// The two halves meet at exactly one place — `loader.setState()` — so the
// in-memory record that the §4.5 dispatch guard reads and the row the admin
// panel reads are moved together and cannot disagree.
//
// Two properties this file exists to keep:
//
// 1. **A module's boot failure costs that module and nothing else** (§4.4).
// Its routes stay mounted and answer 503; the site comes up; the next
// module boots as if nothing happened.
// 2. **The row is a record of what happened, never the source of truth for
// what is mounted** (§2.4). Nothing here mounts, unmounts or re-scans. It
// reads the outcome of a scan that already happened and writes it down.
const log = require('../utils/logger')('modules')
// §2.5: shutdown races the process being killed, so a module that will not let
// go is logged and skipped rather than allowed to hang the exit. `onBoot` has no
// such budget on purpose — it delays the listener binding, which is the feature.
const SHUTDOWN_BUDGET_MS = 5000
/**
* Run one database call for one module without letting it become everyone's
* failure. Returns null on failure, having logged it.
*
* The boot path is the whole reason this exists. 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, and it must not stop the modules after it from
* booting.
*/
async function safe(what, fn) {
try {
return await fn()
} catch (err) {
log.error(`module bookkeeping failed: ${what}`, { error: err.message })
return null
}
}
/**
* Reconcile `installed_modules` with what the loader found, then run every
* surviving module's `onBoot`.
*
* Called once from server.js, after `ensureSchema()` (so a module's own tables
* exist) and `seedDefaults()`, and **before the HTTP listener binds** — a module
* that must not serve traffic until it has warmed a cache gets that for free
* (§2.5).
*
* The order of the four steps is the whole design:
*
* 1. `beginBoot()` clears the last boot's outcomes, so what is on display
* afterwards is what THIS boot did. `disabled` rows are left alone: that is
* an operator decision, not an outcome (§2.4).
* 2. Every module on the volume gets a row, written with null provenance if it
* does not have one — a directory placed on the volume by hand is a
* supported install (§2.4/§2.5), and without a row it could never be
* disabled or shown as failed.
* 3. Rows with no directory are marked failed, because step 1 has just reset
* them to `enabled` and a row claiming to be enabled for a module that is
* not there is the one state that is simply untrue. (A plain uninstall
* leaves `disabled`, which step 1 never touches, so this only catches a
* directory deleted by hand.)
* 4. The outcome each module already carries — disabled by the operator,
* failed during load or schema replay, or ready — is written down, and only
* then is `onBoot` dispatched.
*
* Never throws. @param {object} [deps] injection seam for tests.
*/
async function boot({ modules, model } = {}) {
/* eslint-disable global-require */
const loader = modules || require('./loader')
const rows = model || require('../model/modules/modules.model')
/* eslint-enable global-require */
// Not an error, and the same guard replayFragments carries: a process that
// never required app.js has no scan to reconcile against, and writing rows
// from an empty list would mark every installed module as missing.
if (!loader.isLoaded()) {
log.info('no module scan in this process — skipping module boot')
return
}
const scanned = loader.list()
await safe('resetting last boot\'s outcomes', () => rows.beginBoot())
for (const m of scanned) {
await safe(`recording module "${m.id}"`, () => rows.recordInstalled({
id: m.id,
name: m.name,
version: m.version,
// Null provenance is what a hand-placed directory looks like. An install
// performed through the admin panel (§2.5, a later phase) writes the row
// with its source and hash first; this refresh deliberately does not
// overwrite either, because recordInstalled leaves what it is not given.
}))
}
const stored = (await safe('reading module rows', () => rows.list())) || []
const onVolume = new Set(scanned.map((m) => m.id))
for (const row of stored) {
if (onVolume.has(row.id) || row.state === 'disabled') continue
await safe(`marking module "${row.id}" missing`, () => rows.markStartupFailed(row.id, {
stage: 'require',
reason: 'module directory not present on the volume',
}))
}
const disabled = new Set(stored.filter((r) => r.state === 'disabled').map((r) => r.id))
for (const m of scanned) {
// The operator's switch wins over everything, including a failure. Its
// routes 404 from here on (§4.5 — the leg that was unreachable until this
// PR), it is not booted, and its failure is not re-recorded: overwriting a
// deliberate `disabled` with an outcome would silently re-enable it on the
// next boot.
if (disabled.has(m.id)) {
loader.setState(m.id, 'disabled')
continue
}
if (m.state === 'startup_failed') {
// Already failed in load() or the schema replay — both of which ran before
// the database was available to write it down. This is where it lands.
await safe(`recording failure for "${m.id}"`, () => rows.markStartupFailed(m.id, {
stage: m.stage,
reason: m.reason,
}))
}
}
for (const { id, hook, ctx } of loader.bootable()) {
try {
// Awaited without a timeout, deliberately (§2.5): a slow onBoot delays the
// listener, which is the contract's promise to a module that must warm up
// before it serves. Core's own boot steps are awaited the same way.
if (hook) await hook(ctx)
loader.setState(id, 'started')
await safe(`marking module "${id}" started`, () => rows.markStarted(id))
log.info(`module "${id}" started`)
} catch (err) {
// §4.4's second column: the routes are already mounted, so they stay
// mounted and answer 503. A module that failed to warm up serving
// half-initialised data is worse than one that says it is down.
loader.setState(id, 'startup_failed', { stage: 'boot', reason: err.message })
await safe(`recording boot failure for "${id}"`, () => rows.markStartupFailed(id, {
stage: 'boot',
reason: err.message,
}))
log.error(`module "${id}" onBoot failed — its routes will answer 503`, {
reason: err.message,
})
}
}
}
/** Reject if `fn`'s promise has not settled within `ms`. */
function withBudget(fn, ms) {
let timer
const budget = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`onShutdown exceeded its ${ms}ms budget`)), ms)
})
return Promise.race([Promise.resolve().then(fn), budget]).finally(() => clearTimeout(timer))
}
/**
* Run every started module's `onShutdown`, in reverse registration order.
*
* Called from server.js's signal handler before anything core owns is closed, so
* a module still has a working database pool and push dispatcher to flush
* through. Reverse order is the mirror of boot order: a module that booted after
* another may be holding something the earlier one handed it.
*
* Never throws, and never hangs: each hook gets `SHUTDOWN_BUDGET_MS`, after
* which it is logged and abandoned. Abandoned, not cancelled — nothing can stop
* a promise that is still running — but the process is exiting anyway, and the
* alternative is a shard host where `systemctl stop` hangs until SIGKILL.
*/
async function shutdown({ modules, budgetMs = SHUTDOWN_BUDGET_MS } = {}) {
// eslint-disable-next-line global-require
const loader = modules || require('./loader')
if (!loader.isLoaded()) return
for (const { id, hook } of loader.shutdownHooks()) {
try {
await withBudget(hook, budgetMs)
log.info(`module "${id}" shut down`)
} catch (err) {
log.warn(`module "${id}" onShutdown failed or timed out — continuing`, {
error: err.message,
})
}
}
}
module.exports = { boot, shutdown, SHUTDOWN_BUDGET_MS }

View File

@@ -18,15 +18,19 @@
// boot (§4.4). // boot (§4.4).
// //
// What is deliberately NOT here yet, each landing with the PR that first calls // What is deliberately NOT here yet, each landing with the PR that first calls
// it (§2.7): the three de-entanglement registries (PR 4), boot/shutdown hook // it (§2.7): GET /api/v1/public/modules and the client chunk's static mount
// dispatch and the `installed_modules` reconcile (PR 5), GET // (PRs 6-7).
// /api/v1/public/modules and the client chunk's static mount (PRs 6-7). Until
// PR 5 the state a module carries is in memory only.
// //
// PR 3 added the fragment half of the schema story: this file VALIDATES a // PR 3 added the fragment half of the schema story: this file VALIDATES a
// fragment (statement by statement, at load time, before anything is mounted) // fragment (statement by statement, at load time, before anything is mounted)
// and publishes it through `fragments()`. Replaying it needs a database, so it // and publishes it through `fragments()`. Replaying it needs a database, so it
// belongs to modules/schema.js, which utils/db.js calls after core's schema. // belongs to modules/schema.js, which utils/db.js calls after core's schema.
//
// PR 5 added the lifecycle hooks a module registers here (`onBoot`/`onShutdown`)
// and the failure STAGE carried beside every reason. Dispatching those hooks and
// reconciling `installed_modules` need a database, so they live in
// modules/lifecycle.js for the same reason schema.js is a separate file: this one
// stays require-able against a dead pool.
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
@@ -145,11 +149,10 @@ function buildApi(record) {
if (record.called.has(name)) throw new Error(`${name}() called twice`) if (record.called.has(name)) throw new Error(`${name}() called twice`)
record.called.add(name) record.called.add(name)
} }
// PR 5 brings the boot hooks. They throw rather than no-op: an accepting stub const hook = (name) => (fn) => {
// would let a module believe it had registered something and fail silently at once(name)
// the far end. if (typeof fn !== 'function') throw new Error(`${name}: expected a function`)
const notYet = (name, pr) => () => { record.hooks[name] = fn
throw new Error(`${name}: not available until phase 2 PR ${pr}`)
} }
return { return {
registerRoutes(mounts) { registerRoutes(mounts) {
@@ -180,13 +183,34 @@ function buildApi(record) {
record.staged.registerNotificationStreams(streams) record.staged.registerNotificationStreams(streams)
}, },
registerAnnounceLeg: record.staged.registerAnnounceLeg, registerAnnounceLeg: record.staged.registerAnnounceLeg,
onBoot: notYet('onBoot', 5), // The two lifecycle hooks (§2.5). Registered here, dispatched from
onShutdown: notYet('onShutdown', 5), // lifecycle.js — this file runs with no database and the hooks run with one.
// Both are optional: a module with no warm-up and nothing to close simply
// never calls them.
onBoot: hook('onBoot'),
onShutdown: hook('onShutdown'),
} }
} }
// ── Validation ───────────────────────────────────────────────────────────── // ── Validation ─────────────────────────────────────────────────────────────
/**
* Throw with the §4.3 step that failed attached.
*
* `installed_modules.failure_stage` exists so the admin panel can say *where* a
* module broke and not only what the message was, and the model enumerates the
* eight stages (`FAILURE_STAGES`). The steps that share one function — a
* manifest read that also checks `coreApi`, the mounts and the slots — cannot be
* told apart by position in load(), so they carry their own label; everything
* else is inferred from how far load() had got. An untagged error is recorded
* against the step that was running, never guessed at.
*/
function fail(stage, message) {
const err = new Error(message)
err.stage = stage
throw err
}
// Table names a module may create despite not carrying its own id as a prefix. // Table names a module may create despite not carrying its own id as a prefix.
// //
// module-uo's twenty-seven tables predate the module system by two years, and // module-uo's twenty-seven tables predate the module system by two years, and
@@ -305,42 +329,42 @@ function readManifest(dir, id, tierRouters) {
for (const key of Object.keys(manifest)) { for (const key of Object.keys(manifest)) {
// Rejected, not ignored: a typo'd key must be a loud failure rather than a // Rejected, not ignored: a typo'd key must be a loud failure rather than a
// silently inert setting the operator believes they configured. // silently inert setting the operator believes they configured.
if (!MANIFEST_KEYS.has(key)) throw new Error(`unknown key "${key}" in module.json`) if (!MANIFEST_KEYS.has(key)) fail('manifest', `unknown key "${key}" in module.json`)
} }
if (!ID.test(manifest.id || '')) throw new Error(`invalid id "${manifest.id}"`) if (!ID.test(manifest.id || '')) fail('manifest', `invalid id "${manifest.id}"`)
if (manifest.id !== id) throw new Error(`id "${manifest.id}" does not match directory "${id}"`) if (manifest.id !== id) fail('manifest', `id "${manifest.id}" does not match directory "${id}"`)
if (!manifest.version) throw new Error('missing version') if (!manifest.version) fail('manifest', 'missing version')
if (!manifest.coreApi) throw new Error('missing coreApi') if (!manifest.coreApi) fail('core_api', 'missing coreApi')
if (!semver.satisfies(MODULE_API_VERSION, manifest.coreApi)) { if (!semver.satisfies(MODULE_API_VERSION, manifest.coreApi)) {
throw new Error(`needs core API ${manifest.coreApi}, this core is ${MODULE_API_VERSION}`) fail('core_api', `needs core API ${manifest.coreApi}, this core is ${MODULE_API_VERSION}`)
} }
for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) { for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) {
if (!TIERS.includes(tier)) throw new Error(`unknown tier "${tier}" in mounts`) if (!TIERS.includes(tier)) fail('mounts', `unknown tier "${tier}" in mounts`)
for (const prefix of prefixes) { for (const prefix of prefixes) {
if (!PREFIX.test(prefix)) throw new Error(`bad prefix "${prefix}" in mounts.${tier}`) if (!PREFIX.test(prefix)) fail('mounts', `bad prefix "${prefix}" in mounts.${tier}`)
if (ownedByCore(tierRouters[tier], prefix)) { if (ownedByCore(tierRouters[tier], prefix)) {
throw new Error(`prefix ${tier}${prefix} is owned by core`) fail('mounts', `prefix ${tier}${prefix} is owned by core`)
} }
for (const other of modules.values()) { for (const other of modules.values()) {
if ((other.manifest.mounts?.[tier] || []).includes(prefix)) { if ((other.manifest.mounts?.[tier] || []).includes(prefix)) {
throw new Error(`prefix ${tier}${prefix} already registered by module "${other.id}"`) fail('mounts', `prefix ${tier}${prefix} already registered by module "${other.id}"`)
} }
} }
} }
} }
for (const slot of manifest.extensions || []) { for (const slot of manifest.extensions || []) {
if (!registries.hasSlot(slot)) throw new Error(`unknown extension slot "${slot}"`) if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`)
} }
if (manifest.schema && !manifest.purge) { if (manifest.schema && !manifest.purge) {
// A module that can create tables and cannot drop them leaves an operator // A module that can create tables and cannot drop them leaves an operator
// with orphaned data and no supported way to remove it. // with orphaned data and no supported way to remove it.
throw new Error('declares schema but no purge') fail('schema', 'declares schema but no purge')
} }
if (manifest.purge && !fs.existsSync(path.join(dir, manifest.purge))) { if (manifest.purge && !fs.existsSync(path.join(dir, manifest.purge))) {
throw new Error(`purge file "${manifest.purge}" is missing`) fail('schema', `purge file "${manifest.purge}" is missing`)
} }
return manifest return manifest
} }
@@ -409,20 +433,34 @@ function load(tierRouters) {
staged: registries.stage(id), staged: registries.stage(id),
tables: new Set(), tables: new Set(),
called: new Set(), called: new Set(),
hooks: { onBoot: null, onShutdown: null },
ctx: null,
state: 'installed', state: 'installed',
stage: null,
reason: null, reason: null,
} }
// How far load() has got, so an untagged throw is recorded against the step
// that was actually running (§4.3's steps 5-7). The steps before it label
// themselves, because readManifest covers four of them in one pass.
let stage = 'manifest'
try { try {
record.manifest = readManifest(dir, id, tierRouters) record.manifest = readManifest(dir, id, tierRouters)
stage = 'schema'
record.tables = tablesOf(dir, record.manifest) record.tables = tablesOf(dir, record.manifest)
checkTableNames(id, record.tables) checkTableNames(id, record.tables)
if (record.manifest.server) { if (record.manifest.server) {
const entry = path.join(dir, record.manifest.server) const entry = path.join(dir, record.manifest.server)
stage = 'require'
// eslint-disable-next-line global-require, import/no-dynamic-require // eslint-disable-next-line global-require, import/no-dynamic-require
const register = require(entry) const register = require(entry)
if (typeof register !== 'function') throw new Error(`${record.manifest.server} does not export a function`) if (typeof register !== 'function') throw new Error(`${record.manifest.server} does not export a function`)
register(buildCtx(id, dir), buildApi(record)) stage = 'register'
// Kept on the record, not discarded after register(): §2.5 hands the
// same ctx to onBoot, and building a second one would be a second frozen
// object claiming to be the same handle.
record.ctx = buildCtx(id, dir)
register(record.ctx, buildApi(record))
checkDeclared(record) checkDeclared(record)
} }
record.state = 'registered' record.state = 'registered'
@@ -434,10 +472,14 @@ function load(tierRouters) {
// A failure here is BEFORE any route was mounted, so this module's routes // A failure here is BEFORE any route was mounted, so this module's routes
// and nav are simply absent and the site comes up without it (§4.4). // and nav are simply absent and the site comes up without it (§4.4).
record.state = 'startup_failed' record.state = 'startup_failed'
record.stage = err.stage || stage
record.reason = err.message record.reason = err.message
record.manifest = record.manifest || { id, version: 'unknown' } record.manifest = record.manifest || { id, version: 'unknown' }
modules.set(id, record) modules.set(id, record)
log.error(`module "${id}" failed to load — continuing without it`, { reason: err.message }) log.error(`module "${id}" failed to load — continuing without it`, {
stage: record.stage,
reason: err.message,
})
} }
} }
@@ -455,6 +497,7 @@ function load(tierRouters) {
registries.apply(record.staged.staged) registries.apply(record.staged.staged)
} catch (err) { } catch (err) {
record.state = 'startup_failed' record.state = 'startup_failed'
record.stage = 'register'
record.reason = err.message record.reason = err.message
log.error(`module "${record.id}" failed to register — continuing without it`, { log.error(`module "${record.id}" failed to register — continuing without it`, {
reason: err.message, reason: err.message,
@@ -502,20 +545,25 @@ const RECORD_STATES = new Set(['registered', 'started', 'disabled', 'startup_fai
* Move a loaded module to a new state — the POST-mount transitions. * Move a loaded module to a new state — the POST-mount transitions.
* *
* Called by whoever ran the step that failed or the step that succeeded, because * Called by whoever ran the step that failed or the step that succeeded, because
* only they can know: PR 3's `ensureSchema()` replays the fragments, PR 5's boot * only they can know: `ensureSchema()` replays the fragments (PR 3), and
* dispatch runs `onBoot` and reconciles `installed_modules` (whose `disabled` * lifecycle.js runs `onBoot` and reconciles `installed_modules` (whose `disabled`
* rows are what first make the guard's 404 leg reachable). * rows are what make the guard's 404 leg reachable).
*
* The stage travels with the reason and is cleared by every non-failing move,
* for the same reason the database columns are (§2.4): a running module must
* never be able to show a stale failure.
* *
* Unknown ids are ignored rather than thrown on: a module can be absent from the * Unknown ids are ignored rather than thrown on: a module can be absent from the
* volume and still have a row, and a caller on the boot path must not turn that * volume and still have a row, and a caller on the boot path must not turn that
* into everyone's failure. * into everyone's failure.
*/ */
function setState(id, state, reason = null) { function setState(id, state, { stage = null, reason = null } = {}) {
if (!RECORD_STATES.has(state)) throw new Error(`unknown module state "${state}"`) if (!RECORD_STATES.has(state)) throw new Error(`unknown module state "${state}"`)
const record = modules.get(id) const record = modules.get(id)
if (!record) return if (!record) return
record.state = state record.state = state
record.reason = reason record.stage = state === 'startup_failed' ? stage : null
record.reason = state === 'startup_failed' ? reason : null
} }
// ── Introspection ────────────────────────────────────────────────────────── // ── Introspection ──────────────────────────────────────────────────────────
@@ -550,11 +598,48 @@ function list() {
name: r.manifest.name || r.id, name: r.manifest.name || r.id,
version: r.manifest.version, version: r.manifest.version,
state: r.state, state: r.state,
stage: r.stage,
reason: r.reason, reason: r.reason,
capabilities: r.manifest.capabilities || [], capabilities: r.manifest.capabilities || [],
})) }))
} }
/**
* The modules that are ready to be booted, with their hook, in scan order.
*
* `registered` only — the state a module holds between a clean load and its
* `onBoot`. One that failed validation or schema replay is not going to run, and
* one already `started` has run. A module with no `onBoot` is still listed: it
* has nothing to warm up, but it still has to reach `started` so the admin panel
* and `installed_modules` agree with the guard about what is serving.
*
* @returns {{id: string, hook: Function|null, ctx: object|null}[]}
*/
function bootable() {
assertLoaded('bootable')
return [...modules.values()]
.filter((r) => r.state === 'registered')
.map((r) => ({ id: r.id, hook: r.hooks.onBoot, ctx: r.ctx }))
}
/**
* The shutdown hooks to run, in REVERSE registration order (§2.5).
*
* `started` only. A module whose `onBoot` threw is mid-way through a warm-up it
* never finished, and calling its `onShutdown` would hand it a half-built world
* to tear down — the one thing worse than not closing cleanly. Reverse order is
* the same reasoning applied between modules rather than within one.
*
* @returns {{id: string, hook: Function}[]}
*/
function shutdownHooks() {
assertLoaded('shutdownHooks')
return [...modules.values()]
.filter((r) => r.state === 'started' && r.hooks.onShutdown)
.map((r) => ({ id: r.id, hook: r.hooks.onShutdown }))
.reverse()
}
/** /**
* Every schema fragment waiting to be replayed, in scan order. * Every schema fragment waiting to be replayed, in scan order.
* *
@@ -575,4 +660,4 @@ function fragments() {
/** Absolute path of the modules directory. */ /** Absolute path of the modules directory. */
const dir = () => MODULES_DIR const dir = () => MODULES_DIR
module.exports = { load, list, setState, fragments, isLoaded, dir } module.exports = { load, list, setState, fragments, bootable, shutdownHooks, isLoaded, dir }

View File

@@ -73,7 +73,7 @@ async function replayFragments({ query, modules } = {}) {
} }
log.info(`schema ensured for module "${id}"`, { statements: statements.length }) log.info(`schema ensured for module "${id}"`, { statements: statements.length })
} catch (err) { } catch (err) {
loader.setState(id, 'startup_failed', err.message) loader.setState(id, 'startup_failed', { stage: 'schema', reason: err.message })
log.error(`module "${id}" schema fragment failed — its routes will answer 503`, { log.error(`module "${id}" schema fragment failed — its routes will answer 503`, {
reason: err.message, reason: err.message,
}) })

View File

@@ -17,6 +17,7 @@ const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.mode
const shardAtlas = require('./model/shardAtlas/shardAtlas.model') const shardAtlas = require('./model/shardAtlas/shardAtlas.model')
const shardClilocs = require('./model/shardClilocs/shardClilocs.model') const shardClilocs = require('./model/shardClilocs/shardClilocs.model')
const shardMarket = require('./model/shardMarket/shardMarket.model') const shardMarket = require('./model/shardMarket/shardMarket.model')
const moduleLifecycle = require('./modules/lifecycle')
const createLogger = require('./utils/logger') const createLogger = require('./utils/logger')
const { evaluateBotInternalKey } = require('./utils/botInternalKey') const { evaluateBotInternalKey } = require('./utils/botInternalKey')
const brand = require('./config/brand') const brand = require('./config/brand')
@@ -109,6 +110,15 @@ async function start() {
const mode = await settings.get('site_mode') const mode = await settings.get('site_mode')
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`) log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
// Reconcile installed_modules with what the loader found on the volume at
// require time, then run each module's onBoot (MODULE_API.md §2.5). Placed
// after core's own boot work and BEFORE the listener binds, for both reasons
// the contract gives: a module's warm-up may depend on core being up, and a
// module that must not serve traffic until it has warmed a cache gets that
// guarantee only if nothing is listening yet. Never throws — a module that
// fails here keeps its URLs and answers 503.
await moduleLifecycle.boot()
const server = http.createServer(app) const server = http.createServer(app)
server.listen(PORT, HOST, () => { server.listen(PORT, HOST, () => {
log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`) log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`)
@@ -174,6 +184,12 @@ function setupShutdown(server, internalServer) {
if (closing) return if (closing) return
closing = true closing = true
log.warn(`${signal} received — shutting down gracefully`) log.warn(`${signal} received — shutting down gracefully`)
// Modules first, while everything they were handed still works: the database
// pool, the push dispatcher and the SSE fan-out are all still open here, and
// a module's onShutdown is the only chance it gets to flush through them
// (MODULE_API.md §2.5). Each hook is budgeted, so one that will not let go
// costs five seconds rather than the whole shutdown.
await moduleLifecycle.shutdown()
botScore.stopSweeper() // stop the bot-store cleanup interval botScore.stopSweeper() // stop the bot-store cleanup interval
announceWorker.stop() // stop the news-announcement dispatcher poller announceWorker.stop() // stop the news-announcement dispatcher poller
uoLinkSocket.stop() // close the uo-link WS ingest client uoLinkSocket.stop() // close the uo-link WS ingest client

View File

@@ -0,0 +1,376 @@
// ── 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 })
},
}
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('every module is recorded, booted with its ctx and marked started, in scan order', async () => {
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 })
// 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.deepEqual(model.calls.slice(1, 3), ['recordInstalled:aaa', 'recordInstalled:bbb'])
assert.deepEqual(noted(file), ['boot:aaa:ctx', 'boot:bbb:ctx'])
assert.equal(stateOf(loader, 'aaa').state, 'started')
assert.equal(model.rows.get('bbb').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 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 fails its own module and no one else', async () => {
const file = path.join(tmpRoot, 'log.txt')
writeModule('aaa', { boot: '', log: file })
writeModule('bbb', { boot: 'throw new Error("cache warm-up failed")', log: file })
writeModule('ccc', { boot: '', log: file })
const loader = freshLoader(tmpRoot)
const model = fakeModel()
await lifecycle.boot({ modules: loader, model })
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')
// The one that matters: the module AFTER the failure still booted.
assert.deepEqual(noted(file), ['boot:aaa:ctx', 'boot:bbb:ctx', 'boot:ccc:ctx'])
assert.equal(stateOf(loader, 'ccc').state, 'started')
})
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 started modules in reverse order and skips the rest', async () => {
const file = path.join(tmpRoot, 'log.txt')
writeModule('aaa', { boot: '', shutdown: '', log: file })
writeModule('bbb', { boot: 'throw new Error("no")', shutdown: '', log: file })
writeModule('ccc', { 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 })
// Reverse of boot order, and `bbb` absent: its onBoot threw, so it has a
// half-built world that its onShutdown was never written to tear down.
assert.deepEqual(noted(file), ['shutdown:ccc', 'shutdown:aaa'])
})
test('a hook that hangs costs its budget, not the shutdown', async () => {
const file = path.join(tmpRoot, 'log.txt')
writeModule('aaa', { boot: '', shutdown: '', log: file })
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; it is abandoned and aaa still gets its turn. The
// alternative is a host where `systemctl stop` hangs until SIGKILL.
assert.deepEqual(noted(file), ['shutdown:zzz', 'shutdown:aaa'])
assert.ok(Date.now() - started < 2000, 'shutdown must not wait on a hung hook')
})
test('a hook that throws does not stop the ones behind it', async () => {
const file = path.join(tmpRoot, 'log.txt')
writeModule('aaa', { boot: '', shutdown: '', log: file })
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', 'shutdown:aaa'])
})

View File

@@ -437,9 +437,10 @@ test('a registered module answers on its prefix; a failed one is simply absent',
test('a module that fails AFTER mounting keeps its URLs and answers 503', async () => { test('a module that fails AFTER mounting keeps its URLs and answers 503', async () => {
// The right-hand column of §4.4, and the reason routes.manifest.json can be // The right-hand column of §4.4, and the reason routes.manifest.json can be
// generated off a dead database: the URL surface must not depend on whether a // generated off a dead database: the URL surface must not depend on whether a
// boot step succeeded on the generating machine. PR 3 (schema replay) and PR 5 // boot step succeeded on the generating machine. The schema replay and onBoot
// (onBoot) are the two things that will trip this in real life; here the state // are the two things that trip this in real life (moduleSchema.test.js and
// is moved by hand, because the loader is the thing under test. // moduleLifecycle.test.js cover both); here the state is moved by hand,
// because the loader is the thing under test.
writeModule('later', oneRoute('/widgets')) writeModule('later', oneRoute('/widgets'))
const tiers = emptyTiers() const tiers = emptyTiers()
@@ -451,18 +452,23 @@ test('a module that fails AFTER mounting keeps its URLs and answers 503', async
assert.equal(stateOf(loader, 'later').state, 'registered') assert.equal(stateOf(loader, 'later').state, 'registered')
loader.setState('later', 'startup_failed', 'schema fragment blew up') loader.setState('later', 'startup_failed', { stage: 'schema', reason: 'schema fragment blew up' })
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503) assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503)
assert.equal(stateOf(loader, 'later').reason, 'schema fragment blew up') assert.equal(stateOf(loader, 'later').reason, 'schema fragment blew up')
assert.equal(stateOf(loader, 'later').stage, 'schema')
// The 404 leg becomes reachable for real in PR 5, when the boot reconcile // The 404 leg is reached for real by the boot reconcile, when it finds a
// reads a `disabled` row out of installed_modules. A disabled module is // `disabled` row in installed_modules. A disabled module is mounted and
// mounted and guarded, never unmounted (§4.5) — same reason as the 503. // guarded, never unmounted (§4.5) — same reason as the 503.
loader.setState('later', 'disabled') loader.setState('later', 'disabled')
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 404) assert.equal((await fetch(`${app.url}/public/widgets`)).status, 404)
loader.setState('later', 'started') loader.setState('later', 'started')
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200) assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
// Every non-failing move clears the failure, so a running module can never
// show the reason it failed two boots ago (§2.4).
assert.equal(stateOf(loader, 'later').reason, null)
assert.equal(stateOf(loader, 'later').stage, null)
} finally { } finally {
await app.close() await app.close()
} }
@@ -506,19 +512,79 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
assert.equal(probe.mutable, false, 'ctx members must be frozen') assert.equal(probe.mutable, false, 'ctx members must be frozen')
}) })
test('the register calls PR 5 owns throw rather than silently accepting', () => { // ── Lifecycle hooks ────────────────────────────────────────────────────────
// An accepting no-op would let a module believe it had registered a boot hook
// and fail silently at the far end. test('a lifecycle hook must be a function, and may be registered once', () => {
for (const [call, pr] of [ // Both are register-time failures, so they cost the module its mount entirely
['onBoot', 5], // rather than surfacing at boot — the far end of a hook that was never really
['onShutdown', 5], // registered is a module that silently never warms up.
for (const [body, expected] of [
['api.onBoot("later")', /onBoot: expected a function/],
['api.onShutdown("later")', /onShutdown: expected a function/],
['api.onBoot(() => {}); api.onBoot(() => {})', /onBoot\(\) called twice/],
]) { ]) {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-')) tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
writeModule('early', { server: `module.exports = (ctx, api) => api.${call}(() => {})` }) writeModule('hooked', { server: `module.exports = (ctx, api) => { ${body} }` })
assert.match( const state = stateOf(freshLoader(tmpRoot), 'hooked')
stateOf(freshLoader(tmpRoot), 'early').reason, assert.match(state.reason, expected)
new RegExp(`${call}: not available until phase 2 PR ${pr}`), assert.equal(state.stage, 'register')
) }
})
test('a module with no hooks is bootable, and offers nothing to shut down', () => {
writeModule('quiet', oneRoute('/widgets'))
const loader = freshLoader(tmpRoot)
// Listed with a null hook rather than filtered out: it still has to reach
// `started`, or the admin panel and the dispatch guard would disagree about
// whether it is serving.
assert.deepEqual(loader.bootable().map((b) => b.id), ['quiet'])
assert.equal(loader.bootable()[0].hook, null)
assert.deepEqual(loader.shutdownHooks(), [])
})
test('shutdown hooks come back in reverse order, and only for started modules', () => {
const hook = 'module.exports = (ctx, api) => api.onShutdown(async () => {})'
writeModule('aaa', { server: hook })
writeModule('bbb', { server: hook })
writeModule('ccc', { server: hook })
const loader = freshLoader(tmpRoot)
// Nothing has started yet, so there is nothing to tear down.
assert.deepEqual(loader.shutdownHooks(), [])
loader.setState('aaa', 'started')
loader.setState('bbb', 'startup_failed', { stage: 'boot', reason: 'never warmed up' })
loader.setState('ccc', 'started')
// Reverse registration order (§2.5), and `bbb` is absent: a module whose
// onBoot threw is mid-way through a warm-up it never finished, and handing it
// a half-built world to tear down is worse than not closing cleanly.
assert.deepEqual(loader.shutdownHooks().map((h) => h.id), ['ccc', 'aaa'])
})
// ── Failure stages ─────────────────────────────────────────────────────────
test('a failure is recorded against the §4.3 step that produced it', () => {
// installed_modules.failure_stage exists so the admin panel can say WHERE a
// module broke. The four steps readManifest covers in one pass have to label
// themselves; the rest are inferred from how far load() had got.
const cases = [
['a-manifest', { manifest: { nonsense: true } }, 'manifest'],
['b-coreapi', { manifest: { coreApi: '^99.0.0' } }, 'core_api'],
['c-mounts', { manifest: { mounts: { public: ['/bad prefix'] } } }, 'mounts'],
['d-slots', { manifest: { extensions: ['no.such.slot'] } }, 'extensions'],
['e-schema', { schema: 'DELETE FROM x;' }, 'schema'],
['f-require', { server: 'throw new Error("boom")' }, 'require'],
['g-register', { server: 'module.exports = (ctx, api) => { throw new Error("nope") }' }, 'register'],
]
for (const [id, spec] of cases) writeModule(id, spec)
const loader = freshLoader(tmpRoot)
for (const [id, , stage] of cases) {
const state = stateOf(loader, id)
assert.equal(state.state, 'startup_failed', `${id} should have failed`)
assert.equal(state.stage, stage, `${id} should have failed at "${stage}"`)
} }
}) })