Isolate unit-test runs from the installed Harper root (per-PID system database, config, and logs) - #2299
Isolate unit-test runs from the installed Harper root (per-PID system database, config, and logs)#2299kriszyp wants to merge 18 commits into
Conversation
Every mocha process now runs against its own per-PID root under unitTests/envDir/<pid>: config file, log, keys, and every database -- system included. setupTestDBPath()/initTestEnvironment() no longer pin the system database to the installed root, so unit runs no longer contend on that root's exclusive RocksDB LOCK (or fail when a running Harper holds it), no longer write to the installed root at all, and run on machines with no Harper install. apiTests seed the system tables, super_user role/admin user, and self-signed certs the way an install would instead of borrowing the installed system database. Co-Authored-By: Claude Fable <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fNsG5Q888AojBUqNmQx5E
…ey at the renamed test key Co-Authored-By: Claude Fable <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fNsG5Q888AojBUqNmQx5E
…olation regression tests Co-Authored-By: Claude Fable <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fNsG5Q888AojBUqNmQx5E
…uard keyed on final artifact Co-Authored-By: Claude Fable <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fNsG5Q888AojBUqNmQx5E
…ey; child timeout Co-Authored-By: Claude Fable <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fNsG5Q888AojBUqNmQx5E
…tion test's child root Co-Authored-By: Claude Fable <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fNsG5Q888AojBUqNmQx5E
There was a problem hiding this comment.
Code Review
This pull request implements robust unit-test isolation by ensuring each test run operates within its own per-PID root directory, preventing interference with any installed Harper root. Key changes include updating the test bootstrap (mocha.init.js) to configure the per-PID environment, introducing a new isolation test suite, and adding ensureSystemTables to seed system databases locally. Feedback on these changes suggests defensively guarding the certificate search and error handling in testUtils.js against null or undefined values to prevent runtime crashes, and ensuring that temporary directories in isolation.test.js are cleaned up before assertions run to avoid resource leaks on failure.
|
Reviewed; no blockers found. |
…per-PID root, stale-PID wipe - isolation.test.js no longer calls setupTestDBPath() inside the test body, so its assertions observe the state mocha.init.js established instead of repairing it first (the headline test could not previously fail on the regression it exists to catch). - Factor the per-PID root/config synthesis into unitTests/perPidRoot.js and call it from mocha.init.js, setupTestDBPath(), and ensureSystemTables(): a mid-run tearDownMockDB() removes the whole root including harper-config.yaml, and a later re-seed died in updateConfigValue's readFileSync instead of re-materializing it. - mocha.init.js wipes a stale per-PID dir before creating it (a recycled PID must not inherit a killed run's config, keys, or seeded system database), guarded on isMainThread so worker threads re-running the preload under the same PID cannot delete the live root. - mTLS tests assert cert.private_key_name before joining it into a key path; the seeded cert-record rename derives the name from testKeyPath instead of a second hardcoded copy. - Trim comments that restated adjacent code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K72HdmqMNVvG7nYwtWW9R
…y-aware seed guard, child-probe assertions
- The exit-hook cleanup never actually ran: an async tearDownMockDB() inside
process.on('exit') schedules its fs.remove and process.exit() drops it.
The hook (registered once now) removes the per-PID root synchronously, and
mocha.init additionally sweeps envDir roots whose owning PID is dead, so
runs killed before the hook (SIGKILL/OOM/cancelled CI) are reclaimed too.
- The seed guard also verifies the seeded admin user is present and active;
tables + key file alone stayed green after a suite deleted the identities
the seed exists to create.
- The spawned-child isolation test now probes the resolved Harper root,
storage path, and system database root inside the fresh child, where no
earlier suite can have repaired the state being asserted.
- mTLS key resolution keeps the private_key_name assertion; trim comments
flagged as restating adjacent code, and correct the resetNoBootFileCache
note (ROOTPATH is cleared per-test-scope, not exported transiently).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018K72HdmqMNVvG7nYwtWW9R
- The dead-PID sweep no longer trusts process.kill(pid, 0) alone: PIDs are not comparable across namespaces, so a bind-mounted checkout probed from a container would read every host run as gone and delete a live root. The sweep now requires the root to be over an hour stale as well, which also closes the check-to-remove window against PID reuse. - The seed can now repair every state its guard rejects, instead of only detecting some of them: alterUser reactivates/relinks an existing admin (addUser throws on existing rows), alterRole restores a super_user role whose permission no longer grants super_user, and the guard verifies the admin's role resolves to a super-user role. Previously a deactivated admin left the guard false forever, re-running the full seed — including certificate regeneration against a live server — on every call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K72HdmqMNVvG7nYwtWW9R
…e run for a dead one materializePerPidRoot() now refreshes the root's mtime, and every suite's setupTestDBPath() lands there — so a run stays inside the sweep's staleness floor for as long as it is making progress, including when probed from a PID namespace where its PID is not visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K72HdmqMNVvG7nYwtWW9R
If the role is deleted between addRole's already-exists check and the repair's lookup, proceed with the seed (addUser reports the missing role clearly) instead of throwing on existing.id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K72HdmqMNVvG7nYwtWW9R
…ten child probe - initTestEnvironment() sets storage.path to <pid>/database like the preload does. preTestPrep() stubs env.initSync with this function, so any mid-run re-init previously repointed storage at the PID directory itself and detached the seeded system database, resurrecting "Table hdb_role not found" order-dependently. - The child-process isolation assertions check the child's own PID directory rather than envDir as a whole; the parent's root is inherited via ROOTPATH and also lives under envDir, so the wider check could not catch a child resolving into the parent's root. - Drop remaining comments that narrate adjacent code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K72HdmqMNVvG7nYwtWW9R
…lean up too The exit hook lived in setupTestDBPath(), so any suite that never opened a database left the root the preload had already created; six runs of the config-watcher suite leaked six roots, reclaimed only by the hourly sweep. Registering it in mocha.init.js covers every mocha process. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K72HdmqMNVvG7nYwtWW9R
…nest teardown contract - preTestPrep() prepends an exit listener that calls process.exit() on a clean run, and node skips the remaining 'exit' listeners once _exiting is set — so the preload's cleanup never fired for the ~98 suites that use it, and every green run leaked a root holding a seeded system database and a private key. That handler now removes the root itself; the preload's listener still covers suites that do not call preTestPrep. - ensureSystemTables() decides on the on-disk database rather than the module cache. Seeding through handles whose files a tearDownMockDB() removed wrote to unlinked files and reported success — the re-seeded system directory was empty. That ordering cannot be repaired from test code (reopening recurses inside databases.table()), so it now throws a message naming the fix instead of producing a silently empty database. - perPidRoot's docblock no longer claims the teardown case is handled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K72HdmqMNVvG7nYwtWW9R
…ced JSDoc The child probe now also reports the resolved log path and the storage path after an initTestEnvironment() re-init, so the layout fix that keeps a seeded system database attached cannot regress silently — reverting it fails this test. The ensureSystemTables JSDoc had drifted onto systemDatabaseOnDisk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K72HdmqMNVvG7nYwtWW9R
…ils instead of wedging RootConfigWatcher only emits 'ready' from handleChange(), which returns silently on an empty read and swallows read/parse errors, so a consumer awaiting .ready can wait forever. With .mocharc.json's timeout: 0 that wedged the whole unit run — 13 hours locally, and on CI the Node 24 job sat nine minutes at this suite before being cancelled. Reproduced roughly one run in six; the suite now fails visibly instead. The describe becomes a `function` because a timeout set inside a beforeEach applies only to the hook, not to the tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K72HdmqMNVvG7nYwtWW9R
|
Reviewed Verification beyond static read: mutation-tested the isolation contract in — |
Guarantee spawned probe cleanup on every assertion path and make system-seeding failures defensive without allowing a partially seeded certificate state. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Tag the child-process probe payload so unrelated stdout cannot break parsing, and finish certificate iteration before updating matching records. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
|
Reviewed Delta: 2 commits, 2 files ( Re-verified by measurement, not by reading:
— |
Unit-test runs no longer borrow the installed Harper root. Every mocha process now runs against its own per-PID root (
unitTests/envDir/<pid>): its own config file, log, keys, and every database —systemincluded. Suites that need the system tables seed them there the way an install would. This removes the exclusive-LOCK contention that made unit runs fail whenever a running Harper (or a leaked prior test process) held the installed root's RocksDBsystemLOCK — the failure mode that once broke every local unit run for three weeks, and that currently blocks a pre-push changed-tests check. It also makes the suites runnable on a machine with no installed Harper at all.Refs the unit-system-db-isolation dispatch task.
What changed
unitTests/mocha.init.jsnow builds a complete per-PID root before any Harper module loads: the standard directories, plus a config file generated fromstatic/defaultConfig.yamlwith the same path fields the installer's validation resolves (the synthesis lives in the newunitTests/perPidRoot.js, so seeding can re-materialize the root after a mid-runtearDownMockDB()). It exportsROOTPATH=<pid dir>— config resolution (configUtils.getConfigFilePath) and the logger's file stream bind to it at first initialization, and it reaches worker threads and spawned processes — and deletes any ambientSTORAGE_PATH/SCHEMAS_DATA_PATH, which outrank every configured path ingetDatabases(). The apiTests carve-out (which existed only to preserve the installedsystempath) is gone.asyncteardown fromprocess.on('exit'), so its removal was scheduled and then dropped byprocess.exit()— it had silently never worked, which matters more once each root contains a seeded system database and a private key. There was a second reason it never fired:preTestPrep()prepends its own'exit'listener that callsprocess.exit(), and node skips every listener after that one, so the ~98 suites usingpreTestPrepwere never reached; that handler now removes the root itself. The hook removes the root synchronously;mocha.init.jsadditionally wipes a recycled PID's stale root (guarded onisMainThread— worker threads re-run the preload under the same PID) and sweepsenvDirroots that are both over an hour stale and whose PID is gone, reclaiming runs killed before the hook (SIGKILL, OOM, cancelled CI). The age floor is load-bearing:kill(pid, 0)alone is not a liveness signal across PID namespaces (a bind-mounted checkout probed from inside a container reads every host run as dead), somaterializePerPidRoot()heartbeats the root's mtime — every suite'ssetupTestDBPath()refreshes it, keeping a live run inside the floor for as long as it makes progress.setupTestDBPath()andsetTestPath()inunitTests/testUtils.jsdrop the "preserve the installed system path" logic and assert the same paths asmocha.init.js. The old "really confuses the system" warning was about repointing an already-opensystem; sincemocha.init.jsconfigures these exact paths before any module can open it, the path never changes after open.ensureSystemTables()inunitTests/testUtils.jsseeds the per-PIDsystemdatabase exactly as an install does:mountHdb(the system tables), asuper_userrole with an activeadminuser (authorizeLocal/getSuperUser()require one to authorize local requests), and self-signed certificates viagenerateCertsKeys()(the TLS listeners — e.g. MQTT's secure port — need them). The generated key is renamed tounitTestPrivateKey.pemand the per-PID config'stls.privateKeyis repointed at it, so cert records, config, and the key watcher agree — the nameprivateKey.pemis whatloadCertificates()registers for an ambient config's key in the in-processprivateKeyscache, which would shadow the generated key and pair the fresh certificates with an unrelated one (ERR_OSSL_X509_KEY_VALUES_MISMATCH, zero TLS contexts). The seed is guard-keyed on its final artifacts (key file, tables, a usable admin, the config repoint), and every state the guard rejects it can also repair:alterUserreactivates/relinks an existing admin,alterRolerestores a mangledsuper_userpermission — so a broken identity can't strand the guard false and re-run certificate generation against a live server on every call. It decides whether to mount from the database on disk rather than the module cache: after atearDownMockDB()the cache still answers for a database whose files are gone, and seeding through those handles wrote to unlinked files and reported success (the re-seededsystemdirectory came out empty). That ordering can't be repaired from test code — reopening the handles recurses insidedatabases.table()— so it now throws a message naming the fix instead.unitTests/apiTests/setupTestApp.mjsreplaces thegetDatabases()preload of the installed root withensureSystemTables().private_key_nameunder<root>/keys— instead of reading thetls.privateKeyconfig path.ROOTPATHenv var shadows) clear the variable — and thenoBootFile()memo derived from it — for their own scope:configUtils.test.js,installation.test.js, andharper_logger.test.js'sinitLogSettingsdescribe. The logger's stdio-capture tests now stubwriteToLogFilelike they already stub the rest of the config surface, since a fresh module copy'sinitLogSettingscan fail after an earlier test'stearDownMockDB()removed the per-PID config.unitTests/isolation.test.jslocks the contract directly. The load-bearing test spawns a fresh child with hostileSTORAGE_PATH/SCHEMAS_DATA_PATHexported and asserts — inside the child, where no earlier suite can have repaired state — that the env vars are neutralized and the resolved Harper root, storage path, log path, andsystemdatabase root all land inside the child's own per-PID directory (not merely somewhere underenvDir, which the parent's inherited root would also satisfy). It also re-runsinitTestEnvironment()inside the child and asserts the storage layout survives it — that assertion was checked the only way worth trusting: it fails when the layout fix is reverted and passes when it is restored. In-process assertions cover the same contract as a fast signal.unitTests/:initTestEnvironment()inutility/environment/environmentManager.ts(a documented test-only function) loses its copy of the installed-system-path preservation, and itsstorage.pathnow follows the same<pid>/databaselayout the preload pins —preTestPrep()stubsinitSyncwith this function, so a mid-run re-init previously repointed storage at the PID dir itself and detached the seededsystemdatabase (verified: seed → re-init →resetDatabases()now keeps the system tables).utility/common_utils.tsgains a two-lineresetNoBootFileCache()test-support export. Production database resolution is untouched.Why seeding rather than a snapshot
A consistent snapshot of the installed
systemdatabase is impossible in exactly the scenario this change exists to fix: a RocksDB checkpoint must be taken by the process that has the database open, and when a running Harper holds the LOCK the test process cannot open it at all (nor can a recursive file copy be made consistent from outside the engine). Seeding a fresh minimal system database is the only primitive that works while the installed root is contended — and it is also more deterministic than inheriting whatever users/roles/certs a developer's install happens to contain. Seeding runs the real install code paths (mountHdb,addRole/addUser,generateCertsKeys) rather than copying in a checked-in fixture root: a fixture drifts silently as install internals evolve, while this fails loudly.harper.js stopstays intest:unit:apitestsWith full root isolation the stop is no longer needed for the LOCK, but it is still needed for ports: a running Harper binds
*:9925/9926/1883(wildcard), so the integration-testing loopback pool rejects every127.0.0.xcandidate ("still in use by another Harper node (port 9925 bound); skipping to avoid an SO_REUSEPORT co-bind") and apiTests setup loops indefinitely. Moving apiTests off the default ports would fix that, but is a separate change.For the human reviewer
Sixteen cross-model rounds ran pre-push (codex graded throughout; Harper-domain adjudication most rounds; gemini where its adapter cooperated). One note on the footer below: the final round was a receipt-only pass after a comment trim, so it ran the graded leg alone and the field reads 4 for want of adjudication — the substantive rounds on the identical code adjudicated to 3, and the per-SHA lock means a re-run cannot lower it. Read it as 3 with an unadjudicated last round, not as a new concern. Later rounds hardened the harness itself: the exit-hook cleanup that had silently never worked (async teardown in
process.on('exit')), the dead-PID sweep and its cross-namespace liveness hazard, the self-repairing seed guard, the child-probe isolation test replacing assertions earlier suites could repair, andinitTestEnvironment()'s storage path brought onto the<pid>/databaselayout so a stubbed re-init can no longer detach the seeded system database. Dismissed with rationale along the way: gemini majors that adjudication refuted by code trace (e.g.getHdbBasePath()is set before any module loads;initLogSettingsreadsROOTPATHdirectly, nevernoBootFile()), ROOTPATH-unset-on-crash, and the seededadmin/passworduser (per-PID database, loopback-only listeners, removed at exit — the same shape a dev-profile install creates). The decision ledger, with where each landed:hdb.log. Permanent export closes that; the cost is three scoped test-file shims, which are also the isolation's remaining carve-out (a fresh module copy taken inside those windows resolves the installed root) — stated in AGENTS.md rather than papered over.resetNoBootFileCache()on shipped core: kept.noBootFile()memoizes aROOTPATH-derived true; the shims must be able to un-poison it, and re-requiring the module can't reach the copyconfigUtilsclosed over. It is two lines, documented as test support.ensureSystemTables()pays RSA keygen per apiTests process and couples the harness to the security stack's real behavior — deliberate, for fidelity.harper-config.yamlfromstatic/defaultConfig.yamland fills the path fields install validation would. Drift between this and the real installer would surface as confusing suite failures rather than a diff.privateKeyscache is keyed by file name, which is what forces theunitTestPrivateKey.pemrename. That aliasing could equally bite two roots in one production process; fixing the cache to key by absolute path insecurity/keys.tsis the root-cause change, left out of scope here (noted in dispatch findings).setupTestDBPath(), so a run only becomes sweepable if it makes no progress for an hour and its PID reads as gone. The residual (a run wedged inside one suite for over an hour, probed from another namespace) is accepted.unitTests/config/rootConfigWatcher.test.jsbefore being cancelled, and it reproduces locally about one run in six (once for thirteen hours). The cause is in code this branch does not touch:RootConfigWatcheremitsreadyonly fromhandleChange(), which returns silently when the read comes back empty and swallows read/parse errors, whilehandleError()drops repeat exhaustion errors without reopening — so.readycan stay pending forever, and.mocharc.json'stimeout: 0means nothing breaks the deadlock. There is no local baseline to compare against, becausemaincannot run this suite in a dev checkout at all: it dies at load reaching into the installed root (Cannot read properties of undefined (reading 'toJSON') opening database .../database/system.mdb) — the very borrowing this PR removes. Acceptance criterion 5 confines this change to test infrastructure, so the product paths are not touched here; the suite gets a timeout so the flake fails visibly instead of wedging the run, and the product fix is written up for a follow-up issue. Verified in situ: the guard caught a real occurrence as a 30s failure with the suite completing normally, and four further runs were clean.configValidator"does not warn when a relative rootPath resolves within the limit" — it resolvesrelative/root/operations-serveragainst the checkout path, and any checkout path over ~76 bytes crosses the 107-byte domain-socket limit. Environmental, pre-existing; noted in the dispatch findings.main, not this change.Integration Tests 6/6 (Windows, Node.js v24)fails onintegrationTests/apiTests/configuration.test.mjswithset_configurationreturning 500 behindEPERM: operation not permitted, rename harper-config.yaml.<pid>.0.<hash>.tmp -> harper-config.yamlatserver/operationsServer.ts:345. That is #2313 — filed from amainrun (32809913865) on the same test and the same signature, so it predates this head. It reproduces here rather than flaking: a re-run of the shard at the identical head failed again, 3 failures instead of 1. This branch cannot reach that path — the last all-green Integration run on it wasfbe41dc33, and the two commits since touch onlyunitTests/isolation.test.jsandunitTests/testUtils.js, which integration tests never load.Verification
All runs on a scratch Harper install (prod profile) under a fake
$HOME, withstorage.pathmade absolute to reproduce the leak shape; "running" means that instance is started and holding itssystemLOCK:test:unit:resources, Harper runningIO error: While lock file: .../database/system/LOCK: Resource temporarily unavailabletest:unit:security, Harper runningpreTestPrep's STORAGE_PATH override — file-order luck)$HOME)hdb_boot_properties.fileENOENT at server boot)test:integration:all: 187 passing; the only failures are environmental or pre-existing — the Ollama-backend tests (require a live local Ollama), the QA-269 TTL canary (a deliberate product-behavior probe, untouched by this diff), and one Northwind CSV-upload timeout under heavy box contention that passes cleanly re-run in isolation. Integration tests do not loadunitTests/, and the only core edits are the test-onlyinitTestEnvironmentbranch removal and the additiveresetNoBootFileCacheexport.test:unit:serverandtest:unit:componentsfail at file-load on base and branch alike (fixture files matched by their globs; neither suite is in CI) — dispatch findings. Formatting andlint:requiredclean.Feedback-fix verification at
b879915f4:npm run build, the focused isolation suite (5 passing),test:unit:apitests(194 passing on the first full run; one unrelated cache-expiration timing failure on the post-review rerun passed immediately in isolation),test:unit:main(4,857 passing with the documented path-length failure plus an inheritedGIT_CONFIG_GLOBAL/GIT_PAGERenvironment failure),test:unit:resources(1,673 passing; randomized HNSW failure passed immediately in isolation),test:integration:all(1,805 passing, six Ollama children cancelled by a pre-existing Node JSON import-attribute error), formatting, and lint. This test-infrastructure-only fix is not separately observable end-to-end.Complexity: medium
Review-Coverage: authored=codex; ran=gemini; adjudicated=domain; declined=claude,cursor-grok,cursor-composer; rounds=2 @ b879915
Human-Review-Need: 3 (decisions: run-root-identity, system-seed-fidelity, key-cache-workaround, test-cache-api) @ b879915