| … | … | @@ -0,0 +1,86 @@ |
| 1 | // Are the two autobase patches this daemon depends on actually present? | |
| 2 | // | |
| 3 | // ⛔ WHY A BOOT CHECK AND NOT TRUST. `scripts/patch-autobase-warmup.cjs` re-applies both patches on | |
| 4 | // every `npm install`, so a reinstall cannot revert them — proven 2026-09-15 against a pristine | |
| 5 | // upstream copy. What it does NOT survive is an autobase BUMP: the anchors move, the script prints | |
| 6 | // | |
| 7 | // anchor not found (autobase version changed?) — SKIPPING; re-verify manually | |
| 8 | // | |
| 9 | // and the install SUCCEEDS. Both guards vanish, and what they prevent is a SIGABRT crash-loop on boot | |
| 10 | // (pear-git #3) and a daemon wedged in a microtask chain that accepts nothing and reaps nothing | |
| 11 | // (measured on stortron: 800,000 `_advance` calls, no banner). A console line at install time is not | |
| 12 | // a defence against either — nobody reads install output, and the symptom arrives days later wearing | |
| 13 | // a different face. | |
| 14 | // | |
| 15 | // `overrides: { "autobase": "7.28.1" }` pins the version and is the only thing holding this shut | |
| 16 | // today. This makes the pin's failure LOUD instead of silent. | |
| 17 | // | |
| 18 | // ⛔ FAIL CLOSED ON A POSITIVE ANSWER ONLY. A file we can read whose marker is missing is a fact, and | |
| 19 | // we refuse. A file we cannot read at all is NOT knowledge — the layout could have changed, this | |
| 20 | // could be a bundled tree, the check itself could be wrong — and refusing to boot a production forge | |
| 21 | // over our own uncertainty is worse than the thing we are guarding against. Unknown proceeds, loudly. | |
| 22 | // Same posture as chooseBundle's addon guard, for the same reason. | |
| 23 | // | |
| 24 | // PURE + INJECTED: `read` is a parameter, so every branch is testable with no node_modules. | |
| 25 | ||
| 26 | export const GUARDS = [ | |
| 27 | { | |
| 28 | file: 'node_modules/autobase/lib/apply-state.js', | |
| 29 | marker: 'GUARD (pgv3 patch)', | |
| 30 | protects: 'null view.core during _drain warmup — without it the daemon SIGABRT-crash-loops on boot (pear-git #3)' | |
| 31 | }, | |
| 32 | { | |
| 33 | file: 'node_modules/autobase/index.js', | |
| 34 | marker: 'BOUND (pgv3 patch)', | |
| 35 | protects: 'the _appendBatch flush loop — without it a mirror whose writer the origin has not enrolled spins forever and the daemon never serves (stortron, 800k _advance calls)' | |
| 36 | } | |
| 37 | ] | |
| 38 | ||
| 39 | /** | |
| 40 | * @param {object} o | |
| 41 | * read (relativePath) => string | null — source text, or null when it cannot be read | |
| 42 | * @returns {{ ok: boolean, missing: object[], unreadable: object[] }} | |
| 43 | */ | |
| 44 | export function checkAutobaseGuards ({ read } = {}) { | |
| 45 | if (typeof read !== 'function') throw new Error('checkAutobaseGuards requires read(path)') | |
| 46 | const missing = [] | |
| 47 | const unreadable = [] | |
| 48 | for (const g of GUARDS) { | |
| 49 | let src = null | |
| 50 | try { src = read(g.file) } catch { src = null } | |
| 51 | if (src == null) { unreadable.push(g); continue } | |
| 52 | if (!src.includes(g.marker)) missing.push(g) | |
| 53 | } | |
| 54 | return { ok: missing.length === 0, missing, unreadable } | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Boot gate. Throws when a guard is positively absent; warns when it cannot tell. | |
| 59 | * | |
| 60 | * ⚠ `PEAR_GIT_ALLOW_UNPATCHED_AUTOBASE=1` overrides, for the operator who has upstreamed the patches | |
| 61 | * or is deliberately testing without them. An override that must be typed is a decision; a check that | |
| 62 | * can be skipped by accident is not a check. | |
| 63 | */ | |
| 64 | export function assertAutobaseGuards ({ read, env = {}, log = console.error } = {}) { | |
| 65 | const { ok, missing, unreadable } = checkAutobaseGuards({ read }) | |
| 66 | ||
| 67 | for (const g of unreadable) { | |
| 68 | log('[autobase-guards] cannot read ' + g.file + ' — NOT verifying: ' + g.protects) | |
| 69 | } | |
| 70 | if (ok) return { ok: true, missing, unreadable } | |
| 71 | ||
| 72 | const detail = missing.map((g) => ' ' + g.file + ' is missing "' + g.marker + '"\n guards: ' + g.protects).join('\n') | |
| 73 | const msg = 'autobase is not patched for this daemon.\n' + detail + | |
| 74 | '\n\nRun `npm install` (postinstall re-applies both), or if autobase was bumped the anchors have' + | |
| 75 | '\nmoved and scripts/patch-autobase-warmup.cjs needs re-pointing — it warns and succeeds in that' + | |
| 76 | '\ncase, which is why this check exists.' + | |
| 77 | '\n\nTo boot anyway: PEAR_GIT_ALLOW_UNPATCHED_AUTOBASE=1' | |
| 78 | ||
| 79 | if (env.PEAR_GIT_ALLOW_UNPATCHED_AUTOBASE === '1') { | |
| 80 | log('[autobase-guards] OVERRIDDEN — booting unpatched.\n' + msg) | |
| 81 | return { ok: false, overridden: true, missing, unreadable } | |
| 82 | } | |
| 83 | throw new Error('[autobase-guards] ' + msg) | |
| 84 | } | |
| 85 | ||
| 86 | export default { GUARDS, checkAutobaseGuards, assertAutobaseGuards } |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pear-git-v3", |
| 3 | "version": "0.1.252", | |
| 4 | "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.", | |
| 3 | "version": "0.1.253", | |
| 4 | "description": "P2P git forge \u2014 multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| 7 | 7 | "scripts": { |
| … | … | @@ -184,6 +184,23 @@ let shuttingDown = false |
| 184 | 184 | let otaRuntime = null |
| 185 | 185 | |
| 186 | 186 | async function main () { |
| 187 | // ⛔ BEFORE startPearGit, because that is where the unpatched failures happen: a SIGABRT | |
| 188 | // crash-loop on boot, or a wedge in a microtask chain that never hands the event loop back. | |
| 189 | // Refusing here is a sentence an operator can act on; both alternatives are a daemon that looks | |
| 190 | // broken for reasons nothing names. See lib/autobase-guards.js. | |
| 191 | { | |
| 192 | // ⚠ `#node:fs`, NOT `node:fs`. This daemon runs under BOTH node and bare (`start` / `start:bare`), | |
| 193 | // and the bare build has no `node:` builtins — the import map at the top of package.json is the | |
| 194 | // whole reason every other import in this file is `#node:…`. A plain `node:fs` here would load | |
| 195 | // fine under node and fail at boot under bare, which is the runtime the fleet actually ships. | |
| 196 | const { assertAutobaseGuards } = await import('./lib/autobase-guards.js') | |
| 197 | const root = dirname(fileURLToPath(import.meta.url)) | |
| 198 | assertAutobaseGuards({ | |
| 199 | read: (rel) => { try { return readFileSync(join(root, rel), 'utf8') } catch { return null } }, | |
| 200 | env: process.env | |
| 201 | }) | |
| 202 | } | |
| 203 | ||
| 187 | 204 | node = await startPearGit(opts) |
| 188 | 205 | |
| 189 | 206 | console.log('pear-git-v3 daemon') |
| … | … | @@ -0,0 +1,74 @@ |
| 1 | // The two autobase patches this daemon cannot boot safely without. | |
| 2 | // | |
| 3 | // ⛔ THE HOLE THIS CLOSES. `scripts/patch-autobase-warmup.cjs` re-applies both on every npm install — | |
| 4 | // proven against a pristine upstream copy — so a REINSTALL cannot revert them. An autobase BUMP can: | |
| 5 | // the anchors move, the script prints "anchor not found … SKIPPING" and the install SUCCEEDS. Both | |
| 6 | // guards vanish silently, and what they prevent is a SIGABRT crash-loop on boot and a daemon wedged | |
| 7 | // in a microtask chain that accepts nothing. | |
| 8 | // | |
| 9 | // ⚠ A console line at install time is not a defence. Nobody reads install output, and the symptom | |
| 10 | // arrives days later wearing a different face — which is how both of these were found the first time. | |
| 11 | ||
| 12 | import test from 'brittle' | |
| 13 | import { readFileSync } from '#node:fs' | |
| 14 | import { join } from '#node:path' | |
| 15 | import { checkAutobaseGuards, assertAutobaseGuards, GUARDS } from '../lib/autobase-guards.js' | |
| 16 | ||
| 17 | const ROOT = new URL('..', import.meta.url).pathname | |
| 18 | ||
| 19 | const patched = () => ({ | |
| 20 | 'node_modules/autobase/lib/apply-state.js': 'x\n// GUARD (pgv3 patch): …\ny', | |
| 21 | 'node_modules/autobase/index.js': 'a\n// BOUND (pgv3 patch): …\nb' | |
| 22 | }) | |
| 23 | const reader = (files) => (p) => (p in files ? files[p] : null) | |
| 24 | ||
| 25 | test('both guards present — boots', (t) => { | |
| 26 | const r = checkAutobaseGuards({ read: reader(patched()) }) | |
| 27 | t.ok(r.ok) | |
| 28 | t.is(r.missing.length, 0) | |
| 29 | t.is(r.unreadable.length, 0) | |
| 30 | }) | |
| 31 | ||
| 32 | test('★★★ a guard positively ABSENT refuses the boot, and names what it protected', (t) => { | |
| 33 | const files = patched() | |
| 34 | files['node_modules/autobase/index.js'] = 'a\n// upstream, unpatched\nb' | |
| 35 | t.exception( | |
| 36 | () => assertAutobaseGuards({ read: reader(files), env: {}, log: () => {} }), | |
| 37 | /_appendBatch flush loop/, | |
| 38 | 'the error says what breaks, not merely that a string is missing' | |
| 39 | ) | |
| 40 | }) | |
| 41 | ||
| 42 | test('⛔ a file we cannot READ does not refuse — unknown is not a fact', (t) => { | |
| 43 | const said = [] | |
| 44 | // The layout could have changed, this could be a bundled tree, the check itself could be wrong. | |
| 45 | // Refusing to boot a production forge over our own uncertainty is worse than what we guard against. | |
| 46 | const r = assertAutobaseGuards({ read: () => null, env: {}, log: (m) => said.push(m) }) | |
| 47 | t.ok(r.ok, 'unreadable is not missing') | |
| 48 | t.is(said.length, GUARDS.length, 'but every one it could not verify is said out loud') | |
| 49 | t.ok(said.every((m) => /NOT verifying/.test(m))) | |
| 50 | }) | |
| 51 | ||
| 52 | test('⚠ the override boots, and says so', (t) => { | |
| 53 | const files = patched() | |
| 54 | delete files['node_modules/autobase/index.js'] | |
| 55 | files['node_modules/autobase/index.js'] = 'unpatched' | |
| 56 | const said = [] | |
| 57 | const r = assertAutobaseGuards({ | |
| 58 | read: reader(files), | |
| 59 | env: { PEAR_GIT_ALLOW_UNPATCHED_AUTOBASE: '1' }, | |
| 60 | log: (m) => said.push(m) | |
| 61 | }) | |
| 62 | t.absent(r.ok) | |
| 63 | t.ok(r.overridden) | |
| 64 | t.ok(said.some((m) => /OVERRIDDEN/.test(m)), 'an override that is silent is indistinguishable from a check that does not run') | |
| 65 | }) | |
| 66 | ||
| 67 | test('★ the real tree passes — this is the check that would have caught a bump', async (t) => { | |
| 68 | const r = checkAutobaseGuards({ | |
| 69 | read: (p) => { try { return readFileSync(join(ROOT, p), 'utf8') } catch { return null } } | |
| 70 | }) | |
| 71 | // ⚠ If autobase is ever unreadable here this goes to `unreadable` and passes — deliberately. The | |
| 72 | // assertion is about THIS tree being patched, not about the test environment having node_modules. | |
| 73 | t.is(r.missing.length, 0, 'no guard is positively missing in the installed autobase') | |
| 74 | }) |