Files
gbrain/test/pglite-repair-command.serial.test.ts
Garry TanandClaude Fable 5 f15480b9d0 v0.42.75.0 fix(pglite): in-place WAL auto-repair for the macOS Aborted() startup crash (#2575, #223, #1670) (#3901)
* fix(pglite): in-place WAL auto-repair for the Aborted() startup crash (#223, #1670, #2575)

The 'macOS 26.x WASM bug' was a misdiagnosis: an unclean shutdown (typically
the OS-upgrade reboot) tears the data dir's WAL, and every subsequent open
fails WAL replay inside WASM with an opaque RuntimeError: Aborted(). This
ports the pg_resetwal recovery upstream rejected (electric-sql/pglite#994,
by @yestheboxer) and wires it into connect() as bounded auto-repair:

- src/core/pglite-resetwal.ts: pg_resetwal for PG17 NodeFS dirs, fail-closed
  layout validation, atomic+durable writes (tmp+fsync+rename), idempotent.
- src/core/pglite-repair.ts: whole-pg_wal-dir rename backup (zero transient
  disk), overwrite-order restore with mtime guard, cooldown sidecar +
  episode-scoped backup retention (newest 3 episodes), and a never-throws
  engine seam. Kill-switch: GBRAIN_PGLITE_WAL_REPAIR=off.
- pglite-engine.ts: verdict rename macos-26-3 -> wasm-abort, classifier now
  matches the real production message (it previously fell to 'unknown'),
  corrupt-beats-wasm precedence preserved, honest per-outcome error copy
  incl. the failed-not-restored arm, and repair only under a cleanly-acquired
  lock (new LockHandle.reaped provenance; never after reaping a holder).
- gbrain pglite-repair: manual dry-run/repair command (validate-before-lock,
  serve/reaped refusals, no --force by design).
- doctor: pglite_data_dir fs-check with recurrence escalation and backup
  inventory when a PGLite brain fails to connect.
- reinit-pglite: embedding flags default from file-only config so the
  recovery ladder's rebuild rung works bare mid-outage.
- stringifyPgliteInitError: message-less Emscripten ErrnoError objects no
  longer surface as [object Object].

Regression-tested against real brains: corrupt every WAL segment (truncate
and garbage variants), reopen, auto-repair fires, original rows readable,
process.exitCode stays contained (#2084).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(pglite): replace the macOS-26.x misdiagnosis with the corrupt-WAL recovery ladder

README + INSTALL.md shipped (via #1671) the claim that PGLite is incompatible
with macOS 26.x and that a Bun/WASM fix would restore it. The real cause is
torn WAL state from the upgrade reboot, now auto-repaired in place. Rewrites
those sections around the recovery ladder (auto-repair -> gbrain pglite-repair
-> reinit-pglite -> engine switch; native-Postgres recipe kept, credit
@roysaurav), adds the ENGINES.md troubleshooting section, updates the
KEY_FILES.md entries to current truth, files the two follow-up TODOs
(SIGTERM engine-close extension; pglite upgrade blocker), and regenerates
the llms bundles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pglite): harden WAL auto-repair (pre-landing + adversarial review)

Review-army (security/testing/maintainability/perf) + Claude & Codex
adversarial passes on the WAL-repair wave. Correctness + safety hardening,
no behavior change to the happy path:

- Live-writer safety: repair refuses any reaped lock acquisition, a corrupt
  (unknowable-liveness) reap writes a cross-process quarantine marker that
  gates auto-repair AND the manual command for 10 min, isProcessAlive treats
  only ESRCH as dead (EPERM/malformed-pid read as alive), and a live
  postmaster.pid (native Postgres) is refused. Lock heartbeat + initial write
  are atomic (tmp+rename) so a torn read can't misclassify a healthy holder;
  an in-flight acquisition is no longer mistaken for corrupt.
- resetWal verifies the stored pg_control CRC before trusting/re-signing it —
  a damaged control file routes to rebuild instead of laundering corrupt
  checkpoint counters under a fresh CRC. Atomic 'wx' writes (no symlink
  follow), whole-pg_wal-dir rename backup, 64MB seg-size cap.
- Honest failure reporting: repairPgliteWal threads the real restore result
  out via WalRepairError so the 'failed-restored' vs 'failed-not-restored'
  message never lies; the not-restored copy names the correct restore paths.
- Episode lifecycle: episodes close on the next healthy connect (not just on
  a verified repair), a gutted (restored) backup loses its pin, stale (>24h)
  episode backups aren't reused, and the cooldown also caps repaired-only
  crash loops. Empty backup dirs are pruned on refusal.
- Command: rejects unknown flags and valueless --path (a destructive command
  must not silently mis-parse), confirm prompt goes to stderr (stdout stays
  clean for --json), embedding-flag defaults come from the config file only.
- Symlink confinement extended to global/; sidecar reuse path validated
  (prefix + no '..' + must still hold pg_wal); sidecar writes atomic.
- doctor recurrence escalation counts all attempts; data dir absolutized.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(pglite): current-state KEY_FILES + WAL-repair follow-up TODOs

KEY_FILES.md pglite entries updated to the hardened truth (reap marker +
quarantine, atomic writes, CRC gate, global-symlink refusal, WalRepairError,
episode lifecycle). TODOS.md files the deferred judgment-call follow-ups
(unclean-shutdown gate on auto-repair; non-gbrain pglite consumer boundary;
mixed-version torn-lock double-read).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump version and changelog (v0.42.75.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 17:01:20 -07:00

419 lines
15 KiB
TypeScript

/**
* `gbrain pglite-repair` command surface (#223 WAL-repair wave) — SERIAL:
* the happy path does a real persistent-PGLite cold start (create → corrupt
* pg_wal → repair in place → reconnect), which is too heavy + lock-contended
* for the parallel unit shards.
*
* `runPgliteRepair` is imported directly (no process spawns); stdout/stderr
* are captured by spying console.log/console.error per test and restored in
* finally. Every case uses `--path <hermetic tmpdir>` so the user's real
* brain and config are never touched, and `--json` so assertions parse a
* machine receipt instead of prose.
*
* Refusal-order note (cases 5-7): the fake layout deliberately PASSES
* `validateWalRepairTarget` (PG_VERSION 17 + base/ + 8192-byte pg_control) so
* the command reaches its lock gates; an actual repair on the garbage
* pg_control would fail in resetWal, but all three cases must refuse BEFORE
* repair — asserting the refused_* codes proves the ordering.
*/
import { describe, test, expect } from 'bun:test';
import {
existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join } from 'node:path';
import { runPgliteRepair } from '../src/commands/pglite-repair.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { withEnv } from './helpers/with-env.ts';
function tmp(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
/** Fake PG17 layout that passes validateWalRepairTarget (see file header). */
function makeFakeLayout(dir: string): void {
mkdirSync(join(dir, 'base'), { recursive: true });
mkdirSync(join(dir, 'global'), { recursive: true });
mkdirSync(join(dir, 'pg_wal'), { recursive: true });
writeFileSync(join(dir, 'PG_VERSION'), '17\n');
writeFileSync(join(dir, 'global', 'pg_control'), Buffer.alloc(8192));
}
function writeLockFile(dir: string, lock: Record<string, unknown>): string {
const lockDir = join(dir, '.gbrain-lock');
mkdirSync(lockDir, { recursive: true });
writeFileSync(join(lockDir, 'lock'), JSON.stringify(lock), { mode: 0o644 });
return lockDir;
}
interface Captured {
logs: string[];
errors: string[];
restore: () => void;
}
/** Spy console.log/console.error; caller MUST call restore() in finally. */
function captureConsole(): Captured {
const logs: string[] = [];
const errors: string[] = [];
const origLog = console.log;
const origErr = console.error;
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(' ')); };
console.error = (...args: unknown[]) => { errors.push(args.map(String).join(' ')); };
return {
logs,
errors,
restore: () => {
console.log = origLog;
console.error = origErr;
},
};
}
/** Last parseable JSON object line from captured console.log output. */
function parseJsonLine(logs: string[]): Record<string, any> {
for (let i = logs.length - 1; i >= 0; i--) {
const line = logs[i].trim();
if (!line.startsWith('{')) continue;
try {
return JSON.parse(line) as Record<string, any>;
} catch { /* not this line — keep looking */ }
}
throw new Error(`no JSON line in captured output: ${JSON.stringify(logs)}`);
}
/**
* A PID that provably belongs to no live process: spawn a short-lived child,
* wait for it (spawnSync reaps it), then verify kill(pid, 0) throws. Retries
* to dodge instant PID reuse.
*/
function deadPid(): number {
for (let attempt = 0; attempt < 5; attempt++) {
const proc = Bun.spawnSync(['bash', '-c', 'exit 0']);
const pid = proc.pid;
try {
process.kill(pid, 0); // still alive/visible → PID reused, try again
} catch {
return pid;
}
}
throw new Error('could not obtain a provably-dead PID after 5 spawns');
}
const backupDirsBeside = (dir: string): string[] =>
readdirSync(join(dir, '..')).filter((n) => n.startsWith(`${basename(dir)}.wal-repair-backup-`));
describe('gbrain pglite-repair — dry-run is strictly read-only', () => {
test('1. dry-run on a corrupt-ish layout: exit 0, JSON diagnosis, zero mutation', async () => {
const parent = tmp('gbrain-repair-dry-');
const dir = join(parent, 'brain.pglite');
makeFakeLayout(dir);
writeFileSync(join(dir, 'postmaster.pid'), '99999\n'); // unclean-shutdown marker
const parentBefore = readdirSync(parent).sort();
const dirBefore = readdirSync(dir).sort();
const cap = captureConsole();
let rc: number;
try {
rc = await runPgliteRepair(['--dry-run', '--path', dir, '--json']);
} finally {
cap.restore();
}
expect(rc).toBe(0);
const out = parseJsonLine(cap.logs);
expect(out.status).toBe('ok');
expect(out.action).toBe('dry-run');
expect(out.data_dir).toBe(dir);
expect(out.validation.ok).toBe(true);
expect(out.diagnosis.verdict).toBeDefined();
expect(out.diagnosis.verdict).toBe('wal-corruption-likely');
// Read-only: no backup dirs, no sidecar, nothing added or removed.
expect(backupDirsBeside(dir)).toEqual([]);
expect(existsSync(`${dir}.wal-repair-attempt.json`)).toBe(false);
expect(readdirSync(parent).sort()).toEqual(parentBefore);
expect(readdirSync(dir).sort()).toEqual(dirBefore);
expect(existsSync(join(dir, 'postmaster.pid'))).toBe(true);
});
test('2. dry-run on a missing path: exit 0, validation.ok false, path NOT created', async () => {
const missing = join(tmp('gbrain-repair-dry-missing-'), 'never-created.pglite');
const cap = captureConsole();
let rc: number;
try {
rc = await runPgliteRepair(['--dry-run', '--path', missing, '--json']);
} finally {
cap.restore();
}
expect(rc).toBe(0);
const out = parseJsonLine(cap.logs);
expect(out.status).toBe('ok');
expect(out.action).toBe('dry-run');
expect(out.validation.ok).toBe(false);
expect(out.diagnosis.verdict).toBe('missing');
expect(existsSync(missing)).toBe(false);
});
});
describe('gbrain pglite-repair — refusals (validate before lock, never mkdir a typo)', () => {
test('3. non-dry-run on a missing path: exit 1, refused_missing-dir, path NOT created', async () => {
const missing = join(tmp('gbrain-repair-missing-'), 'typo.pglite');
const cap = captureConsole();
let rc: number;
try {
rc = await runPgliteRepair(['--path', missing, '--yes', '--json']);
} finally {
cap.restore();
}
expect(rc).toBe(1);
const out = parseJsonLine(cap.logs);
expect(out.status).toBe('error');
expect(out.code).toBe('refused_missing-dir');
// validate-before-lock: acquireLock would have mkdir'd the dir.
expect(existsSync(missing)).toBe(false);
});
test('5. live lock holder: exit 1, refused_locked, no repair attempted', async () => {
const dir = join(tmp('gbrain-repair-locked-'), 'brain.pglite');
makeFakeLayout(dir);
const lockDir = writeLockFile(dir, {
pid: process.pid, // this test process — provably alive
acquired_at: Date.now(),
refreshed_at: Date.now(),
command: 'gbrain embed',
subcommand: 'embed',
});
const cap = captureConsole();
let rc: number;
try {
rc = await runPgliteRepair(['--path', dir, '--yes', '--json']);
} finally {
cap.restore();
rmSync(lockDir, { recursive: true, force: true });
}
expect(rc).toBe(1);
const out = parseJsonLine(cap.logs);
expect(out.status).toBe('error');
expect(out.code).toBe('refused_locked');
// Refused BEFORE repair: no backup dir, no sidecar.
expect(backupDirsBeside(dir)).toEqual([]);
expect(existsSync(`${dir}.wal-repair-attempt.json`)).toBe(false);
});
test('6. reaped (dead-PID) lock: exit 1, refused_reaped_lock', async () => {
const dir = join(tmp('gbrain-repair-reaped-'), 'brain.pglite');
makeFakeLayout(dir);
writeLockFile(dir, {
pid: deadPid(), // provably dead — acquireLock reaps it, then refuses
acquired_at: Date.now() - 60_000,
refreshed_at: Date.now() - 60_000,
command: 'gbrain embed',
subcommand: 'embed',
});
const cap = captureConsole();
let rc: number;
try {
rc = await runPgliteRepair(['--path', dir, '--yes', '--json']);
} finally {
cap.restore();
}
expect(rc).toBe(1);
const out = parseJsonLine(cap.logs);
expect(out.status).toBe('error');
expect(out.code).toBe('refused_reaped_lock');
// Refused BEFORE repair: no backup dir, no sidecar.
expect(backupDirsBeside(dir)).toEqual([]);
expect(existsSync(`${dir}.wal-repair-attempt.json`)).toBe(false);
}, 30_000);
test('7. live serve holder: exit 1, refused_locked names the PID', async () => {
const dir = join(tmp('gbrain-repair-serve-'), 'brain.pglite');
makeFakeLayout(dir);
const lockDir = writeLockFile(dir, {
pid: process.pid, // alive — the pre-lock diagnosis catches it as 'locked'
acquired_at: Date.now(),
refreshed_at: Date.now(),
command: 'gbrain serve',
subcommand: 'serve',
});
const cap = captureConsole();
let rc: number;
try {
rc = await runPgliteRepair(['--path', dir, '--yes', '--json']);
} finally {
cap.restore();
rmSync(lockDir, { recursive: true, force: true });
}
expect(rc).toBe(1);
const out = parseJsonLine(cap.logs);
expect(out.status).toBe('error');
expect(out.code).toBe('refused_locked');
expect(out.message).toContain(String(process.pid));
expect(backupDirsBeside(dir)).toEqual([]);
});
});
describe('gbrain pglite-repair — TTY + config gates', () => {
test('8. non-TTY without --yes refuses: exit 1, no_tty_no_yes, zero mutation', async () => {
const dir = join(tmp('gbrain-repair-notty-'), 'brain.pglite');
makeFakeLayout(dir);
// Pin stdin to non-TTY: under `bun test` in a terminal stdin can still be
// a TTY, which would route into the interactive confirm instead.
const origTty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
const cap = captureConsole();
let rc: number;
try {
rc = await runPgliteRepair(['--path', dir, '--json']); // no --yes
} finally {
cap.restore();
if (origTty) Object.defineProperty(process.stdin, 'isTTY', origTty);
else delete (process.stdin as unknown as Record<string, unknown>).isTTY;
}
expect(rc).toBe(1);
const out = parseJsonLine(cap.logs);
expect(out.status).toBe('error');
expect(out.code).toBe('no_tty_no_yes');
// Refused BEFORE any surgery: no backup dir, no sidecar.
expect(backupDirsBeside(dir)).toEqual([]);
expect(existsSync(`${dir}.wal-repair-attempt.json`)).toBe(false);
});
test('9. no --path with a non-pglite configured engine: exit 1, not_pglite', async () => {
// Hermetic GBRAIN_HOME (same convention as apply-migrations-pglite-spawn):
// configDir() appends '.gbrain', so the config lands at <home>/.gbrain/.
const home = tmp('gbrain-repair-home-');
mkdirSync(join(home, '.gbrain'), { recursive: true });
writeFileSync(
join(home, '.gbrain', 'config.json'),
JSON.stringify({ engine: 'postgres', database_url: 'postgresql://localhost:5432/x' }) + '\n',
);
const cap = captureConsole();
let rc: number;
try {
rc = await withEnv({ GBRAIN_HOME: home }, () => runPgliteRepair(['--yes', '--json']));
} finally {
cap.restore();
}
expect(rc).toBe(1);
const out = parseJsonLine(cap.logs);
expect(out.status).toBe('error');
expect(out.code).toBe('not_pglite');
expect(out.message).toContain('--path');
});
});
describe('gbrain pglite-repair — the happy path (real PGLite)', () => {
test('4. corrupt pg_wal → repair in place → data survives, no auto-repair on reconnect', async () => {
const dir = join(tmp('gbrain-repair-happy-'), 'brain.pglite');
// 1) Real brain with a probe row, closed cleanly.
const engine = new PGLiteEngine();
await engine.connect({ database_path: dir });
try {
await engine.executeRaw('CREATE TABLE repair_probe(id int)');
await engine.executeRaw('INSERT INTO repair_probe VALUES (42)');
} finally {
await engine.disconnect();
}
// 2) Corrupt every WAL segment with garbage.
const walDir = join(dir, 'pg_wal');
const segments = readdirSync(walDir).filter((f) => /^[0-9A-F]{24}$/.test(f));
expect(segments.length).toBeGreaterThan(0); // sanity: there IS WAL to corrupt
for (const seg of segments) {
writeFileSync(join(walDir, seg), Buffer.alloc(1024, 0xff));
}
// 3) Repair in place.
const cap = captureConsole();
let rc: number;
try {
rc = await runPgliteRepair(['--path', dir, '--yes', '--json']);
} finally {
cap.restore();
}
expect(rc).toBe(0);
const receipt = parseJsonLine(cap.logs);
expect(receipt.status).toBe('ok');
expect(receipt.action).toBe('repaired');
expect(receipt.data_dir).toBe(dir);
expect(receipt.reset_segment).toMatch(/^[0-9A-F]{24}$/);
expect(existsSync(receipt.backup_path)).toBe(true);
expect(existsSync(join(receipt.backup_path, 'pg_wal'))).toBe(true);
// 4) The repaired dir opens WITHOUT auto-repair firing, data intact.
const engine2 = new PGLiteEngine();
await engine2.connect({ database_path: dir });
try {
expect(engine2.walRepairReceipt).toBeNull();
const rows = await engine2.executeRaw<{ id: number }>('SELECT id FROM repair_probe');
expect(rows.length).toBe(1);
expect(rows[0].id).toBe(42);
} finally {
await engine2.disconnect();
}
}, 180_000);
});
describe('gbrain pglite-repair — argument + quarantine hardening (adversarial fixes)', () => {
test('unknown flag is rejected (exit 2), not silently ignored on a destructive command', async () => {
const cap = captureConsole();
let rc: number;
try {
rc = await runPgliteRepair(['--dry-rnu', '--yes', '--json']);
} finally {
cap.restore();
}
expect(rc).toBe(2);
expect(parseJsonLine(cap.logs).code).toBe('unknown_flag');
});
test('--path with no value is rejected (does NOT retarget the default brain)', async () => {
const cap = captureConsole();
let rc: number;
try {
rc = await runPgliteRepair(['--yes', '--json', '--path']);
} finally {
cap.restore();
}
expect(rc).toBe(2);
expect(parseJsonLine(cap.logs).code).toBe('unknown_flag');
});
test('the command honors the cross-process reap quarantine (F3: a second --yes cannot bypass it)', async () => {
const dir = join(mkdtempSync(join(tmpdir(), 'pgrepaircmd-')), 'brain.pglite');
makeFakeLayout(dir);
// A fresh corrupt-lock reap marker from a prior run — the possibly-live
// writer it protects must not be repaired under.
writeFileSync(`${dir}.lock-reap.json`, JSON.stringify({ ts: Date.now(), by: 999999 }), { mode: 0o644 });
const cap = captureConsole();
let rc: number;
try {
rc = await runPgliteRepair(['--path', dir, '--yes', '--json']);
} finally {
cap.restore();
}
expect(rc).toBe(1);
expect(parseJsonLine(cap.logs).code).toBe('refused_reap_quarantine');
// No surgery: no backup dir created.
expect(readdirSync(join(dir, '..')).some((f) => f.includes('.wal-repair-backup-'))).toBe(false);
});
});