Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-nodefs-lock-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@electric-sql/pglite': patch
---

Add data directory locking to NodeFS to prevent multi-process corruption
65 changes: 65 additions & 0 deletions packages/pglite/src/fs/nodefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -17,6 +20,9 @@ export class NodeFS extends EmscriptenBuiltinFilesystem {

async init(pg: PGlite, opts: Partial<PostgresMod>) {
this.pg = pg

this.#acquireLock()

const options: Partial<PostgresMod> = {
...opts,
preRun: [
Expand All @@ -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<void> {
this.#releaseLock()
this.pg!.Module.FS.quit()
}
}
71 changes: 71 additions & 0 deletions packages/pglite/tests/nodefs-lock.test.js
Original file line number Diff line number Diff line change
@@ -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)
})