From f5ce53c493d4d0e47a89487009e032ec7f0c1208 Mon Sep 17 00:00:00 2001 From: zay Date: Thu, 23 Jul 2026 14:03:03 -0400 Subject: [PATCH] fix: clarify PGLite data-dir lock contention (#2658) --- src/core/pglite-lock.ts | 47 ++++++++++++++++++++++++++-------------- test/pglite-lock.test.ts | 20 +++++++++++++++++ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/core/pglite-lock.ts b/src/core/pglite-lock.ts index 74c91aa45..188e5c666 100644 --- a/src/core/pglite-lock.ts +++ b/src/core/pglite-lock.ts @@ -124,6 +124,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. @@ -206,28 +235,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); } /** diff --git a/test/pglite-lock.test.ts b/test/pglite-lock.test.ts index 2850a0a94..0f8c545a1 100644 --- a/test/pglite-lock.test.ts +++ b/test/pglite-lock.test.ts @@ -212,6 +212,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