fix: clarify PGLite data-dir lock contention (#2658)

This commit is contained in:
zay
2026-07-23 11:03:03 -07:00
committed by GitHub
parent 220af4b2d0
commit 0556dbdc2c
2 changed files with 53 additions and 18 deletions
+31 -16
View File
@@ -108,6 +108,35 @@ function isProcessAlive(pid: number): boolean {
}
}
function formatLockTimestamp(value: unknown): string {
return typeof value === 'number' && Number.isFinite(value)
? new Date(value).toISOString()
: 'unknown time';
}
function pgliteLockTimeoutError(lockDir: string): Error {
const lockPath = join(lockDir, LOCK_FILE);
try {
const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'));
const pid = String(lockData.pid ?? 'unknown');
const command = String(lockData.command ?? 'unknown');
const serveHint = command.includes('gbrain serve')
? ' The holder looks like `gbrain serve`, so this is probably serve↔sync contention from an MCP/HTTP server; stop that server/client and rerun the command.'
: '';
return new Error(
`GBrain: Timed out waiting for PGLite data-dir lock. Process ${pid} has held it since ${formatLockTimestamp(lockData.acquired_at)} (command: ${command}). ` +
`Lock directory: ${lockDir}. If that process is dead, remove the lock directory and try again. ` +
`This is a PGLite data-dir lock, not the \`gbrain-sync:*\` advisory lock; \`gbrain sync --break-lock\` will not clear a live PGLite holder.` +
serveHint,
);
} catch {
return new Error(
`GBrain: Timed out waiting for PGLite lock. Remove ${lockDir} and try again.`
);
}
}
/**
* Attempt to acquire an exclusive lock on the PGLite data directory.
* Returns { acquired: true } if the lock was obtained, { acquired: false } otherwise.
@@ -177,28 +206,14 @@ export async function acquireLock(dataDir: string | undefined, opts?: { timeoutM
// mkdir failed — someone else grabbed it between our check and mkdir
// This is fine, we'll retry
if (Date.now() - startTime >= timeoutMs) {
// Timeout — report which process holds the lock
const lockPath = join(lockDir, LOCK_FILE);
try {
const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'));
throw new Error(
`GBrain: Timed out waiting for PGLite lock. Process ${lockData.pid} has held it since ${new Date(lockData.acquired_at).toISOString()} (command: ${lockData.command}). ` +
`If that process is dead, remove ${lockDir} and try again.`
);
} catch (readErr) {
if (readErr instanceof Error && readErr.message.startsWith('GBrain')) throw readErr;
throw new Error(
`GBrain: Timed out waiting for PGLite lock. Remove ${lockDir} and try again.`
);
}
throw pgliteLockTimeoutError(lockDir);
}
// Brief wait before retry
await new Promise(r => setTimeout(r, 500));
}
}
// Should not reach here, but just in case
throw new Error(`GBrain: Timed out waiting for PGLite lock.`);
throw pgliteLockTimeoutError(lockDir);
}
/**
+22 -2
View File
@@ -109,7 +109,7 @@ describe('pglite-lock #2058 heartbeat + steal-grace', () => {
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
});
function writeHolder(fields: { pid: number; acquiredAgoMs: number; refreshedAgoMs: number }) {
function writeHolder(fields: { pid: number; acquiredAgoMs: number; refreshedAgoMs: number; command?: string }) {
const lockDir = join(TEST_DIR, '.gbrain-lock');
mkdirSync(lockDir, { recursive: true });
const now = Date.now();
@@ -117,7 +117,7 @@ describe('pglite-lock #2058 heartbeat + steal-grace', () => {
pid: fields.pid,
acquired_at: now - fields.acquiredAgoMs,
refreshed_at: now - fields.refreshedAgoMs,
command: 'test holder',
command: fields.command ?? 'test holder',
}));
}
@@ -146,6 +146,26 @@ describe('pglite-lock #2058 heartbeat + steal-grace', () => {
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