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

369 lines
14 KiB
TypeScript

import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { acquireLock, releaseLock, type LockHandle } from '../src/core/pglite-lock';
const TEST_DIR = join(tmpdir(), 'gbrain-lock-test-' + process.pid);
describe('pglite-lock', () => {
beforeEach(() => {
// Clean up test directory
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
mkdirSync(TEST_DIR, { recursive: true });
});
afterEach(() => {
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
});
test('acquires and releases lock', async () => {
const lock = await acquireLock(TEST_DIR);
expect(lock.acquired).toBe(true);
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
await releaseLock(lock);
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(false);
});
test('creates missing data directory before acquiring lock', async () => {
const missingDataDir = join(TEST_DIR, 'missing-data-dir');
const lock = await acquireLock(missingDataDir);
expect(lock.acquired).toBe(true);
expect(existsSync(missingDataDir)).toBe(true);
expect(existsSync(join(missingDataDir, '.gbrain-lock'))).toBe(true);
await releaseLock(lock);
expect(existsSync(join(missingDataDir, '.gbrain-lock'))).toBe(false);
});
test('prevents concurrent lock acquisition', async () => {
const lock1 = await acquireLock(TEST_DIR, { timeoutMs: 2000 });
expect(lock1.acquired).toBe(true);
// Second lock attempt should timeout
await expect(acquireLock(TEST_DIR, { timeoutMs: 1000 })).rejects.toThrow(/Timed out/);
await releaseLock(lock1);
});
test('detects and cleans stale lock from dead process', async () => {
// Simulate a stale lock from a dead process
const lockDir = join(TEST_DIR, '.gbrain-lock');
mkdirSync(lockDir);
writeFileSync(join(lockDir, 'lock'), JSON.stringify({
pid: 999999999, // Non-existent PID
acquired_at: Date.now(),
command: 'test',
}));
// Should clean up the stale lock and acquire
const lock = await acquireLock(TEST_DIR);
expect(lock.acquired).toBe(true);
await releaseLock(lock);
});
test('skips lock for in-memory (undefined dataDir)', async () => {
const lock = await acquireLock(undefined);
expect(lock.acquired).toBe(true);
expect(lock.lockDir).toBe('');
// Release should be a no-op
await releaseLock(lock);
});
test('lock file contains PID and command', async () => {
const lock = await acquireLock(TEST_DIR);
const lockData = JSON.parse(readFileSync(join(TEST_DIR, '.gbrain-lock', 'lock'), 'utf-8'));
expect(lockData.pid).toBe(process.pid);
expect(lockData.acquired_at).toBeDefined();
expect(lockData.command).toBeDefined();
await releaseLock(lock);
});
test('releases lock on disconnect even if DB close fails', async () => {
const lock = await acquireLock(TEST_DIR);
expect(lock.acquired).toBe(true);
// Simulate DB already closed
await releaseLock(lock);
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(false);
// Second acquisition should work
const lock2 = await acquireLock(TEST_DIR);
expect(lock2.acquired).toBe(true);
await releaseLock(lock2);
});
});
describe('pglite-lock #2058 heartbeat + steal-grace', () => {
beforeEach(() => {
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
mkdirSync(TEST_DIR, { recursive: true });
});
afterEach(() => {
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
});
function writeHolder(fields: {
pid: number;
acquiredAgoMs: number;
refreshedAgoMs: number;
command?: string;
subcommand?: string;
}) {
const lockDir = join(TEST_DIR, '.gbrain-lock');
mkdirSync(lockDir, { recursive: true });
const now = Date.now();
writeFileSync(join(lockDir, 'lock'), JSON.stringify({
pid: fields.pid,
acquired_at: now - fields.acquiredAgoMs,
refreshed_at: now - fields.refreshedAgoMs,
command: fields.command ?? 'test holder',
...(fields.subcommand === undefined ? {} : { subcommand: fields.subcommand }),
}));
}
test('a live gbrain serve owner with global flags fails fast with a clear explanation', async () => {
writeHolder({
pid: process.pid,
acquiredAgoMs: 60_000,
refreshedAgoMs: 0,
command: '/path with spaces/gbrain/src/cli.ts --quiet serve',
subcommand: 'serve',
});
const startedAt = Date.now();
await expect(acquireLock(TEST_DIR, { timeoutMs: 5_000 })).rejects.toThrow(
/already open through `gbrain serve`.*Stop `gbrain serve`, then retry this CLI command.*use its MCP tools instead.*will not remove/s,
);
expect(Date.now() - startedAt).toBeLessThan(1_000);
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
});
test('legacy serve lock metadata is still recognized', async () => {
writeHolder({
pid: process.pid,
acquiredAgoMs: 60_000,
refreshedAgoMs: 0,
command: '/path/to/gbrain/src/cli.ts serve',
});
await expect(acquireLock(TEST_DIR, { timeoutMs: 5_000 })).rejects.toThrow(
/already open through `gbrain serve`/,
);
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
});
test('a search for the word serve is not mistaken for the MCP server', async () => {
writeHolder({
pid: process.pid,
acquiredAgoMs: 60_000,
refreshedAgoMs: 0,
command: '/compiled/gbrain search serve',
subcommand: 'search',
});
await expect(acquireLock(TEST_DIR, { timeoutMs: 100 })).rejects.toThrow(/Timed out/);
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
});
test('a dead gbrain serve owner is still cleaned up automatically', async () => {
writeHolder({
pid: 999999999,
acquiredAgoMs: 60_000,
refreshedAgoMs: 0,
command: '/path/to/gbrain/src/cli.ts serve',
subcommand: 'serve',
});
const lock = await acquireLock(TEST_DIR, { timeoutMs: 2_000 });
expect(lock.acquired).toBe(true);
await releaseLock(lock);
});
test('[REGRESSION] a LIVE holder with a fresh heartbeat is NOT stolen even when the lock is old', async () => {
// The WAL-corruption bug: a >5min embed used to get its lock force-removed.
// Now an alive holder that heartbeated recently is left alone regardless of
// age. acquired 20min ago, but refreshed just now → must wait, not steal.
writeHolder({ pid: process.pid, acquiredAgoMs: 20 * 60_000, refreshedAgoMs: 0 });
await expect(acquireLock(TEST_DIR, { timeoutMs: 1200 })).rejects.toThrow(/Timed out/);
// Holder's lock still present (was never stolen).
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
});
test('[REGRESSION #2348] a LIVE PID with a STALE heartbeat is NOT stolen', async () => {
// The #2348 corruption: a live `gbrain dream`/embed holder whose heartbeat
// lapsed (the JS event loop is blocked during a long synchronous WASM
// import) used to get its lock reaped past the grace window — letting a
// second OS process open the same data dir and corrupt the catalog +
// pgvector extension state. A live PID is now NEVER stolen, regardless of
// how stale its heartbeat is. Acquire must time out, not steal.
writeHolder({ pid: process.pid, acquiredAgoMs: 25 * 60_000, refreshedAgoMs: 20 * 60_000 });
await expect(acquireLock(TEST_DIR, { timeoutMs: 1200 })).rejects.toThrow(/Timed out/);
// The live holder's lock is still present — never force-removed.
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
});
test('explains live gbrain serve contention is not a sync advisory lock', async () => {
writeHolder({
pid: process.pid,
acquiredAgoMs: 60_000,
refreshedAgoMs: 0,
command: 'bun /Users/master/.bun/bin/gbrain serve',
});
let message = '';
try {
await acquireLock(TEST_DIR, { timeoutMs: 100 });
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toContain('serve↔sync contention');
expect(message).toContain('not the `gbrain-sync:*` advisory lock');
expect(message).toContain('`gbrain sync --break-lock` will not clear a live PGLite holder');
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
});
test('[REGRESSION] releaseLock does NOT remove a lock that was stolen + re-acquired by another process', async () => {
// We acquire, then simulate a steal: another process reaped us past grace
// and now owns the lock (different pid + acquired_at). Our releaseLock must
// NOT delete their live lock — doing so would let a third process in
// alongside the new owner (the #2058 corruption class).
const lock: LockHandle = await acquireLock(TEST_DIR);
expect(lock.acquired).toBe(true);
expect(lock.ownerToken).toBeDefined();
if (lock.heartbeat) clearInterval(lock.heartbeat); // stop our heartbeat for a deterministic test
// Overwrite the lock file as if process B re-acquired it.
const lockFile = join(TEST_DIR, '.gbrain-lock', 'lock');
const bNow = Date.now() + 1;
writeFileSync(lockFile, JSON.stringify({ pid: 999999, acquired_at: bNow, refreshed_at: bNow, command: 'process B' }));
await releaseLock(lock); // our (stale) handle
// B's lock survives — we did not clobber it.
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
const after = JSON.parse(readFileSync(lockFile, 'utf-8'));
expect(after.pid).toBe(999999);
// Cleanup for afterEach.
rmSync(join(TEST_DIR, '.gbrain-lock'), { recursive: true, force: true });
});
test('acquire starts a heartbeat and seeds refreshed_at; release clears it', async () => {
const lock: LockHandle = await acquireLock(TEST_DIR);
expect(lock.acquired).toBe(true);
expect(lock.heartbeat).toBeDefined();
const data = JSON.parse(readFileSync(join(TEST_DIR, '.gbrain-lock', 'lock'), 'utf-8'));
expect(data.refreshed_at).toBeDefined();
expect(typeof data.refreshed_at).toBe('number');
await releaseLock(lock);
expect(lock.heartbeat).toBeUndefined();
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(false);
});
});
describe('pglite-lock reap classification (WAL-repair wave)', () => {
// Unique per-test tmpdirs: the reap marker lands at `${dataDir}.lock-reap.json`
// — a SIBLING of the data dir — so each test gets its own parent to rm.
function freshDataDir(): { parent: string; dataDir: string } {
const parent = mkdtempSync(join(tmpdir(), 'gbrain-lock-reap-'));
return { parent, dataDir: join(parent, 'data') };
}
/**
* 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');
}
test('corrupt lock file: reaped acquisition + persisted .lock-reap.json marker', async () => {
const { parent, dataDir } = freshDataDir();
try {
const lockDir = join(dataDir, '.gbrain-lock');
mkdirSync(lockDir, { recursive: true });
writeFileSync(join(lockDir, 'lock'), 'not json {{{'); // holder liveness UNKNOWABLE
const lock = await acquireLock(dataDir, { timeoutMs: 5000 });
try {
expect(lock.acquired).toBe(true);
expect(lock.reaped).toBe(true);
// Unknowable-liveness reap is persisted cross-process for the repair gate.
expect(existsSync(`${dataDir}.lock-reap.json`)).toBe(true);
const marker = JSON.parse(readFileSync(`${dataDir}.lock-reap.json`, 'utf-8'));
expect(typeof marker.ts).toBe('number');
expect(marker.by).toBe(process.pid);
} finally {
await releaseLock(lock);
}
} finally {
rmSync(parent, { recursive: true, force: true });
}
});
test('clean acquisition: reaped falsy, no .lock-reap.json marker', async () => {
const { parent, dataDir } = freshDataDir();
try {
const lock = await acquireLock(dataDir, { timeoutMs: 5000 });
try {
expect(lock.acquired).toBe(true);
expect(lock.reaped).toBeFalsy();
expect(existsSync(`${dataDir}.lock-reap.json`)).toBe(false);
} finally {
await releaseLock(lock);
}
} finally {
rmSync(parent, { recursive: true, force: true });
}
});
test('dead-PID lock: reaped acquisition but NO marker (affirmative ESRCH verdict)', async () => {
const { parent, dataDir } = freshDataDir();
try {
const lockDir = join(dataDir, '.gbrain-lock');
mkdirSync(lockDir, { recursive: true });
const now = Date.now();
writeFileSync(join(lockDir, 'lock'), JSON.stringify({
pid: deadPid(),
acquired_at: now - 60_000,
refreshed_at: now - 60_000,
command: 'gbrain embed',
subcommand: 'embed',
}));
const lock = await acquireLock(dataDir, { timeoutMs: 5000 });
try {
expect(lock.acquired).toBe(true);
expect(lock.reaped).toBe(true);
// Dead-PID reaps deliberately do NOT quarantine the next acquirer.
expect(existsSync(`${dataDir}.lock-reap.json`)).toBe(false);
} finally {
await releaseLock(lock);
}
} finally {
rmSync(parent, { recursive: true, force: true });
}
}, 30_000);
});