Stage 4 of docs/modules/rust/PLAN_LAST_FIXES.md, with the org lead's 2026-10-22 decisions. W19: one module per site at boot, as at install. The first module directory by name holds the site, whether or not it then loads; every other is recorded startup_failed at the manifest stage, before a line of its code is required, with a reason naming the module that holds the site. Module-uo and Module-Rust side by side used to fail the second with a slot collision. Loader, lifecycle, schema and event-contract tests that loaded several fixture modules now load each case alone. W1: a reconcile files a resource the game no longer has, and that is past its `until`, as expired rather than orphaned, with the same log line expired() writes. No MODULE_API bump: `until` and reconcile's answer are unchanged. W5: a dry run that passes on the draft is kept with the fingerprint of the validated spec (three nullable columns on event_definitions). Publish stamps the new version verified, with that dry run's reviewer and time, when the spec it freezes has the same fingerprint, and writes version.verified on the occurrences it re-pins. Verify answers draftRecorded and publish answers verified; OpenAPI regenerated. W2: the run console says "Phase p1: steps done, waiting to advance" and "Why this phase has not advanced", not "has not started". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
544 lines
23 KiB
JavaScript
544 lines
23 KiB
JavaScript
// ── 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')
|
|
})
|