mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +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>
369 lines
14 KiB
TypeScript
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);
|
|
});
|