Skip to content

fix: rethrow non-longjmp exceptions in execProtocolRawSync protocol loop - #1097

Closed
Brown-Sage wants to merge 1 commit into
electric-sql:mainfrom
Brown-Sage:fix/exec-protocol-raw-sync-spin
Closed

fix: rethrow non-longjmp exceptions in execProtocolRawSync protocol loop#1097
Brown-Sage wants to merge 1 commit into
electric-sql:mainfrom
Brown-Sage:fix/exec-protocol-raw-sync-spin

Conversation

@Brown-Sage

Copy link
Copy Markdown

Description

Fixes #1058.

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 exec()), the protocol loop spins forever, synchronously, at 100% CPU. The call never settles, so no timer, Promise.race, or AbortSignal can interrupt it — the process has to be killed from outside.

Root cause

After the backend exits, _PostgresMainLoopOnce() throws ExitStatus. That is not the longjmp sentinel, so it was 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, producing a tight call → throw → swallow loop.

Changes

  • Rethrow anything that is not the longjmp sentinel (ExitStatus, WASM RuntimeError, …) — the backend is gone; looping cannot help
  • Guard the finally's own WASM calls (_PostgresSendReadyForQueryIfNecessary / _pgl_pq_flush) so they cannot mask the original error against a dead runtime

Behavior after the fix (verified against the repro from #1058)

  • the killing exec() rejects with ExitStatus { status: 1 }
  • follow-up queries reject immediately with the same error instead of spinning
  • close() resolves and the event loop stays alive throughout

Regression test

New tests/backend-death.test.ts:

  1. COPY ... FROM STDIN rejects instead of hanging
  2. queries after the backend died reject promptly
  3. close() still resolves

Against unfixed code the test process hangs synchronously — it even defeats vitest's own test timeout, so the baseline run has to be killed externally. With this patch all tests pass.

Also adds a changeset for @electric-sql/pglite.

Fixes #1058

When the WASM backend terminates while execProtocolRawSync is processing
a message (e.g. exit(1) after hitting EOF during a COPY ... FROM STDIN),
_PostgresMainLoopOnce() throws ExitStatus. That is not the longjmp
sentinel, so it was silently swallowed; the dead backend consumes no
input, the loop condition never becomes false, and the call spins
forever, synchronously, at 100% CPU — unkillable by timers, Promise.race
or AbortSignal.

Rethrow anything that is not the longjmp sentinel so callers see the
backend error and follow-up queries fail fast. Guard the finally's own
WASM calls so they cannot mask the original error against a dead runtime.

Fixes electric-sql#1058
@Brown-Sage

Copy link
Copy Markdown
Author

Closing in favor of #1081 which takes a more conservative approach to the same fix (narrower rethrow scope to preserve extension behavior, plus exitCode restoration). Thanks @psh4607 — one note: a RuntimeError thrown when #readOffset === message.length but _pq_buffer_remaining_data() > 0 still bypasses your rethrow condition, so that corner case can retain the spin.

@Brown-Sage Brown-Sage closed this Aug 24, 2026
@Brown-Sage
Brown-Sage deleted the fix/exec-protocol-raw-sync-spin branch August 24, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant