Files
gbrain/test/page-lock.test.ts
T
Garry TanandClaude Fable 5 ee14640ae9 fix(page-lock): never fast-steal a fresh lock on a dead-looking PID (#2840)
process.kill(pid, 0) returns ESRCH for LIVE holders in another PID
namespace (containerized serve + jobs-work sharing GBRAIN_HOME), so the
PID-liveness fast-steal silently stole live locks and re-opened the
concurrent read-modify-write race the lock exists to prevent.

Staleness is now mtime-TTL only (holders already refresh(); crashed
holders age out in 5 min), matching the #2348 pglite-lock philosophy:
never steal a possibly-live holder. Ownership for refresh()/release()
moves from PID comparison (same cross-namespace false-self problem) to a
per-acquire random token, so a stale handle can never clobber or unlink
a new owner's lock.

Fixes #2840

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:57:44 -07:00

155 lines
6.0 KiB
TypeScript

import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync, utimesSync } from 'node:fs';
import { join, sep } from 'node:path';
import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
import { acquirePageLock, withPageLock } from '../src/core/page-lock.ts';
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'page-lock-test-'));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
function lockFile(slug: string) {
const sha = createHash('sha256').update(slug).digest('hex');
return join(tmp, `${sha}.lock`);
}
describe('acquirePageLock', () => {
test('acquires lock when none exists', async () => {
const lock = await acquirePageLock('people/alice', { lockRoot: tmp });
expect(lock).not.toBeNull();
expect(lock!.slug).toBe('people/alice');
expect(existsSync(lockFile('people/alice'))).toBe(true);
await lock!.release();
expect(existsSync(lockFile('people/alice'))).toBe(false);
});
test('returns null when a live holder exists (timeoutMs=0)', async () => {
const first = await acquirePageLock('companies/acme', { lockRoot: tmp });
expect(first).not.toBeNull();
const second = await acquirePageLock('companies/acme', { lockRoot: tmp });
expect(second).toBeNull();
await first!.release();
});
test('reclaims stale lock (mtime > 5 min)', async () => {
const slug = 'meetings/2026-04-29';
// Write a fake stale lock with a non-existent PID.
const path = lockFile(slug);
require('node:fs').mkdirSync(tmp, { recursive: true });
writeFileSync(path, `999999999\n2024-01-01T00:00:00Z\n`);
// Backdate mtime by 10 minutes.
const tenMinAgo = new Date(Date.now() - 10 * 60 * 1000);
utimesSync(path, tenMinAgo, tenMinAgo);
const lock = await acquirePageLock(slug, { lockRoot: tmp });
expect(lock).not.toBeNull();
// We replaced the stale content with our own pid + fresh timestamp.
const content = readFileSync(path, 'utf-8').trim();
expect(content.split('\n')[0]).toBe(String(process.pid));
await lock!.release();
});
test('#2840: does NOT steal a fresh lock whose PID looks dead (cross-PID-namespace holder)', async () => {
const slug = 'people/charlie';
const path = lockFile(slug);
require('node:fs').mkdirSync(tmp, { recursive: true });
// PID 999999999 reads as ESRCH here — exactly what a LIVE holder in
// another PID namespace (container sharing GBRAIN_HOME) looks like.
// Fresh mtime → must NOT be stolen; staleness is mtime-TTL only.
writeFileSync(path, `999999999\n${new Date().toISOString()}\n`);
const lock = await acquirePageLock(slug, { lockRoot: tmp });
expect(lock).toBeNull();
// Same lock past the TTL IS reclaimable (dead holders age out).
const tenMinAgo = new Date(Date.now() - 10 * 60 * 1000);
utimesSync(path, tenMinAgo, tenMinAgo);
const after = await acquirePageLock(slug, { lockRoot: tmp });
expect(after).not.toBeNull();
await after!.release();
});
test('refresh() updates timestamp', async () => {
const lock = await acquirePageLock('test/refresh', { lockRoot: tmp });
expect(lock).not.toBeNull();
const path = lockFile('test/refresh');
const t1 = readFileSync(path, 'utf-8');
await new Promise(r => setTimeout(r, 50));
await lock!.refresh();
const t2 = readFileSync(path, 'utf-8');
// Same pid, different timestamp.
expect(t1.split('\n')[0]).toBe(t2.split('\n')[0]);
expect(t1).not.toBe(t2);
await lock!.release();
});
test('release() does not delete a lock held by a different pid', async () => {
const slug = 'test/foreign-release';
const path = lockFile(slug);
require('node:fs').mkdirSync(tmp, { recursive: true });
writeFileSync(path, `999999999\n${new Date().toISOString()}\n`);
// Backdate past the TTL so the lock is reclaimable (staleness is mtime-only).
const tenMinAgo = new Date(Date.now() - 10 * 60 * 1000);
utimesSync(path, tenMinAgo, tenMinAgo);
// Acquire — this rewrites the lock with our pid + owner token.
const lock = await acquirePageLock(slug, { lockRoot: tmp });
expect(lock).not.toBeNull();
// Manually rewrite with a foreign owner (no matching token).
writeFileSync(path, `888888888\n${new Date().toISOString()}\n`);
// Release should be a no-op (different owner).
await lock!.release();
expect(existsSync(path)).toBe(true);
});
});
describe('withPageLock', () => {
test('runs the callback under the lock and releases on success', async () => {
let ran = false;
await withPageLock('synthesis/test', async () => {
ran = true;
expect(existsSync(lockFile('synthesis/test'))).toBe(true);
}, { lockRoot: tmp, timeoutMs: 5000 });
expect(ran).toBe(true);
expect(existsSync(lockFile('synthesis/test'))).toBe(false);
});
test('releases lock even when callback throws', async () => {
await expect(
withPageLock('synthesis/throws', async () => {
throw new Error('boom');
}, { lockRoot: tmp, timeoutMs: 5000 }),
).rejects.toThrow('boom');
expect(existsSync(lockFile('synthesis/throws'))).toBe(false);
});
test('throws when timeout elapses with a live holder', async () => {
const first = await acquirePageLock('held/page', { lockRoot: tmp });
expect(first).not.toBeNull();
await expect(
withPageLock('held/page', async () => 'unreachable', {
lockRoot: tmp,
timeoutMs: 200,
}),
).rejects.toThrow();
await first!.release();
});
});
describe('SHA-256 path safety', () => {
test('slugs with slashes/unicode produce safe filenames', async () => {
const slug = 'people/alíce-éxample/sub';
const lock = await acquirePageLock(slug, { lockRoot: tmp });
expect(lock).not.toBeNull();
const lockPath = lockFile(slug);
// Filename is a 64-char hex sha + '.lock', not the raw slug.
const filename = lockPath.split(sep).pop()!;
expect(filename).toMatch(/^[0-9a-f]{64}\.lock$/);
await lock!.release();
});
});