diff --git a/.changeset/add-nodefs-lock-file.md b/.changeset/add-nodefs-lock-file.md new file mode 100644 index 000000000..3f7d1a882 --- /dev/null +++ b/.changeset/add-nodefs-lock-file.md @@ -0,0 +1,5 @@ +--- +'@electric-sql/pglite': patch +--- + +Add data directory locking to NodeFS to prevent multi-process corruption diff --git a/packages/pglite/src/fs/nodefs.ts b/packages/pglite/src/fs/nodefs.ts index fb411aab5..1038c9035 100644 --- a/packages/pglite/src/fs/nodefs.ts +++ b/packages/pglite/src/fs/nodefs.ts @@ -4,8 +4,11 @@ import { EmscriptenBuiltinFilesystem, PGDATA } from './base.js' import type { PostgresMod } from '../postgresMod.js' import { PGlite } from '../pglite.js' +// TODO: Add locking for browser backends via Web Locks API + export class NodeFS extends EmscriptenBuiltinFilesystem { protected rootDir: string + #lockFd: number | null = null constructor(dataDir: string) { super(dataDir) @@ -17,6 +20,9 @@ export class NodeFS extends EmscriptenBuiltinFilesystem { async init(pg: PGlite, opts: Partial) { this.pg = pg + + this.#acquireLock() + const options: Partial = { ...opts, preRun: [ @@ -31,7 +37,66 @@ export class NodeFS extends EmscriptenBuiltinFilesystem { return { emscriptenOpts: options } } + // Lock file is a sibling (mydb.lock) to avoid polluting the PG data dir + #acquireLock() { + const lockPath = this.rootDir + '.lock' + + if (fs.existsSync(lockPath)) { + try { + const content = fs.readFileSync(lockPath, 'utf-8').trim() + const lines = content.split('\n') + const pid = parseInt(lines[0], 10) + + if (pid && !isNaN(pid) && this.#isProcessAlive(pid)) { + throw new Error( + `PGlite data directory "${this.rootDir}" is already in use by another instance (PID ${pid}). ` + + `Close the other instance or use a different data directory. ` + + `Delete "${lockPath}" if PID ${pid} is no longer running.`, + ) + } + // Stale lock from a dead process — safe to take over + } catch (e) { + // Re-throw lock errors, ignore parse errors (corrupt lock file = stale) + if (e instanceof Error && e.message.includes('already in use')) { + throw e + } + } + } + + // Write our PID to the lock file and keep the fd open + this.#lockFd = fs.openSync(lockPath, 'w') + fs.writeSync(this.#lockFd, `${process.pid}\n${Date.now()}\n`) + } + + #releaseLock() { + if (this.#lockFd !== null) { + try { + fs.closeSync(this.#lockFd) + } catch { + // Ignore errors on close + } + this.#lockFd = null + + const lockPath = this.rootDir + '.lock' + try { + fs.unlinkSync(lockPath) + } catch { + // Ignore errors on unlink (dir may already be cleaned up) + } + } + } + + #isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) // signal 0 = check if process exists + return true + } catch { + return false // ESRCH = process doesn't exist + } + } + async closeFs(): Promise { + this.#releaseLock() this.pg!.Module.FS.quit() } } diff --git a/packages/pglite/tests/nodefs-lock.test.js b/packages/pglite/tests/nodefs-lock.test.js new file mode 100644 index 000000000..95ab21472 --- /dev/null +++ b/packages/pglite/tests/nodefs-lock.test.js @@ -0,0 +1,71 @@ +import { describe, it, expect, afterAll } from 'vitest' +import { existsSync, writeFileSync, rmSync } from 'node:fs' + +const dataDir = `/tmp/pglite-lock-test-${Date.now()}` + +afterAll(async () => { + if (!process.env.RETAIN_DATA) { + for (const p of [dataDir, dataDir + '.lock']) { + if (existsSync(p)) rmSync(p, { recursive: true, force: true }) + } + } +}) + +describe('NodeFS data directory locking', () => { + it('should block a second instance from opening the same data directory', async () => { + const { PGlite } = await import('../dist/index.js') + + const db1 = new PGlite(dataDir) + await db1.waitReady + + // Lock file should exist while db1 is open + expect(existsSync(dataDir + '.lock')).toBe(true) + + // Second instance on same dir must throw + let lockError = null + try { + const db2 = new PGlite(dataDir) + await db2.waitReady + await db2.close() + } catch (err) { + lockError = err + } + + expect(lockError).not.toBeNull() + expect(lockError.message).toContain('already in use') + expect(lockError.message).toContain(String(process.pid)) + + // First instance should still work fine + const result = await db1.query('SELECT 1 as ok') + expect(result.rows[0].ok).toBe(1) + + await db1.close() + }, 30000) + + it('should allow reopening after the first instance is closed', async () => { + const { PGlite } = await import('../dist/index.js') + + // Lock file should be cleaned up after close + expect(existsSync(dataDir + '.lock')).toBe(false) + + const db = new PGlite(dataDir) + await db.waitReady + const result = await db.query('SELECT 1 as ok') + expect(result.rows[0].ok).toBe(1) + await db.close() + }, 30000) + + it('should override a stale lock from a dead process', async () => { + const { PGlite } = await import('../dist/index.js') + + // Write a fake lock file with a PID that doesn't exist + writeFileSync(dataDir + '.lock', '999999\n0\n') + + // Should succeed — stale lock gets overridden + const db = new PGlite(dataDir) + await db.waitReady + const result = await db.query('SELECT 1 as ok') + expect(result.rows[0].ok).toBe(1) + await db.close() + }, 30000) +})