Error during a `rows`-limited query permanently wedges the connection — `handleError` sends no Sync

Author: drudolfCreated Jul 6, 2026Updated Jul 7, 2026
Labelsbug
  • pg: 8.22.0
  • Node: 24.14.1
  • PostgreSQL: 18.4 (macOS; not server-specific — the wedge follows from the wire protocol)

Summary

When a query runs with the rows option (portal suspension — client.query({ text, rows: N })), an ErrorResponse from the server permanently wedges the connection: the query itself rejects correctly, but ReadyForQuery never arrives, client.readyForQuery stays false, and every subsequent query on that client queues forever.

Found while developing a wire-protocol bridge, where the message flow made the missing frame visible.

Reproduction

javascript
const { Client } = require('pg')

async function main() {
  const client = new Client()
  await client.connect()

  // Errors mid-portal: division by zero on row 5, fetched in pages of 2.
  await client
    .query({ text: 'select 10 / (5 - i) as v from generate_series(1, 7) g(i)', rows: 2 })
    .catch((err) => console.log('query rejected:', err.message)) // "division by zero" — fine

  // The connection is now wedged: this never settles.
  const result = await Promise.race([
    client.query('select 1').then(() => 'follow-up resolved'),
    new Promise((r) => setTimeout(() => r('follow-up HUNG'), 5000)),
  ])
  console.log(result) // "follow-up HUNG"
}
main()

Root cause

With rows set, Query.prepare() drives the extended protocol with Flush, not Sync: _getRows() sends Execute(rows=N) + connection.flush(), and again on every PortalSuspended; Sync is only sent from handleCommandComplete / handleEmptyQuery (lib/query.js). When the server answers with ErrorResponse instead, it enters ignore-till-sync — it discards everything until a Sync arrives and withholds ReadyForQuery. handleError sends nothing, so the RFQ never comes and the client's queue is stuck permanently.

Some history, because the code carries a fossil of the old behavior: handleError did send Sync from at least 2014 (// need to sync after error during a prepared statement — the comment is still in today's source). The Oct 2020 pipelining redesign (d31486fb, then dd3ce616) moved Sync into _getRows(), pipelined right after Execute, and removed the handleError call — correct for the normal prepared path, where a Sync is already in flight by the time an error arrives. But the rows branch of _getRows() pipelines Flush instead, so rows-mode was left with no error-path Sync at all; only the comment survived.

pg-cursor — which uses the same Execute+Flush model — deliberately kept its handleError sync for exactly this reason (packages/pg-cursor/index.js: // call sync to trigger a readyForQuery), which is why cursors recover from errors and the core rows path does not.

Suggested fix

Mirror what handleCommandComplete / handleEmptyQuery already do for rows-mode:

javascript
handleError(err, connection) {
  // rows-mode pipelines Flush, not Sync (_getRows) — after an ErrorResponse the
  // backend discards messages until Sync, so send it here or ReadyForQuery never
  // arrives and the connection is wedged.
  if (this.rows) {
    connection.sync()
  }
  // ... existing body unchanged
}

One edge worth a maintainer's judgement: 9c678e10 (Oct 2020) guarded the old sync-after-error because PostgreSQL 9.x could send both CommandComplete and ErrorResponse for a single timed-out query. If that pairing is still in scope, handleCommandComplete would already have sent Sync and the line above would double-send (two RFQs — the class of bug #2420 fixed in pg-cursor). A small this._syncSent flag shared by the three handlers would make it airtight.

I searched for an existing report and found none covering this path — the closest are #549 (a crash in the pre-2020 handleError sync call) and #1500 (readyForQuery stuck after a dead connection). Since rows is undocumented, this is a low-visibility surface — but the wedge is silent and permanent when hit.