electric-sql / electric-sql/pglite
close() racing an in-flight statement wedges PGlite permanently and blocks the event loop
- Dominant language
- TypeScript
- Stars
- 16k
- Forks
- 442
- Avg merge
- 20h 19m
- Merged PRs (30d)
- 7
Description
# `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
```js
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 above
```
**With `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:
```js
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.js
```
A 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 close
```
This 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:
1. Acquire the same exclusive lock statements use, so `close()` waits for the current statement to finish before `_pgl_setPGliteActive(0)`; and/or
2. Reject queued/in-flight statements with the existing `PGlite is closing` error 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.
Contributor guide
No contributing guide indexed for this repository
Research direction
Reproduce the hang with the provided fire-and-forget DELETE and db.close() sequence. Start at close() in dist/index.js, then trace execProtocolRawSync and the statement path shown in the inspector stack, focusing on deactivation before in-flight work settles. Done means close() and the statement promise always settle without blocking the event loop, with either draining or a clear rejection.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- postgres, typescript, wasm
- Domain
- backend, database
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100