From 0cea788cab98506d0dc7665336a1009674f4264b Mon Sep 17 00:00:00 2001 From: Brown-Sage Date: Mon, 24 Aug 2026 21:55:25 +0530 Subject: [PATCH] fix: rethrow non-longjmp exceptions in execProtocolRawSync protocol loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #1058 --- .changeset/exec-protocol-raw-sync-spin.md | 5 +++ packages/pglite/src/pglite.ts | 15 +++++++- packages/pglite/tests/backend-death.test.ts | 41 +++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 .changeset/exec-protocol-raw-sync-spin.md create mode 100644 packages/pglite/tests/backend-death.test.ts diff --git a/.changeset/exec-protocol-raw-sync-spin.md b/.changeset/exec-protocol-raw-sync-spin.md new file mode 100644 index 000000000..22063a962 --- /dev/null +++ b/.changeset/exec-protocol-raw-sync-spin.md @@ -0,0 +1,5 @@ +--- +'@electric-sql/pglite': patch +--- + +Fix `execProtocolRawSync` spinning forever at 100% CPU when the WASM backend terminates mid-message (e.g. `exit(1)` after hitting EOF during a `COPY ... FROM STDIN`). Non-longjmp exceptions from the protocol loop are now rethrown instead of silently swallowed, so the call rejects with the backend error and subsequent calls fail fast instead of hanging (#1058). diff --git a/packages/pglite/src/pglite.ts b/packages/pglite/src/pglite.ts index 517a4376a..2bb402265 100644 --- a/packages/pglite/src/pglite.ts +++ b/packages/pglite/src/pglite.ts @@ -950,6 +950,12 @@ export class PGlite // that we call whenever the exception longjmp is executed // like this we also just need to setjmp only once, in a similar fashion to the original code. mod._PostgresMainLongJmp() + } else { + // anything else (e.g. an ExitStatus from the backend terminating, + // or a WASM RuntimeError) means the backend is gone — swallowing + // the exception here would leave the loop spinning forever, as + // no more input is consumed. Rethrow so the caller sees the error. + throw e } // even if there is an exception caused by one of the batched queries, // we need to continue processing the rest without throwing. @@ -958,8 +964,13 @@ export class PGlite } } } finally { - mod._PostgresSendReadyForQueryIfNecessary() - mod._pgl_pq_flush() + try { + mod._PostgresSendReadyForQueryIfNecessary() + mod._pgl_pq_flush() + } catch { + // if the backend terminated, the runtime may already be dead; + // don't let these calls mask the original error thrown above + } } this.#outputData = [] diff --git a/packages/pglite/tests/backend-death.test.ts b/packages/pglite/tests/backend-death.test.ts new file mode 100644 index 000000000..0e24cd02b --- /dev/null +++ b/packages/pglite/tests/backend-death.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { PGlite } from '../dist/index.js' + +// Regression test for https://github.com/electric-sql/pglite/issues/1058 +// +// When the WASM backend terminates while `execProtocolRawSync` is processing +// a message (e.g. `exit(1)` after hitting EOF during a `COPY ... FROM STDIN`), +// the protocol loop used to swallow the non-longjmp exception and spin forever, +// synchronously, at 100% CPU. The call never settled and the process had to be +// killed from outside. +describe('backend terminates mid-message', () => { + let db: PGlite + + beforeAll(async () => { + db = await PGlite.create() + await db.exec('CREATE TABLE backend_death_t(a int)') + }) + + afterAll(async () => { + if (!db.closed) { + await db.close() + } + }) + + it('should reject instead of spinning when the backend exits', async () => { + // The backend hits EOF during COPY FROM STDIN and exits with status 1 + await expect(db.exec('COPY backend_death_t FROM STDIN')).rejects.toThrow( + /terminated|exit/i, + ) + }) + + it('should reject promptly for queries issued after the backend died', async () => { + await expect( + db.query('SELECT 1 FROM backend_death_t'), + ).rejects.toThrow(/terminated|exit/i) + }) + + it('should still be able to close the database', async () => { + await expect(db.close()).resolves.toBeUndefined() + }) +})