mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
* 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>
201 lines
8.4 KiB
TypeScript
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);
|
|
});
|
|
});
|