Files
gbrain/test/pglite-wal-repair.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

201 lines
8.4 KiB
TypeScript

/**
* The ported upstream regression (electric-sql/pglite PR #994) against a REAL
* PGLite brain: create → insert → clean shutdown → corrupt the WAL → reopen
* through PGLiteEngine.connect() → auto-repair fires → the original row is
* still readable. Plus the kill-switch, gate-level negatives, and the #2084
* exitCode pin.
*
* .serial: real PGLite cold starts + process.env writes (docs/TESTING.md R1).
*/
import { describe, test, expect } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, truncateSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, basename } from 'node:path';
import { withEnv } from './helpers/with-env.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import type { EngineConfig } from '../src/core/types.ts';
const COLD_START_TIMEOUT = 120_000;
function engineConfig(dir: string): EngineConfig {
return { engine: 'pglite', database_path: dir } as EngineConfig;
}
/** Build a real brain with one probe row, cleanly shut down. */
async function buildRealBrain(): Promise<string> {
const dir = join(mkdtempSync(join(tmpdir(), 'walrepair-')), 'brain.pglite');
const engine = new PGLiteEngine();
await engine.connect(engineConfig(dir));
await engine.db.exec('CREATE TABLE repair_probe (id int); INSERT INTO repair_probe VALUES (42);');
await engine.disconnect();
return dir;
}
function walSegments(dir: string): string[] {
return readdirSync(join(dir, 'pg_wal')).filter((f) => /^[0-9A-F]{24}$/.test(f));
}
function backupDirs(dir: string): string[] {
return readdirSync(dirname(dir)).filter((f) => f.startsWith(`${basename(dir)}.wal-repair-backup-`));
}
function corruptAllSegments(dir: string, mode: 'truncate' | 'garbage'): void {
const segs = walSegments(dir);
expect(segs.length).toBeGreaterThan(0);
for (const seg of segs) {
const p = join(dir, 'pg_wal', seg);
if (mode === 'truncate') {
truncateSync(p, 1024);
} else {
// Overwrite the whole segment with garbage, keeping its size.
const size = readFileSync(p).length;
writeFileSync(p, Buffer.alloc(size, 0xff));
}
}
}
async function connectExpectingRepair(dir: string): Promise<PGLiteEngine> {
const engine = new PGLiteEngine();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => { warns.push(args.join(' ')); };
try {
await engine.connect(engineConfig(dir));
} finally {
console.warn = origWarn;
}
// #2084 pin: the retry create's exit-status scribble is contained.
expect(Number(process.exitCode ?? 0)).toBe(0);
expect(engine.walRepairReceipt).not.toBeNull();
expect(warns.join('\n')).toContain('repaired');
return engine;
}
describe('WAL auto-repair — real-brain regression (#223/#1670/#2575)', () => {
test('case A (truncated WAL): connect() auto-repairs and the row survives', async () => {
const dir = await buildRealBrain();
corruptAllSegments(dir, 'truncate');
const engine = await connectExpectingRepair(dir);
try {
const receipt = engine.walRepairReceipt!;
expect(existsSync(receipt.backupPath)).toBe(true);
expect(existsSync(join(receipt.backupPath, 'pg_wal'))).toBe(true);
const rows = await engine.db.query('SELECT id FROM repair_probe');
expect((rows.rows[0] as { id: number }).id).toBe(42);
} finally {
await engine.disconnect();
}
}, COLD_START_TIMEOUT);
test('case B (garbage-overwritten WAL): connect() auto-repairs and the row survives', async () => {
const dir = await buildRealBrain();
corruptAllSegments(dir, 'garbage');
const engine = await connectExpectingRepair(dir);
try {
const rows = await engine.db.query('SELECT id FROM repair_probe');
expect((rows.rows[0] as { id: number }).id).toBe(42);
// A healthy reconnect afterwards does NOT re-fire repair.
await engine.disconnect();
const engine2 = new PGLiteEngine();
await engine2.connect(engineConfig(dir));
expect(engine2.walRepairReceipt).toBeNull();
await engine2.disconnect();
} finally {
try { await engine.disconnect(); } catch { /* already disconnected */ }
}
}, COLD_START_TIMEOUT);
test('kill-switch: GBRAIN_PGLITE_WAL_REPAIR=off → honest error, no backup, lock released', async () => {
const dir = await buildRealBrain();
corruptAllSegments(dir, 'garbage');
const backupsBefore = backupDirs(dir).length;
await withEnv({ GBRAIN_PGLITE_WAL_REPAIR: 'off' }, async () => {
const engine = new PGLiteEngine();
let message = '';
try {
await engine.connect(engineConfig(dir));
throw new Error('connect unexpectedly succeeded');
} catch (err) {
message = String((err as Error).message);
}
expect(message).toContain('PGLite failed to initialize');
expect(message).toContain('GBRAIN_PGLITE_WAL_REPAIR=off');
expect(message).toContain('gbrain pglite-repair');
expect(message).toContain('Original error:');
});
// No surgery happened…
expect(backupDirs(dir).length).toBe(backupsBefore);
// …and the lock was released: repair works on the next (enabled) connect.
const engine = await connectExpectingRepair(dir);
const rows = await engine.db.query('SELECT id FROM repair_probe');
expect((rows.rows[0] as { id: number }).id).toBe(42);
await engine.disconnect();
}, COLD_START_TIMEOUT);
test('gate negative: a REAL wasm-abort with the cooldown gate active refuses repair — no new backup, honest skip reason', async () => {
// A genuinely corrupt brain (create() aborts with the production
// `RuntimeError: Aborted()` signature) whose sidecar records a fresh
// failed attempt — the classifier says wasm-abort, but the cooldown gate
// must refuse BEFORE any surgery. Pins the skip path end-to-end: verdict
// fired, gate refused, zero backup dirs created, honest message.
const { recordRepairAttempt } = await import('../src/core/pglite-repair.ts');
const dir = await buildRealBrain();
corruptAllSegments(dir, 'garbage');
recordRepairAttempt(dir, 'failed', null);
const engine = new PGLiteEngine();
let message = '';
try {
await engine.connect(engineConfig(dir));
throw new Error('connect unexpectedly succeeded');
} catch (err) {
message = String((err as Error).message);
}
expect(message).toContain('PGLite failed to initialize');
expect(message).toContain('Auto-repair skipped');
expect(message).toContain('gbrain pglite-repair');
expect(backupDirs(dir).length).toBe(0);
}, COLD_START_TIMEOUT);
test('gate negative: symlinked data dir — Emscripten refuses the mount with a NAMED error, repair never fires', async () => {
// PGLite's NODEFS cannot mount through a symlinked data dir: it throws a
// message-less `ErrnoError { errno: 20 }`. Two pins: (a) the error
// stringifier surfaces name+errno instead of "[object Object]" (#2674
// class), (b) no repair surgery runs on either path.
const { symlinkSync } = await import('node:fs');
const real = await buildRealBrain();
corruptAllSegments(real, 'garbage');
const link = join(mkdtempSync(join(tmpdir(), 'walrepair-')), 'link.pglite');
symlinkSync(real, link);
const engine = new PGLiteEngine();
let message = '';
try {
await engine.connect(engineConfig(link));
throw new Error('connect unexpectedly succeeded');
} catch (err) {
message = String((err as Error).message);
}
expect(message).toContain('PGLite failed to initialize');
expect(message).not.toContain('[object Object]');
expect(message).toContain('ErrnoError (errno 20)');
expect(backupDirs(real).length).toBe(0);
expect(backupDirs(link).length).toBe(0);
}, COLD_START_TIMEOUT);
test('gate shape: the seam only runs for wasm-abort + persistent dataDir (structural pin)', () => {
const src = readFileSync('src/core/pglite-engine.ts', 'utf-8');
expect(src).toMatch(/if \(verdict === 'wasm-abort'\)/);
expect(src).toMatch(/if \(!dataDir\) \{\s*\n\s*ctx = \{ repair: 'in-memory' \}/);
// The seam call sits INSIDE the wasm-abort branch (no call site outside it).
const firstSeamCall = src.indexOf('await attemptWalRepairAndRetry(');
const gate = src.indexOf("if (verdict === 'wasm-abort')");
expect(gate).toBeGreaterThan(-1);
expect(firstSeamCall).toBeGreaterThan(gate);
expect(src.indexOf('await attemptWalRepairAndRetry(', firstSeamCall + 1)).toBe(-1);
});
});