You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When the WASM backend terminates while execProtocolRawSync is processing a message (e.g. exit(1) after hitting EOF during a COPY ... FROM STDIN issued via pglite.exec()), the protocol loop spins forever, synchronously, at 100% CPU. The call never returns, so no timer, Promise.race, or AbortSignal can interrupt it (the same structural observation made in #945), and the Node process has to be killed from outside.
while(this.#readOffset <message.length||mod._pq_buffer_remaining_data()>0){try{mod._PostgresMainLoopOnce()}catch(e: any){if(e.status===this.POSTGRES_MAIN_LONGJMP){mod._PostgresMainLongJmp()}// all other exceptions are swallowed and the loop continues}}
After the backend exits, _PostgresMainLoopOnce() throws ExitStatus (status = 1). That is not the longjmp sentinel, so it is silently swallowed; the dead backend consumes no input, so #readOffset never advances and _pq_buffer_remaining_data() never drains — the while condition is permanently true. The result is a tight synchronous call → throw → swallow loop. The event loop never runs again.
There is no death detection: after exit(1), ready remains true and closed remains false, so callers cannot observe the crash even where the loop is escaped.
COPY FROM STDIN via exec() is just the easiest structural trigger — any exception from _PostgresMainLoopOnce that is not the longjmp sentinel arms the same loop (e.g. a WASM RuntimeError from an OOM abort, or the FATAL: terminating connection because protocol synchronization was lost class seen in #820).
To Reproduce
import{PGlite}from'@electric-sql/pglite';constp=newPGlite();awaitp.exec('CREATE TABLE t(a int)');// Backend hits EOF mid-COPY and exits — exec() never settles, process pegs a core:awaitp.exec('COPY t FROM STDIN');console.log('never reached');
A transaction is not required; wrapping the COPY in BEGIN spins identically.
Logs
V8 sampling profile of the spinning process (node --prof, ~20k ticks): 99.9% of samples are inside execProtocolRawSync → two wasm frames, with C++ time dominated by exception allocation/stack capture — consistent with the throw-per-iteration loop above.
Details
PGlite version: 0.5.4 (also present unchanged in main's pglite.ts at time of filing)
Extensions: none
OS: macOS 15 (Darwin 25.3.0)
Runtime: Node 24.14.1
Suggested fix (verified against a patched 0.5.4 dist)
Rethrow anything that is not the longjmp sentinel, and guard the finally's own WASM calls so they cannot mask the original error against a dead runtime:
}catch(e: any){if(e.status===this.POSTGRES_MAIN_LONGJMP){mod._PostgresMainLongJmp()}else{throwe// ExitStatus, RuntimeError, … — the backend is gone; looping cannot help}}// ...}finally{try{mod._PostgresSendReadyForQueryIfNecessary()mod._pgl_pq_flush()}catch{// dead runtime — the rethrown error above is the one that matters}pglUtils.pgliteProc.exitCode=prevExitCode}
With exactly this change applied to the 0.5.4 dist, the repro behaves well in both variants (with and without an open transaction):
the killing exec() rejects with ExitStatus { status: 1 } ("Program terminated with exit(1)")
follow-up queries reject immediately with the same error instead of spinning
close() resolves
the event loop stays alive throughout
Beyond the minimal fix, it would help to mark the instance dead on ExitStatus (flip ready/closed or expose a crashed flag) so callers get a coherent state instead of ready === true on a dead instance, and/or to add a progress guard to the loop (bail if an iteration neither advanced #readOffset nor drained the pq buffer) as defense in depth.
PR wip: test case for unclean shutdown after a "drop database" #351 (closed WIP) — test case for unclean shutdown after DROP DATABASE; its description already warns "this test will hang in a busy loop and CI will continue waiting with no timeout". Same symptom family, never filed as a bug.
Describe the bug
When the WASM backend terminates while
execProtocolRawSyncis processing a message (e.g.exit(1)after hitting EOF during aCOPY ... FROM STDINissued viapglite.exec()), the protocol loop spins forever, synchronously, at 100% CPU. The call never returns, so no timer,Promise.race, orAbortSignalcan interrupt it (the same structural observation made in #945), and the Node process has to be killed from outside.Root cause,
packages/pglite/src/pglite.ts,execProtocolRawSync:After the backend exits,
_PostgresMainLoopOnce()throwsExitStatus(status = 1). That is not the longjmp sentinel, so it is silently swallowed; the dead backend consumes no input, so#readOffsetnever advances and_pq_buffer_remaining_data()never drains — thewhilecondition is permanently true. The result is a tight synchronous call → throw → swallow loop. The event loop never runs again.Two aggravating factors:
finally(which restoresprocess.exitCode, added for [BUG]: PGlite sets process.exitCode = 99 #975) never runs, because the loop never exits.exit(1),readyremainstrueandclosedremainsfalse, so callers cannot observe the crash even where the loop is escaped.COPY FROM STDINviaexec()is just the easiest structural trigger — any exception from_PostgresMainLoopOncethat is not the longjmp sentinel arms the same loop (e.g. a WASMRuntimeErrorfrom an OOM abort, or theFATAL: terminating connection because protocol synchronization was lostclass seen in #820).To Reproduce
A transaction is not required; wrapping the COPY in
BEGINspins identically.Logs
V8 sampling profile of the spinning process (
node --prof, ~20k ticks): 99.9% of samples are insideexecProtocolRawSync→ two wasm frames, with C++ time dominated by exception allocation/stack capture — consistent with the throw-per-iteration loop above.Details
main'spglite.tsat time of filing)Suggested fix (verified against a patched 0.5.4 dist)
Rethrow anything that is not the longjmp sentinel, and guard the
finally's own WASM calls so they cannot mask the original error against a dead runtime:With exactly this change applied to the 0.5.4 dist, the repro behaves well in both variants (with and without an open transaction):
exec()rejects withExitStatus { status: 1 }("Program terminated with exit(1)")close()resolvesBeyond the minimal fix, it would help to mark the instance dead on
ExitStatus(flipready/closedor expose acrashedflag) so callers get a coherent state instead ofready === trueon a dead instance, and/or to add a progress guard to the loop (bail if an iteration neither advanced#readOffsetnor drained the pq buffer) as defense in depth.Additional context
SELECT COUNT(*)hangs indefinitely on large tables due to parallel query planner choosing aGathernode #945 — synchronous WASM hang; documents thatPromise.race/timers cannot interrupt these loops (different root cause: Gather workers)process.exitCode = 99leak; introduced thefinallythat this spin prevents from runningparse(); same method neighborhood, different failure modeDROP DATABASE; its description already warns "this test will hang in a busy loop and CI will continue waiting with no timeout". Same symptom family, never filed as a bug.CREATE TYPEvia pglite-socket →FATAL, backend gone mid-message)