- 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.
Summary
When a query runs with the
rowsoption (portal suspension —client.query({ text, rows: N })),an
ErrorResponsefrom the server permanently wedges the connection: the query itself rejectscorrectly, but
ReadyForQuerynever arrives,client.readyForQuerystaysfalse, and everysubsequent query on that client queues forever.
Found while developing a wire-protocol bridge, where the message flow made the missing
frame visible.
Reproduction
Root cause
With
rowsset,Query.prepare()drives the extended protocol with Flush, not Sync:_getRows()sendsExecute(rows=N)+connection.flush(), and again on everyPortalSuspended; Sync is only sent fromhandleCommandComplete/handleEmptyQuery(lib/query.js). When the server answers with
ErrorResponseinstead, it entersignore-till-sync — it discards everything until a Sync arrives and withholds
ReadyForQuery.handleErrorsends nothing, so the RFQ never comes and the client'squeue is stuck permanently.
Some history, because the code carries a fossil of the old behavior:
handleErrordidsend 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 removedthe
handleErrorcall — correct for the normal prepared path, where a Sync is alreadyin flight by the time an error arrives. But the
rowsbranch of_getRows()pipelinesFlush 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
handleErrorsync for exactly this reason (packages/pg-cursor/index.js:// call sync to trigger a readyForQuery), which is why cursors recover from errorsand the core
rowspath does not.Suggested fix
Mirror what
handleCommandComplete/handleEmptyQueryalready do for rows-mode: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,
handleCommandCompletewould already have sent Sync and the line above woulddouble-send (two RFQs — the class of bug #2420 fixed in pg-cursor). A small
this._syncSentflag 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
handleErrorsync call) and #1500 (readyForQuerystuckafter a dead connection). Since
rowsis undocumented, this is a low-visibilitysurface — but the wedge is silent and permanent when hit.