[BUG]: execProtocolRawSync spins forever at 100% CPU when the backend exits mid-message (swallow-all catch in the protocol loop)

Aperta
#1,058 4 commenti 2 reazioni 1 assegnatario Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@tdrz ci sta già lavorando.

Dal 14/10/2026.

  • #1097 di @Brown-Sage — chiusa senza merge

Valutazione

Difficoltà
3/5
Tempo stimato
1-2 giorni
Idoneità per principianti
74/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
postgresql, typescript, wasm
Ambito
backend, databases

Direzione di ricerca

Inizia da packages/pglite/src/pglite.ts e dall’entry point execProtocolRawSync, quindi esegui la riproduzione di COPY FROM STDIN riportata nell’issue. Verifica che l’uscita di un backend non causi più un’attesa attiva sincrona, che exec() venga rifiutato, che le query successive falliscano prontamente, che close() si risolva e che il loop degli eventi rimanga reattivo.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

Describe the bug

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.

Root cause, packages/pglite/src/pglite.ts, execProtocolRawSync:

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.

Two aggravating factors:

  1. The finally (which restores process.exitCode, added for #975) never runs, because the loop never exits.
  2. 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';

const p = new PGlite();
await p.exec('CREATE TABLE t(a int)');
// Backend hits EOF mid-COPY and exits — exec() never settles, process pegs a core:
await p.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 {
    throw e  // 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.

Additional context

  • #945 — synchronous WASM hang; documents that Promise.race/timers cannot interrupt these loops (different root cause: Gather workers)
  • #975 — process.exitCode = 99 leak; introduced the finally that this spin prevents from running
  • #1052 — protocol parser wedged after a throw in parse(); same method neighborhood, different failure mode
  • PR #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.
  • #820 — an in-the-wild lever for the same backend-death class (oversized CREATE TYPE via pglite-socket → FATAL, backend gone mid-message)
Lingua principale
TypeScript
Stelle
16.3k
Fork
474
Merge medio
23h 53m
PR unite (30g)
53

Preparare l'ambiente

Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di electric-sql/pglite

Tutte le issue di electric-sql/pglite

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.