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

191 lines
8.3 KiB
TypeScript

/**
* Unit tests for the pg_resetwal port (src/core/pglite-resetwal.ts).
*
* Everything here runs on SYNTHETIC PG17 layouts (hand-built pg_control
* buffers) — fast, parallel-safe, no PGLite. The real-engine proof (corrupt a
* real brain's WAL → reopen → row readable) lives in
* test/pglite-wal-repair.serial.test.ts.
*/
import { describe, test, expect } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
resetWal,
crc32c,
parseWalSegNo,
xlogFileName,
WalResetUnsupportedError,
} from '../src/core/pglite-resetwal.ts';
const SEG_SIZE = 1024 * 1024; // 1MB — valid (power of two, divides 2^32), fast to write
const OFF = {
systemIdentifier: 0,
pgControlVersion: 8,
state: 16,
checkPoint: 32,
checkPointCopyRedo: 40,
checkPointCopyThisTimeLineID: 48,
xlogBlcksz: 224,
xlogSegSize: 228,
crc: 288,
} as const;
function makeControl(opts?: { version?: number; segSize?: number; blcksz?: number; tli?: number; redoSegNo?: bigint }): Buffer {
const control = Buffer.alloc(8192);
control.writeBigUInt64LE(0x1122334455667788n, OFF.systemIdentifier);
control.writeUInt32LE(opts?.version ?? 1700, OFF.pgControlVersion);
control.writeUInt32LE(opts?.tli ?? 1, OFF.checkPointCopyThisTimeLineID);
control.writeUInt32LE(opts?.blcksz ?? 8192, OFF.xlogBlcksz);
control.writeUInt32LE(opts?.segSize ?? SEG_SIZE, OFF.xlogSegSize);
const redoSegNo = opts?.redoSegNo ?? 3n;
control.writeBigUInt64LE(redoSegNo * BigInt(opts?.segSize ?? SEG_SIZE) + 40n, OFF.checkPointCopyRedo);
control.writeUInt32LE(crc32c([control.subarray(0, OFF.crc)]), OFF.crc); // valid CRC
return control;
}
function makeLayout(opts?: Parameters<typeof makeControl>[0] & { pgVersion?: string; segments?: string[] }): string {
const dir = mkdtempSync(join(tmpdir(), 'resetwal-'));
writeFileSync(join(dir, 'PG_VERSION'), `${opts?.pgVersion ?? '17'}\n`);
mkdirSync(join(dir, 'global'), { recursive: true });
writeFileSync(join(dir, 'global', 'pg_control'), makeControl(opts));
mkdirSync(join(dir, 'base'), { recursive: true });
mkdirSync(join(dir, 'pg_wal', 'archive_status'), { recursive: true });
for (const seg of opts?.segments ?? []) {
writeFileSync(join(dir, 'pg_wal', seg), Buffer.alloc(1024, 0xaa));
}
return dir;
}
describe('resetWal — validation refusals (fail-closed)', () => {
test('refuses PG_VERSION 16', async () => {
const dir = makeLayout({ pgVersion: '16' });
await expect(resetWal(dir)).rejects.toThrow(WalResetUnsupportedError);
});
test('refuses missing PG_VERSION', async () => {
const dir = mkdtempSync(join(tmpdir(), 'resetwal-'));
await expect(resetWal(dir)).rejects.toThrow(WalResetUnsupportedError);
});
test('refuses wrong pg_control size', async () => {
const dir = makeLayout();
writeFileSync(join(dir, 'global', 'pg_control'), Buffer.alloc(100));
await expect(resetWal(dir)).rejects.toThrow(/pg_control size/);
});
test('refuses wrong pg_control version', async () => {
const dir = makeLayout({ version: 1600 });
await expect(resetWal(dir)).rejects.toThrow(/pg_control version/);
});
test('refuses non-power-of-two WAL segment size', async () => {
const dir = makeLayout({ segSize: 3 * 1024 * 1024 });
await expect(resetWal(dir)).rejects.toThrow(/segment size/);
});
test('refuses unsupported WAL block size', async () => {
const dir = makeLayout({ blcksz: 4096 });
await expect(resetWal(dir)).rejects.toThrow(/block size/);
});
test('refuses a pg_control whose stored CRC does not verify (F6: no laundering corrupt counters)', async () => {
const dir = makeLayout();
// A structurally-valid control (right size/version/seg/block) but with a
// damaged checkpoint copy and a STALE crc — real pg_resetwal refuses this.
const control = readFileSync(join(dir, 'global', 'pg_control'));
control.writeBigUInt64LE(0xdeadbeefn, 56); // trash a checkpointCopy field
// leave the old CRC in place → mismatch
writeFileSync(join(dir, 'global', 'pg_control'), control);
await expect(resetWal(dir)).rejects.toThrow(/CRC mismatch/);
});
});
describe('resetWal — byte surgery on a synthetic PG17 layout', () => {
test('resets WAL: pid removed, old segments gone, fresh checkpoint segment + CRC-valid control', async () => {
const oldSegs = [xlogFileName(1, 3n, SEG_SIZE), xlogFileName(1, 4n, SEG_SIZE)];
const dir = makeLayout({ redoSegNo: 3n, segments: oldSegs });
writeFileSync(join(dir, 'postmaster.pid'), '12345\n');
writeFileSync(join(dir, 'pg_wal', 'archive_status', `${oldSegs[0]}.ready`), '');
mkdirSync(join(dir, 'pg_wal', 'summaries'), { recursive: true });
writeFileSync(join(dir, 'pg_wal', 'summaries', `${'0'.repeat(40)}.summary`), '');
const result = await resetWal(dir);
// Stale run state + old WAL removed.
expect(existsSync(join(dir, 'postmaster.pid'))).toBe(false);
for (const seg of oldSegs) {
expect(existsSync(join(dir, 'pg_wal', seg))).toBe(false);
}
expect(readdirSync(join(dir, 'pg_wal', 'archive_status'))).toEqual([]);
expect(readdirSync(join(dir, 'pg_wal', 'summaries'))).toEqual([]);
// newSegNo = max(redo=3, existing max=4) + 1 = 5.
expect(result.resetSegment).toBe(xlogFileName(1, 5n, SEG_SIZE));
expect(result.timelineId).toBe(1);
expect(result.walSegSize).toBe(SEG_SIZE);
const segPath = join(dir, 'pg_wal', result.resetSegment);
expect(existsSync(segPath)).toBe(true);
expect(statSync(segPath).size).toBe(SEG_SIZE);
const wal = readFileSync(segPath);
expect(wal.readUInt16LE(0)).toBe(0xd116); // XLOG_PAGE_MAGIC
expect(wal.readUInt16LE(2) & 0x0002).toBe(0x0002); // XLP_LONG_HEADER
// Control: shutdown state + self-consistent CRC32C over bytes 0..288.
const control = readFileSync(join(dir, 'global', 'pg_control'));
expect(control.length).toBe(8192);
expect(control.readInt32LE(OFF.state)).toBe(1); // DB_SHUTDOWNED
expect(control.readUInt32LE(OFF.crc)).toBe(crc32c([control.subarray(0, OFF.crc)]));
// checkPoint points into the new segment.
const checkPoint = control.readBigUInt64LE(OFF.checkPoint);
expect(checkPoint / BigInt(SEG_SIZE)).toBe(5n);
// No torn tmp files left behind (atomic-write hygiene).
expect(readdirSync(join(dir, 'pg_wal')).filter((f) => f.includes('.tmp-'))).toEqual([]);
expect(readdirSync(join(dir, 'global')).filter((f) => f.includes('.tmp-'))).toEqual([]);
});
test('works on an EMPTY pg_wal (the whole-dir-rename backup path) and numbers off pg_control alone', async () => {
const dir = makeLayout({ redoSegNo: 7n, segments: [] });
const result = await resetWal(dir);
// No existing segments — newSegNo = redo(7) + 1.
expect(result.resetSegment).toBe(xlogFileName(1, 8n, SEG_SIZE));
expect(existsSync(join(dir, 'pg_wal', 'archive_status'))).toBe(true);
});
test('is idempotent: a second run converges (numbering keeps moving forward)', async () => {
const dir = makeLayout({ redoSegNo: 3n });
const first = await resetWal(dir);
const second = await resetWal(dir);
const firstNo = parseWalSegNo(first.resetSegment, SEG_SIZE)!;
const secondNo = parseWalSegNo(second.resetSegment, SEG_SIZE)!;
expect(secondNo).toBeGreaterThan(firstNo);
// Exactly one segment remains after each run.
const segs = readdirSync(join(dir, 'pg_wal')).filter((f) => /^[0-9A-F]{24}$/.test(f));
expect(segs).toEqual([second.resetSegment]);
});
});
describe('WAL segment name helpers', () => {
test('parseWalSegNo / xlogFileName round-trip', () => {
for (const segNo of [0n, 1n, 255n, 4096n, 0x1_0000_0000n / BigInt(SEG_SIZE) + 7n]) {
const name = xlogFileName(1, segNo, SEG_SIZE);
expect(name).toMatch(/^[0-9A-F]{24}$/);
expect(parseWalSegNo(name, SEG_SIZE)).toBe(segNo);
}
});
test('parseWalSegNo rejects non-segment names', () => {
expect(parseWalSegNo('archive_status', SEG_SIZE)).toBeNull();
expect(parseWalSegNo('000000010000000000000001.partial', SEG_SIZE)).toBeNull();
expect(parseWalSegNo('lowercase0000000000000001', SEG_SIZE)).toBeNull();
});
test('crc32c matches a known vector', () => {
// CRC-32C of ASCII "123456789" is 0xE3069283 (Castagnoli test vector).
expect(crc32c([Buffer.from('123456789')])).toBe(0xe3069283);
});
});