Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

`close()` does not wait for in-flight or queued statements; a statement that reaches the closed instance spins forever in `execProtocolRawSync` (100% CPU, event loop blocked)

Closed
#1,128 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
56/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
postgresql, typescript, wasm
Domain
backend, database

Research direction

Start in src/pglite.ts by reading close() and _runExclusiveQuery, then trace how root queries and transaction statements use the query lock. Reproduce the in-flight and queued cases from the supplied script, and inspect execProtocolRawSync() for the non-terminating loop. Done means closing waits for active work, queued or later statements reject cleanly, and none of the cases spin.

Written by the indexing model from the issue text.

Description

Summary

PGlite.close() is not serialized with the query lock. If a statement is in flight or queued when close() runs, that statement later runs against the closed module and never returns. This includes statements inside a transaction (tx.query / tx.exec), which never call _checkReady(). The statement loops synchronously inside execProtocolRawSync(). The process then holds one CPU core at 100%, and the event loop never runs again, so no JS timer, timeout or signal handler can recover it.

Environment
  • @electric-sql/pglite 0.5.4, memory://, no extensions
  • Node.js 22.23.3 on Windows 11 Pro x64 (build 26200)
  • Also reproduced with Node.js 24.14.0 on Ubuntu 24.04 (WSL2, x86_64)
  • First seen as a hung CI test job on Linux arm64 with Node.js 22.23.3
Reproduction

The script below needs only @electric-sql/pglite. Each case runs on a fresh memory:// instance.

npm i @electric-sql/[email protected]
timeout 30 node pglite-close-race-repro.mjs inflight   # or tx, queued, control
// PGlite close() race: a statement that reaches the module after close() spins
// forever in execProtocolRawSync, pinning one CPU core and blocking the event loop.
//
//   npm i @electric-sql/[email protected]
//   timeout 30 node pglite-close-race-repro.mjs [inflight|tx|queued|control]
//
// Expected: every case prints "done" and exits.
// Actual (0.5.4): "inflight", "tx" and "queued" never print "done", the heartbeat
// stops, and the process uses 100% of one core until it is killed.
import { writeSync } from 'node:fs'
import { PGlite } from '@electric-sql/pglite'

// Log synchronously: once the spin starts, the event loop never runs again.
const log = (msg) =>
  writeSync(1, `${(performance.now() / 1000).toFixed(2)}s ${msg}\n`)
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const settle = (label, promise) =>
  promise.then(
    () => log(`${label} resolved`),
    (error) => log(`${label} rejected: ${error.message}`),
  )

const which = process.argv[2] ?? 'inflight'
const db = new PGlite('memory://')
await db.waitReady
log(`ready (case "${which}", pid ${process.pid})`)

const heartbeat = setInterval(() => log('heartbeat'), 250)
await sleep(600)

if (which === 'inflight') {
  // close() while a db.query() is still running (not awaited yet).
  const query = settle('query', db.query('select 1'))
  log('close() while db.query() is in flight')
  const closing = settle('close()', db.close())
  await query
  await closing
} else if (which === 'tx') {
  // close() while a transaction callback is suspended between statements.
  // tx.query() does not go through _checkReady().
  let resume
  const resumed = new Promise((resolve) => (resume = resolve))
  const transaction = settle(
    'transaction',
    db.transaction(async (tx) => {
      await tx.query('select 1')
      await resumed
      log('tx.query() after close()')
      await tx.query('select 2')
    }),
  )
  await sleep(50)
  log('close() while a transaction is open')
  await settle('close()', db.close())
  resume()
  await transaction
} else if (which === 'queued') {
  // close() while a db.query() that already passed _checkReady() waits on the
  // query lock (held here by runExclusive()).
  let release
  const holder = settle(
    'runExclusive()',
    db.runExclusive(() => new Promise((resolve) => (release = resolve))),
  )
  const queued = settle('queued query', db.query('select 2'))
  await sleep(50)
  log('close() while db.query() waits on the lock')
  const closing = settle('close()', db.close())
  await sleep(50)
  log('releasing the lock')
  release()
  await holder
  await queued
  await closing
} else {
  // Control: a statement issued after close() is rejected, as expected.
  await settle('close()', db.close())
  await settle('query after close()', db.query('select 1'))
}

clearInterval(heartbeat)
log('done')

Output on 0.5.4 (Windows, Node 22.23.3; Linux output is the same apart from timings):

$ timeout 12 node pglite-close-race-repro.mjs control
1.13s ready (case "control", pid 11348)
1.38s heartbeat
1.65s heartbeat
1.74s close() resolved
1.74s query after close() rejected: PGlite is closed
1.74s done                                   # exit 0

$ timeout 12 node pglite-close-race-repro.mjs inflight
1.12s ready (case "inflight", pid 81980)
1.37s heartbeat
1.63s heartbeat
1.73s close() while db.query() is in flight
                                             # no more output; killed by timeout, exit 124

$ timeout 12 node pglite-close-race-repro.mjs tx
1.04s ready (case "tx", pid 34664)
1.30s heartbeat
1.57s heartbeat
1.72s close() while a transaction is open
1.72s close() resolved
1.72s tx.query() after close()
                                             # no more output; killed by timeout, exit 124

$ timeout 12 node pglite-close-race-repro.mjs queued
1.03s ready (case "queued", pid 18784)
1.30s heartbeat
1.56s heartbeat
1.70s close() while db.query() waits on the lock
1.70s close() resolved
1.76s releasing the lock
                                             # no more output; killed by timeout, exit 124

In the three hanging cases the process is busy, not idle. It used 4.97 to 5.00 CPU-seconds per 5 s of wall time on Windows. On Linux, ps showed 8 s of CPU time after 8 s of elapsed time.

Expected behavior
  • A statement that already holds the query lock when close() is called either finishes before close() tears the module down, or rejects.
  • A statement that is queued, or issued later through a still-open transaction object (including the implicit COMMIT / ROLLBACK), rejects with an error such as PGlite is closed. A root query() issued after close() already behaves this way (the control case).
  • close() resolves, and nothing spins.
Actual behavior
  • inflight: the query never settles, close() never resolves, and the process spins.
  • tx: close() resolves, then the next tx.query() never settles and the process spins.
  • queued: close() resolves while a db.query() waits on the lock. When the lock is released, that query never settles and the process spins.
Cause (from reading the 0.5.4 source)
  1. close() calls _checkReady() and sets #closing, but it takes neither #queryMutex nor #transactionMutex. It then calls removeFunction() on the socket read and write callbacks, closes the filesystem, and calls _emscripten_force_exit(0).

  2. Statements can get past the closed check in three ways:

    • Root query(), exec() and transaction() call _checkReady() only once, on entry, before they wait on the mutexes. A statement already waiting on a mutex has therefore passed the check.
    • One query() is several awaited protocol round trips (parse, describe, bind, describe, execute, sync), so close() can finish between two of them. In the inflight case the spin starts at the Bind message.
    • tx.query() and tx.exec(), and the COMMIT / ROLLBACK that transaction() issues, never call _checkReady(). They check only the transaction's own closed flag.
  3. In execProtocolRawSync(), the loop is:

    while (this.#readOffset < message.length || mod._pq_buffer_remaining_data() > 0) {
      try { mod._PostgresMainLoopOnce() }
      catch (e) { if (e.status === this.POSTGRES_MAIN_LONGJMP) mod._PostgresMainLongJmp() }
    }
    

    It swallows every error that is not the longjmp. After close(), every _PostgresMainLoopOnce() call throws RuntimeError: null function or function signature mismatch, because the read callback has been removed. A temporary wrapper around it counted 280,000 to 430,000 of these throws in 2 s. #readOffset never advances, so the loop never ends.

Suggested fix
  1. Take the query lock in close(). Wait for the statement that currently holds #queryMutex, set #closing inside the lock, and only then tear the module down.
  2. Reject queued statements inside the lock. Every protocol round trip goes through _runExclusiveQuery, both root statements and statements inside a transaction. If it throws PGlite is closed when the instance is closing or closed, that covers queued statements, tx.* calls, and the implicit COMMIT / ROLLBACK.
  3. Optional, defense in depth. In execProtocolRawSync(), rethrow errors that are not the longjmp, or stop when the instance is closed, so a dead module produces an error instead of an endless loop.

Sketch, in src/pglite.ts:

_runExclusiveQuery<T>(fn: () => Promise<T>): Promise<T> {
  return this.#queryMutex.runExclusive(async () => {
    if (this.#closing || this.#closed) throw new Error('PGlite is closed')
    return fn()
  })
}

async close() {
  await this._checkReady()
  // Let the statement that holds the lock finish; refuse every later one.
  await this.#queryMutex.runExclusive(async () => {
    await this._checkReady() // a concurrent close() may have run first
    this.#closing = true
  })
  // ...existing teardown, unchanged
}

One caveat: with this change, calling close() from inside runExclusive() would wait on its own lock. That case could throw instead.

Workaround (verified)

Overriding the same two methods in a subclass makes all four repro cases exit on 0.5.4. It relies on the internal _runExclusiveQuery.

class ClosablePGlite extends PGlite {
  #closing = false
  _runExclusiveQuery(fn) {
    return super._runExclusiveQuery(async () => {
      if (this.#closing) throw new Error('PGlite is closed')
      return fn()
    })
  }
  async close() {
    await super._runExclusiveQuery(async () => { this.#closing = true })
    return super.close()
  }
}

With this subclass:

  • inflight: the query rejects with PGlite is closed, then close() resolves.
  • tx: close() resolves, then the transaction rejects with PGlite is closed.
  • queued: the queued query is ahead of close() in the lock queue, so it completes first, then close() resolves.
  • control: unchanged.
Context

We hit this in a test suite. A test timed out while the instance was still starting, and its afterEach closed the shared memory:// instance while a migration's first query was still waiting for startup to finish. The test worker then sat at 100% of one core, almost all of it user time. Because the event loop was blocked, the test runner's test and hook timeouts never fired, and the job ran until its 60-minute CI limit. The same can happen to any application that closes PGlite at shutdown while a query is still running. The mechanism does not appear to depend on the filesystem, but we only tested memory://.

Dominant language
TypeScript
Stars
16.1k
Forks
456
Avg merge
1d 7h
Merged PRs (30d)
15

Getting set up

This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from electric-sql/pglite

All issues in electric-sql/pglite

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.