fix(core): the last fixes' core half (W1 W2 W5 W19)
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
This commit is contained in:
@@ -143,10 +143,9 @@ beforeEach(() => {
|
||||
|
||||
// ── The happy path ─────────────────────────────────────────────────────────
|
||||
|
||||
test('every module is recorded, booted with its ctx and marked started, in scan order', async () => {
|
||||
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 })
|
||||
writeModule('bbb', { boot: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
@@ -155,15 +154,35 @@ test('every module is recorded, booted with its ctx and marked started, in scan
|
||||
// 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(model.calls[1], 'recordInstalled:aaa')
|
||||
assert.deepEqual(noted(file), ['boot:aaa:ctx'])
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'started')
|
||||
assert.equal(model.rows.get('bbb').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.
|
||||
@@ -193,23 +212,19 @@ test('a module with no onBoot still reaches started', async () => {
|
||||
|
||||
// ── Failure is a state ─────────────────────────────────────────────────────
|
||||
|
||||
test('an onBoot that throws fails its own module and no one else', async () => {
|
||||
test('an onBoot that throws is a failed state, not a failed boot', 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 })
|
||||
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')
|
||||
// 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 () => {
|
||||
@@ -333,25 +348,28 @@ test('a process that never scanned writes nothing at all', async () => {
|
||||
|
||||
// ── 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)
|
||||
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 })
|
||||
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'])
|
||||
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('aaa', { boot: '', shutdown: '', log: file })
|
||||
writeModule('zzz', { boot: '', shutdown: 'await new Promise(() => {})', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
@@ -361,15 +379,14 @@ test('a hook that hangs costs its budget, not the shutdown', async () => {
|
||||
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'])
|
||||
// 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 stop the ones behind it', async () => {
|
||||
test('a hook that throws does not fail the shutdown', 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)
|
||||
|
||||
@@ -377,7 +394,7 @@ test('a hook that throws does not stop the ones behind it', async () => {
|
||||
fs.writeFileSync(file, '')
|
||||
|
||||
await assert.doesNotReject(() => lifecycle.shutdown({ modules: loader }))
|
||||
assert.deepEqual(noted(file), ['shutdown:zzz', 'shutdown:aaa'])
|
||||
assert.deepEqual(noted(file), ['shutdown:zzz'])
|
||||
})
|
||||
|
||||
// ── stop(): the admin panel's Disable ──────────────────────────────────────
|
||||
@@ -385,10 +402,9 @@ test('a hook that throws does not stop the ones behind it', async () => {
|
||||
// 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 that one module\'s onShutdown and leaves the others alone', async () => {
|
||||
test('stop runs the module\'s onShutdown and disables it', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', shutdown: '', log: file })
|
||||
writeModule('bbb', { boot: '', shutdown: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
@@ -398,10 +414,8 @@ test('stop runs that one module\'s onShutdown and leaves the others alone', asyn
|
||||
const result = await lifecycle.stop('aaa', { modules: loader, model })
|
||||
|
||||
assert.deepEqual(result, { stopped: true, error: null })
|
||||
// Only aaa. This is the difference from shutdown(), which runs everything.
|
||||
assert.deepEqual(noted(file), ['shutdown:aaa'])
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'disabled')
|
||||
assert.equal(stateOf(loader, 'bbb').state, 'started', 'the other module keeps running')
|
||||
assert.equal(model.rows.get('aaa').state, 'disabled')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user