Skip to content

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

Description

@drudolf
  • 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

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 (d31486f,
then dd3ce61) 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:

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: 9c678e1 (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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions