`close()` does not wait for in-flight or queued statements; a statement that reaches the closed instance spins forever in `execProtocolRawSync` (100% CPU, event loop blocked)
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 56/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- postgresql, typescript, wasm
Research direction
Start in src/pglite.ts by reading close() and _runExclusiveQuery, then trace how root queries and transaction statements use the query lock. Reproduce the in-flight and queued cases from the supplied script, and inspect execProtocolRawSync() for the non-terminating loop. Done means closing waits for active work, queued or later statements reject cleanly, and none of the cases spin.
Written by the indexing model from the issue text.
Description
Summary
PGlite.close() is not serialized with the query lock. If a statement is in flight or queued when close() runs, that statement later runs against the closed module and never returns. This includes statements inside a transaction (tx.query / tx.exec), which never call _checkReady(). The statement loops synchronously inside execProtocolRawSync(). The process then holds one CPU core at 100%, and the event loop never runs again, so no JS timer, timeout or signal handler can recover it.
Environment
@electric-sql/pglite0.5.4,memory://, no extensions- Node.js 22.23.3 on Windows 11 Pro x64 (build 26200)
- Also reproduced with Node.js 24.14.0 on Ubuntu 24.04 (WSL2, x86_64)
- First seen as a hung CI test job on Linux arm64 with Node.js 22.23.3
Reproduction
The script below needs only @electric-sql/pglite. Each case runs on a fresh memory:// instance.
npm i @electric-sql/[email protected]
timeout 30 node pglite-close-race-repro.mjs inflight # or tx, queued, control
// PGlite close() race: a statement that reaches the module after close() spins
// forever in execProtocolRawSync, pinning one CPU core and blocking the event loop.
//
// npm i @electric-sql/[email protected]
// timeout 30 node pglite-close-race-repro.mjs [inflight|tx|queued|control]
//
// Expected: every case prints "done" and exits.
// Actual (0.5.4): "inflight", "tx" and "queued" never print "done", the heartbeat
// stops, and the process uses 100% of one core until it is killed.
import { writeSync } from 'node:fs'
import { PGlite } from '@electric-sql/pglite'
// Log synchronously: once the spin starts, the event loop never runs again.
const log = (msg) =>
writeSync(1, `${(performance.now() / 1000).toFixed(2)}s ${msg}\n`)
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const settle = (label, promise) =>
promise.then(
() => log(`${label} resolved`),
(error) => log(`${label} rejected: ${error.message}`),
)
const which = process.argv[2] ?? 'inflight'
const db = new PGlite('memory://')
await db.waitReady
log(`ready (case "${which}", pid ${process.pid})`)
const heartbeat = setInterval(() => log('heartbeat'), 250)
await sleep(600)
if (which === 'inflight') {
// close() while a db.query() is still running (not awaited yet).
const query = settle('query', db.query('select 1'))
log('close() while db.query() is in flight')
const closing = settle('close()', db.close())
await query
await closing
} else if (which === 'tx') {
// close() while a transaction callback is suspended between statements.
// tx.query() does not go through _checkReady().
let resume
const resumed = new Promise((resolve) => (resume = resolve))
const transaction = settle(
'transaction',
db.transaction(async (tx) => {
await tx.query('select 1')
await resumed
log('tx.query() after close()')
await tx.query('select 2')
}),
)
await sleep(50)
log('close() while a transaction is open')
await settle('close()', db.close())
resume()
await transaction
} else if (which === 'queued') {
// close() while a db.query() that already passed _checkReady() waits on the
// query lock (held here by runExclusive()).
let release
const holder = settle(
'runExclusive()',
db.runExclusive(() => new Promise((resolve) => (release = resolve))),
)
const queued = settle('queued query', db.query('select 2'))
await sleep(50)
log('close() while db.query() waits on the lock')
const closing = settle('close()', db.close())
await sleep(50)
log('releasing the lock')
release()
await holder
await queued
await closing
} else {
// Control: a statement issued after close() is rejected, as expected.
await settle('close()', db.close())
await settle('query after close()', db.query('select 1'))
}
clearInterval(heartbeat)
log('done')
Output on 0.5.4 (Windows, Node 22.23.3; Linux output is the same apart from timings):
$ timeout 12 node pglite-close-race-repro.mjs control
1.13s ready (case "control", pid 11348)
1.38s heartbeat
1.65s heartbeat
1.74s close() resolved
1.74s query after close() rejected: PGlite is closed
1.74s done # exit 0
$ timeout 12 node pglite-close-race-repro.mjs inflight
1.12s ready (case "inflight", pid 81980)
1.37s heartbeat
1.63s heartbeat
1.73s close() while db.query() is in flight
# no more output; killed by timeout, exit 124
$ timeout 12 node pglite-close-race-repro.mjs tx
1.04s ready (case "tx", pid 34664)
1.30s heartbeat
1.57s heartbeat
1.72s close() while a transaction is open
1.72s close() resolved
1.72s tx.query() after close()
# no more output; killed by timeout, exit 124
$ timeout 12 node pglite-close-race-repro.mjs queued
1.03s ready (case "queued", pid 18784)
1.30s heartbeat
1.56s heartbeat
1.70s close() while db.query() waits on the lock
1.70s close() resolved
1.76s releasing the lock
# no more output; killed by timeout, exit 124
In the three hanging cases the process is busy, not idle. It used 4.97 to 5.00 CPU-seconds per 5 s of wall time on Windows. On Linux, ps showed 8 s of CPU time after 8 s of elapsed time.
Expected behavior
- A statement that already holds the query lock when
close()is called either finishes beforeclose()tears the module down, or rejects. - A statement that is queued, or issued later through a still-open transaction object (including the implicit
COMMIT/ROLLBACK), rejects with an error such asPGlite is closed. A rootquery()issued afterclose()already behaves this way (thecontrolcase). close()resolves, and nothing spins.
Actual behavior
inflight: the query never settles,close()never resolves, and the process spins.tx:close()resolves, then the nexttx.query()never settles and the process spins.queued:close()resolves while adb.query()waits on the lock. When the lock is released, that query never settles and the process spins.
Cause (from reading the 0.5.4 source)
-
close()calls_checkReady()and sets#closing, but it takes neither#queryMutexnor#transactionMutex. It then callsremoveFunction()on the socket read and write callbacks, closes the filesystem, and calls_emscripten_force_exit(0). -
Statements can get past the closed check in three ways:
- Root
query(),exec()andtransaction()call_checkReady()only once, on entry, before they wait on the mutexes. A statement already waiting on a mutex has therefore passed the check. - One
query()is several awaited protocol round trips (parse, describe, bind, describe, execute, sync), soclose()can finish between two of them. In theinflightcase the spin starts at the Bind message. tx.query()andtx.exec(), and theCOMMIT/ROLLBACKthattransaction()issues, never call_checkReady(). They check only the transaction's ownclosedflag.
- Root
-
In
execProtocolRawSync(), the loop is:while (this.#readOffset < message.length || mod._pq_buffer_remaining_data() > 0) { try { mod._PostgresMainLoopOnce() } catch (e) { if (e.status === this.POSTGRES_MAIN_LONGJMP) mod._PostgresMainLongJmp() } }It swallows every error that is not the longjmp. After
close(), every_PostgresMainLoopOnce()call throwsRuntimeError: null function or function signature mismatch, because the read callback has been removed. A temporary wrapper around it counted 280,000 to 430,000 of these throws in 2 s.#readOffsetnever advances, so the loop never ends.
Suggested fix
- Take the query lock in
close(). Wait for the statement that currently holds#queryMutex, set#closinginside the lock, and only then tear the module down. - Reject queued statements inside the lock. Every protocol round trip goes through
_runExclusiveQuery, both root statements and statements inside a transaction. If it throwsPGlite is closedwhen the instance is closing or closed, that covers queued statements,tx.*calls, and the implicitCOMMIT/ROLLBACK. - Optional, defense in depth. In
execProtocolRawSync(), rethrow errors that are not the longjmp, or stop when the instance is closed, so a dead module produces an error instead of an endless loop.
Sketch, in src/pglite.ts:
_runExclusiveQuery<T>(fn: () => Promise<T>): Promise<T> {
return this.#queryMutex.runExclusive(async () => {
if (this.#closing || this.#closed) throw new Error('PGlite is closed')
return fn()
})
}
async close() {
await this._checkReady()
// Let the statement that holds the lock finish; refuse every later one.
await this.#queryMutex.runExclusive(async () => {
await this._checkReady() // a concurrent close() may have run first
this.#closing = true
})
// ...existing teardown, unchanged
}
One caveat: with this change, calling close() from inside runExclusive() would wait on its own lock. That case could throw instead.
Workaround (verified)
Overriding the same two methods in a subclass makes all four repro cases exit on 0.5.4. It relies on the internal _runExclusiveQuery.
class ClosablePGlite extends PGlite {
#closing = false
_runExclusiveQuery(fn) {
return super._runExclusiveQuery(async () => {
if (this.#closing) throw new Error('PGlite is closed')
return fn()
})
}
async close() {
await super._runExclusiveQuery(async () => { this.#closing = true })
return super.close()
}
}
With this subclass:
inflight: the query rejects withPGlite is closed, thenclose()resolves.tx:close()resolves, then the transaction rejects withPGlite is closed.queued: the queued query is ahead ofclose()in the lock queue, so it completes first, thenclose()resolves.control: unchanged.
Context
We hit this in a test suite. A test timed out while the instance was still starting, and its afterEach closed the shared memory:// instance while a migration's first query was still waiting for startup to finish. The test worker then sat at 100% of one core, almost all of it user time. Because the event loop was blocked, the test runner's test and hook timeouts never fired, and the job ran until its 60-minute CI limit. The same can happen to any application that closes PGlite at shutdown while a query is still running. The mechanism does not appear to depend on the filesystem, but we only tested memory://.
- Dominant language
- TypeScript
- Stars
- 16.1k
- Forks
- 456
- Avg merge
- 1d 7h
- Merged PRs (30d)
- 15
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from electric-sql/pglite
-
Feature request: Nitro 3 support (`unwasm` export condition)May be free again A pull request for this issue was closed without being merged. Open
Difficulty 1/5 Under an hour Newbie friendliness 85/100
electric-sql/pglite#1120 · 8 comments ·
Maintainers usually reply within 1 day
-
[BUG]: pglite-sync: after a `must-refetch` a synced table can stay empty; commit errors are swallowedPossibly taken @jeromerg claimed this 7 days ago. Open
Difficulty 4/5 3-5 days Newbie friendliness 45/100
electric-sql/pglite#1125 ·
Maintainers usually reply within 1 day
-
Worker: init() rejection is never propagated to clientsPossibly taken @tdrz claimed this 1 day ago. Open
Difficulty 4/5 3-5 days Newbie friendliness 72/100
electric-sql/pglite#1122 · 1 reaction · 1 assignee ·
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 78/100
electric-sql/pglite#1116 ·
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 72/100
electric-sql/pglite#1112 ·
Maintainers usually reply within 1 day
All issues in electric-sql/pglite
Similar issues
-
area:ui effort:S enhancement priority:P2
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
QuantumNous/new-api#7839 ·
Maintainers usually reply within 1 day
-
good first issue maintenance
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
dark-princezz/stellar-hooks#1010 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
interledger/rhyza#352 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
Maintainers usually reply within 1 day