From ffc545396cfa2120ba4aa50f888fc2b1caffdfdd Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Tue, 18 Aug 2026 09:56:06 -0700 Subject: [PATCH 1/3] fix close race with in-flight queries --- .changeset/quiet-databases-close.md | 5 + packages/pglite/src/pglite.ts | 121 ++++++++++-------- .../tests/targets/runtimes/node-close.test.js | 63 +++++++++ 3 files changed, 139 insertions(+), 50 deletions(-) create mode 100644 .changeset/quiet-databases-close.md create mode 100644 packages/pglite/tests/targets/runtimes/node-close.test.js diff --git a/.changeset/quiet-databases-close.md b/.changeset/quiet-databases-close.md new file mode 100644 index 000000000..d44c99adc --- /dev/null +++ b/.changeset/quiet-databases-close.md @@ -0,0 +1,5 @@ +--- +'@electric-sql/pglite': patch +--- + +Wait for in-flight queries and transactions to finish before closing PGlite. diff --git a/packages/pglite/src/pglite.ts b/packages/pglite/src/pglite.ts index 517a4376a..9be31dab8 100644 --- a/packages/pglite/src/pglite.ts +++ b/packages/pglite/src/pglite.ts @@ -89,6 +89,7 @@ export class PGlite #ready = false #closing = false #closed = false + #closePromise?: Promise #relaxedDurability = false readonly waitReady: Promise @@ -784,62 +785,82 @@ export class PGlite * Close the database * @returns A promise that resolves when the database is closed */ - async close() { - await this._checkReady() - this.#closing = true - - // Close all extensions - for (const closeFn of this.#extensionsClose) { - await closeFn() + close() { + if (!this.#closePromise) { + // Claim the lifecycle transition synchronously so operations started in + // the same tick cannot pass _checkReady() and queue behind close(). + this.#closing = true + this.#closePromise = this.#close() } + return this.#closePromise + } - // Close the database - try { - this.mod!._pgl_setPGliteActive(0) - await this.execProtocol(serialize.end()) - this.mod!._pgl_run_atexit_funcs() - } catch (e: any) { - const err = e as { name: string; status: number } - if (err.name === 'ExitStatus' && err.status === 0) { - // Database closed successfully - // An earlier build of PGlite would throw an error here when closing - // leaving this here for now. I believe it was a bug in Emscripten. - } else { - this.#log(`An error occured while closing the db`, e.toString()) - } - } finally { - this.mod!.removeFunction(this.#pglite_socket_read) - this.mod!.removeFunction(this.#pglite_socket_write) + async #close() { + if (!this.#ready) { + await this.waitReady } - // Close the filesystem - await this.fs!.closeFs() + // Let operations that passed _checkReady() before close() was called + // enqueue on the mutexes. Operations started after close() are rejected + // synchronously by the #closing flag set above. + await Promise.resolve() - this.#closed = true - this.#closing = false - this.#ready = false - this.#running = false + await this._runExclusiveTransaction(() => + this._runExclusiveQuery(async () => { + // Close all extensions + for (const closeFn of this.#extensionsClose) { + await closeFn() + } - const exitCode = pglUtils.pgliteProc.exitCode - try { - // exit the runtime. since we're using `noExitRuntime: true` on our module, - // we need to do this explicitly - // this sets process.exitCode to 0 - this.mod!._emscripten_force_exit(0) - // clear mod to release memory - this.mod = undefined - } catch (e: any) { - this.#log(e) - if (e.status !== 0) { - this.#log('Error when exiting', e.toString()) - } - } finally { - try { - pglUtils.pgliteProc.exitCode = exitCode - } catch { - // some envs do not allow setting the exitCode, swallow - } - } + // Close the database + try { + this.mod!._pgl_setPGliteActive(0) + await this.execProtocol(serialize.end()) + this.mod!._pgl_run_atexit_funcs() + } catch (e: any) { + const err = e as { name: string; status: number } + if (err.name === 'ExitStatus' && err.status === 0) { + // Database closed successfully + // An earlier build of PGlite would throw an error here when closing + // leaving this here for now. I believe it was a bug in Emscripten. + } else { + this.#log(`An error occured while closing the db`, e.toString()) + } + } finally { + this.mod!.removeFunction(this.#pglite_socket_read) + this.mod!.removeFunction(this.#pglite_socket_write) + } + + // Close the filesystem + await this.fs!.closeFs() + + this.#closed = true + this.#closing = false + this.#ready = false + this.#running = false + + const exitCode = pglUtils.pgliteProc.exitCode + try { + // exit the runtime. since we're using `noExitRuntime: true` on our module, + // we need to do this explicitly + // this sets process.exitCode to 0 + this.mod!._emscripten_force_exit(0) + // clear mod to release memory + this.mod = undefined + } catch (e: any) { + this.#log(e) + if (e.status !== 0) { + this.#log('Error when exiting', e.toString()) + } + } finally { + try { + pglUtils.pgliteProc.exitCode = exitCode + } catch { + // some envs do not allow setting the exitCode, swallow + } + } + }), + ) } /** diff --git a/packages/pglite/tests/targets/runtimes/node-close.test.js b/packages/pglite/tests/targets/runtimes/node-close.test.js new file mode 100644 index 000000000..cb961c072 --- /dev/null +++ b/packages/pglite/tests/targets/runtimes/node-close.test.js @@ -0,0 +1,63 @@ +import { spawn } from 'node:child_process' +import { describe, expect, it } from 'vitest' + +const pgliteUrl = new URL('../../../dist/index.js', import.meta.url).href + +describe('close', () => { + it('waits for an in-flight query before shutting down', async () => { + const script = ` + const { PGlite } = await import(process.argv[1]) + const db = new PGlite() + + await db.exec('CREATE TABLE t (workflow_name TEXT, run_id TEXT)') + await db.exec("INSERT INTO t VALUES ('agentic-loop', 'run-1')") + + const query = db.query( + 'DELETE FROM t WHERE workflow_name = $1 AND run_id = $2', + ['agentic-loop', 'run-1'], + ) + const firstClose = db.close() + const secondClose = db.close() + + await Promise.all([query, firstClose, secondClose]) + + const db2 = new PGlite() + await db2.waitReady + const close = db2.close() + const rejectedQuery = db2.query('SELECT 1').then( + () => false, + (error) => error.message === 'PGlite is closing', + ) + + if (!(await rejectedQuery)) { + throw new Error('query started after close was not rejected') + } + await close + ` + + const result = await new Promise((resolve) => { + const child = spawn( + process.execPath, + ['--input-type=module', '--eval', script, pgliteUrl], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ) + let stderr = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + + const timeout = setTimeout(() => { + child.kill('SIGKILL') + resolve({ code: null, stderr: 'PGlite close timed out' }) + }, 5_000) + + child.on('exit', (code) => { + clearTimeout(timeout) + resolve({ code, stderr }) + }) + }) + + expect(result).toEqual({ code: 0, stderr: '' }) + }, 10_000) +}) From dfc63b243764e81d7bf02fb5eba96e01c37fcd01 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Tue, 18 Aug 2026 10:22:27 -0700 Subject: [PATCH 2/3] clarify close behavior in changeset --- .changeset/quiet-databases-close.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/quiet-databases-close.md b/.changeset/quiet-databases-close.md index d44c99adc..69a357589 100644 --- a/.changeset/quiet-databases-close.md +++ b/.changeset/quiet-databases-close.md @@ -2,4 +2,6 @@ '@electric-sql/pglite': patch --- -Wait for in-flight queries and transactions to finish before closing PGlite. +Prevent `close()` from hanging when called while a query or transaction is in +flight. Work started before `close()` is allowed to finish, while later +operations are rejected. From 0401180a95640e92c4899a7069cd5aab8545e876 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Tue, 18 Aug 2026 10:37:59 -0700 Subject: [PATCH 3/3] strengthen close lifecycle coverage --- packages/pglite/src/pglite.ts | 116 +++++++++-------- .../tests/targets/runtimes/node-close.test.js | 121 ++++++++++++------ 2 files changed, 146 insertions(+), 91 deletions(-) diff --git a/packages/pglite/src/pglite.ts b/packages/pglite/src/pglite.ts index 9be31dab8..b105a1c9e 100644 --- a/packages/pglite/src/pglite.ts +++ b/packages/pglite/src/pglite.ts @@ -787,9 +787,6 @@ export class PGlite */ close() { if (!this.#closePromise) { - // Claim the lifecycle transition synchronously so operations started in - // the same tick cannot pass _checkReady() and queue behind close(). - this.#closing = true this.#closePromise = this.#close() } return this.#closePromise @@ -799,6 +796,9 @@ export class PGlite if (!this.#ready) { await this.waitReady } + // Claim the lifecycle transition synchronously once initialization is + // complete so later operations cannot queue behind close(). + this.#closing = true // Let operations that passed _checkReady() before close() was called // enqueue on the mutexes. Operations started after close() are rejected @@ -806,61 +806,63 @@ export class PGlite await Promise.resolve() await this._runExclusiveTransaction(() => - this._runExclusiveQuery(async () => { - // Close all extensions - for (const closeFn of this.#extensionsClose) { - await closeFn() - } + this._runExclusiveQuery(() => this.#closeExclusive()), + ) + } - // Close the database - try { - this.mod!._pgl_setPGliteActive(0) - await this.execProtocol(serialize.end()) - this.mod!._pgl_run_atexit_funcs() - } catch (e: any) { - const err = e as { name: string; status: number } - if (err.name === 'ExitStatus' && err.status === 0) { - // Database closed successfully - // An earlier build of PGlite would throw an error here when closing - // leaving this here for now. I believe it was a bug in Emscripten. - } else { - this.#log(`An error occured while closing the db`, e.toString()) - } - } finally { - this.mod!.removeFunction(this.#pglite_socket_read) - this.mod!.removeFunction(this.#pglite_socket_write) - } + async #closeExclusive() { + // Close all extensions + for (const closeFn of this.#extensionsClose) { + await closeFn() + } - // Close the filesystem - await this.fs!.closeFs() + // Close the database + try { + this.mod!._pgl_setPGliteActive(0) + await this.execProtocol(serialize.end()) + this.mod!._pgl_run_atexit_funcs() + } catch (e: any) { + const err = e as { name: string; status: number } + if (err.name === 'ExitStatus' && err.status === 0) { + // Database closed successfully + // An earlier build of PGlite would throw an error here when closing + // leaving this here for now. I believe it was a bug in Emscripten. + } else { + this.#log(`An error occured while closing the db`, e.toString()) + } + } finally { + this.mod!.removeFunction(this.#pglite_socket_read) + this.mod!.removeFunction(this.#pglite_socket_write) + } - this.#closed = true - this.#closing = false - this.#ready = false - this.#running = false + // Close the filesystem + await this.fs!.closeFs() - const exitCode = pglUtils.pgliteProc.exitCode - try { - // exit the runtime. since we're using `noExitRuntime: true` on our module, - // we need to do this explicitly - // this sets process.exitCode to 0 - this.mod!._emscripten_force_exit(0) - // clear mod to release memory - this.mod = undefined - } catch (e: any) { - this.#log(e) - if (e.status !== 0) { - this.#log('Error when exiting', e.toString()) - } - } finally { - try { - pglUtils.pgliteProc.exitCode = exitCode - } catch { - // some envs do not allow setting the exitCode, swallow - } - } - }), - ) + this.#closed = true + this.#closing = false + this.#ready = false + this.#running = false + + const exitCode = pglUtils.pgliteProc.exitCode + try { + // exit the runtime. since we're using `noExitRuntime: true` on our module, + // we need to do this explicitly + // this sets process.exitCode to 0 + this.mod!._emscripten_force_exit(0) + // clear mod to release memory + this.mod = undefined + } catch (e: any) { + this.#log(e) + if (e.status !== 0) { + this.#log('Error when exiting', e.toString()) + } + } finally { + try { + pglUtils.pgliteProc.exitCode = exitCode + } catch { + // some envs do not allow setting the exitCode, swallow + } + } } /** @@ -914,6 +916,12 @@ export class PGlite // Starting the database can take a while and it might not be ready yet // We'll wait for it to be ready before continuing await this.waitReady + if (this.#closing) { + throw new Error('PGlite is closing') + } + if (this.#closed) { + throw new Error('PGlite is closed') + } } } diff --git a/packages/pglite/tests/targets/runtimes/node-close.test.js b/packages/pglite/tests/targets/runtimes/node-close.test.js index cb961c072..10b567127 100644 --- a/packages/pglite/tests/targets/runtimes/node-close.test.js +++ b/packages/pglite/tests/targets/runtimes/node-close.test.js @@ -3,9 +3,48 @@ import { describe, expect, it } from 'vitest' const pgliteUrl = new URL('../../../dist/index.js', import.meta.url).href +async function expectChildToExitCleanly(script) { + const result = await new Promise((resolve) => { + const child = spawn( + process.execPath, + ['--input-type=module', '--eval', script, pgliteUrl], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ) + let stderr = '' + let settled = false + + const finish = (result) => { + if (settled) return + settled = true + clearTimeout(timeout) + resolve(result) + } + + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk) => { + stderr += chunk + stderr = stderr.slice(-4_000) + }) + + const timeout = setTimeout(() => { + child.kill('SIGKILL') + finish({ code: null, stderr: 'PGlite close timed out' }) + }, 5_000) + + child.on('error', (error) => { + finish({ code: null, stderr: error.message }) + }) + child.on('exit', (code) => { + finish({ code, stderr }) + }) + }) + + expect(result).toEqual({ code: 0, stderr: '' }) +} + describe('close', () => { it('waits for an in-flight query before shutting down', async () => { - const script = ` + await expectChildToExitCleanly(` const { PGlite } = await import(process.argv[1]) const db = new PGlite() @@ -16,48 +55,56 @@ describe('close', () => { 'DELETE FROM t WHERE workflow_name = $1 AND run_id = $2', ['agentic-loop', 'run-1'], ) - const firstClose = db.close() - const secondClose = db.close() + const close = db.close() - await Promise.all([query, firstClose, secondClose]) + await Promise.all([query, close]) + `) + }, 10_000) - const db2 = new PGlite() - await db2.waitReady - const close = db2.close() - const rejectedQuery = db2.query('SELECT 1').then( - () => false, - (error) => error.message === 'PGlite is closing', - ) + it('waits for an active transaction before shutting down', async () => { + await expectChildToExitCleanly(` + const { PGlite } = await import(process.argv[1]) + const db = new PGlite() - if (!(await rejectedQuery)) { - throw new Error('query started after close was not rejected') - } - await close - ` - - const result = await new Promise((resolve) => { - const child = spawn( - process.execPath, - ['--input-type=module', '--eval', script, pgliteUrl], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ) - let stderr = '' - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk) => { - stderr += chunk - }) + await db.exec('CREATE TABLE t (value INTEGER)') - const timeout = setTimeout(() => { - child.kill('SIGKILL') - resolve({ code: null, stderr: 'PGlite close timed out' }) - }, 5_000) + let markTransactionStarted + const transactionStarted = new Promise((resolve) => { + markTransactionStarted = resolve + }) + let resumeTransaction + const transactionGate = new Promise((resolve) => { + resumeTransaction = resolve + }) + const events = [] - child.on('exit', (code) => { - clearTimeout(timeout) - resolve({ code, stderr }) + const transaction = db.transaction(async (tx) => { + markTransactionStarted() + await transactionGate + await tx.query('INSERT INTO t VALUES (1)') + events.push('transaction') }) - }) - expect(result).toEqual({ code: 0, stderr: '' }) + await transactionStarted + const close = db.close().then(() => events.push('close')) + resumeTransaction() + await Promise.all([transaction, close]) + + if (events.join(',') !== 'transaction,close') { + throw new Error('close did not wait for the active transaction') + } + `) + }, 10_000) + + it('closes once during initialization and rejects later queries', async () => { + const { PGlite } = await import(pgliteUrl) + const db = new PGlite() + + const firstClose = db.close() + expect(db.close()).toBe(firstClose) + await expect(db.query('SELECT 1')).rejects.toThrow('PGlite is closing') + + await firstClose + expect(db.closed).toBe(true) }, 10_000) })