`npm test` never terminated. Twenty-two test files omitted the two lines that
point the pool at a dead port, so utils/db.js -- which builds its mariadb pool at
require time and calls dotenv.config() itself -- picked up server/.env and opened
five live connections to the developer's MariaDB. The tests still passed, because
they stub their models and never issue a query; 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, and that
is 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 is loaded with --require by the
npm script, ahead of the test file it hosts, which is the only moment early
enough to matter. It pins the dead port -- dotenv does not overwrite an existing
variable, so an explicit DB_PORT= still wins for anyone who wants a live database
-- and closes the pool after the file's tests, so the process exits at once
instead of waiting out the driver's connect retries. The per-file preambles stay:
they keep `node --test test/one.test.js` safe on its own.
Two supporting fixes:
- db.close() is idempotent. pool.end() throws "pool is already closed" on a
second call, and closing twice is now normal rather than exceptional -- the
harness closes the pool for every file on top of the suites that close it
themselves, and a SIGINT followed by a SIGTERM already reached the shutdown
handler twice.
- test/_helper.js's close() destroys open connections. server.close() only stops
accepting and waits for existing connections to end, and node's global fetch
keeps 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.js alone: 120s+ hang -> 0.35s. The whole suite now finishes in
~75s where it previously did not finish at all: 901 tests, 901 pass, verified
three times on CI's exact platform (node:20 on Linux, via Docker).
Co-Authored-By: Claude <noreply@anthropic.com>
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>