close() racing an in-flight statement wedges PGlite permanently and blocks the event loop
close() racing an in-flight statement wedges PGlite permanently (and blocks the event loop)
Package: @electric-sql/pglite 0.5.4
Platform: Node 24.18.0, macOS (darwin 27.0.0)
Summary
If close() is called while a statement issued earlier is still in flight, neither promise ever settles — the statement never resolves or rejects, and close() never returns. The process is left permanently wedged. Depending on where in the protocol exchange the backend was deactivated, it either spins at 100% CPU inside execProtocolRawSync's synchronous main loop or blocks at 0% CPU.
The critical consequence: because execProtocolRawSync is synchronous, the wedge blocks the Node event loop. No timer, no AbortSignal, no Promise.race timeout, and no test-runner timeout can fire. There is no way to recover in-process — the only remedy is SIGKILL.
Reproduction
import { PGlite } from '@electric-sql/pglite';
const db = new PGlite();
await db.query('CREATE TABLE t (workflow_name TEXT, run_id TEXT)');
await db.query("INSERT INTO t VALUES ('agentic-loop', 'run-1')");
// A background statement whose promise the caller does not await —
// e.g. fire-and-forget cleanup from a library.
const background = db.query('DELETE FROM t WHERE workflow_name = $1 AND run_id = $2', [
'agentic-loop',
'run-1',
]);
// await background; // <-- with this line: close() returns in 1ms, clean exit
console.log('calling close()...');
await db.close();
console.log('close() returned'); // <-- never printed without the await aboveWith await background: background query resolved, close() returned after 1ms, exit 0.
Without it: prints calling close()... and then hangs forever. Observed hung for 51s before SIGKILL; neither the DELETE promise nor close() ever settled. An unref'd setInterval watchdog installed before the call never fires once, confirming the event loop is blocked.
Cause
close() deactivates the WASM backend before it drains or rejects work that is already in flight:
async close() {
await this._checkReady();
this.#closing = true;
for (const cb of this.#closeListeners) await cb();
try {
this.mod._pgl_setPGliteActive(0); // <-- backend deactivated here
await this.execProtocol(end()); // <-- then it awaits
this.mod._pgl_run_atexit_funcs();
} catch (e) { /* ... */ }
finally { /* removeFunction ... */ }
await this.fs.closeFs();
this.#closed = true;
// ...
}_checkReady() guards entry (PGlite is closing / PGlite is closed), so a statement that has already passed that guard is unprotected. Once _pgl_setPGliteActive(0) has run, such a statement enters execProtocolRawSync's for (;;) main loop against a dead backend, which never produces output and never terminates.
Confirmed by attaching an inspector to the wedged process and pausing it:
$PostgresMainLoopOnce @ wasm://wasm/0267b22e
execProtocolRawSync @ @electric-sql/pglite/dist/index.js
execProtocolRaw @ @electric-sql/pglite/dist/index.js
execProtocolStream @ @electric-sql/pglite/dist/index.js
y @ @electric-sql/pglite/dist/chunk-SRXPYZFS.jsA 4s CPU profile attributed 96.6% of samples to a single WASM frame under one execProtocolRaw call — one statement, not a retry loop.
How we hit it
A library issued fire-and-forget cleanup DML after its own work returned (DELETE FROM … WHERE … AND run_id = $2), and our test teardown called await db.close() 5ms later. Our statement trace shows both entering and neither completing:
[…368299] START #356 query: DELETE FROM mastra_workflow_snapshot WHERE …
[…368304] START #357 closeThis wedged a CI job to its 60-minute cancellation cap. Because the event loop was blocked, the test framework's own 30s per-test timeout never fired — the job could not fail, only be cancelled. The same hazard exists in any app that closes a PGlite during shutdown while background work is still in flight.
Suggested fix
close() should serialize against in-flight statements rather than deactivating underneath them. Either:
- Acquire the same exclusive lock statements use, so
close()waits for the current statement to finish before_pgl_setPGliteActive(0); and/or - Reject queued/in-flight statements with the existing
PGlite is closingerror at deactivation time, so callers observe a rejection instead of a hang.
Even if draining is considered out of scope, the failure mode should be a rejection, never an unrecoverable synchronous spin — the current behaviour is undebuggable from inside the process.
Source: electric-sql/pglite