feat(modules): boot/shutdown hook dispatch and the installed_modules reconcile #132
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/module-lifecycle"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Phase 2 PR 5 of
docs/website/MODULE_SYSTEM.md§2.7.api.onBoot/api.onShutdownstop throwing,server.jsgains one call on each side, and the §2.4 state machine finally runs against real outcomes — which is what makes §4.5'sdisabled404 leg reachable for the first time. Docs half: docs#130.Nothing an operator or a client can see changes. 901 tests pass (17 new),
routes.manifest.jsonis unchanged at 229 routes, and the OpenAPI spec regenerates byte-identical.Carries a second, unrelated commit: the test suite never terminated, and that turned out to be a live-database bug rather than slowness. Detail at the bottom.
Where it lives, and why not in the loader
server/src/modules/lifecycle.js, for the same reasonmodules/schema.jsexists:scripts/routeManifest.jsandswagger/swagger.jsboth requireapp.jswith the pool pointed at a dead port (§4.1), so the loader may not reach the database. This half is database-first.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 drift apart.The four decisions
failure_stagesays where a module broke instead of being a column nothing ever filled. The four stepsreadManifestcovers in one pass label themselves; the rest are inferred from how farload()had got, and an unlabelled throw is recorded against the step that was running rather than guessed at.enabled, and a row claiming to be enabled for a module that is not on the volume is the one state that is simply untrue — read that way by the admin panel and byGET /api/v1/public/modulesalike. An uninstall leavesdisabled, which the reset never touches, so this catches only a hand-deleted directory.server.jsonBootgets no timeoutonBootdelaying the listener is the contract's promise to a module that must warm up before it serves, not a problem to time out.onShutdownkeeps its 5s budget.What a boot does, in order
disabledrows are left alone — a decision, not an outcome.startup_failed.onBoot.Two rules fall out and are tested:
disabledmodule is guarded, is not booted, and does not have its failure re-recorded — overwriting a decision with an outcome would silently switch it back on next boot.Second commit: the suite was talking to a real database
npm testdid not terminate, and the reason was not slowness. Twenty-two test files omitted the two lines that point the pool at a dead port, soutils/db.js— which builds its mariadb pool at require time and callsdotenv.config()itself — picked upserver/.envand opened five live connections to the developer's MariaDB. The tests still passed, because they stub their models and never issue a query, so the only symptoms were a process that never exited and five connections held for as long as it lived. Thirty stranded workers is 150 connections, which is the whole server's limit — the "too many connections" this workspace has hit before.The convention was right and only ever as good as the next test file's memory of it, so it moves into the harness:
test/_setup.js, loaded with--requirefrom the npm script, ahead of the test file it hosts — the only moment early enough, since the pool is built at require time. It pins the dead port (dotenvdoes not overwrite an existing variable, so an explicitDB_PORT=still wins for anyone who wants a live database) and closes the pool after that file's tests, so the process exits at once rather than waiting out the driver's connect retries. The per-file preambles stay: they keepnode --test test/one.test.jssafe on its own.db.close()is idempotent.pool.end()throws on a second call, and closing twice is now normal rather than exceptional — the harness closes on top of the suites that close themselves, and a SIGINT followed by a SIGTERM already reached the shutdown handler twice.test/_helper.js'sclose()destroys open connections.server.close()only stops accepting and waits for existing connections to end, and node's globalfetchkeeps its sockets alive, so the listener outlived the test that created it — invisible until now, because the pool was holding the process open anyway.announceJobs.test.jsalone: a 120s+ hang → 0.35s.Verification
node:20on Linux via Docker, 901/901 each time.moduleLifecycle.test.js(reconcile order, hand-placed rows, the 503 reached by a real failingonBoot, the 404 reached by a realdisabledrow, orphan rows, a database that refuses every write, a hung hook, a thrown hook, reverse shutdown order) and 4 inmoduleLoader.test.js(hook validation, the stage table,bootable/shutdownHooks).routes.manifest.json: zero-line diff, 227 public + 2 internal.routes.guards.jsonunchanged.npm run swaggerregenerates byte-identical — PR 4's slot merge still finds all six paths.One honest caveat: on Windows + Node 24 a very fast test file still occasionally reports a bare
'test failed'with no diagnostics, roughly once in fourteen runs, a different file each time and never reproducible in isolation. It does not occur on the CI platform across the runs above, and five instrumented runs caught no uncaught exception or unhandled rejection behind it — it looks like a runner-level race on children that now exit promptly, so I have not papered over it.AI-assisted (Claude Code).
🤖 Generated with Claude Code
https://claude.ai/code/session_018ocYxQWk3EhZe5gWRJXFU8