From 53aa736ad903a45d5c9d3cf653ff7e2bbbc5f4bd Mon Sep 17 00:00:00 2001 From: Arjun Teotia Date: Mon, 28 Sep 2026 20:47:10 +0000 Subject: [PATCH] fix(pg-cursor): settle close() after the cursor has errored close() only short-circuited when the cursor was 'done'. Once handleError has put it in the 'error' state, close() sent a Close for the portal and waited for a readyForQuery, but no Sync follows that Close (handleError already sent one), so the wait could only end if the error's own readyForQuery had not arrived yet. When it had, close() never settled. That covers both reported cases: - the connection is lost mid-read (pg_terminate_backend, a failover): the socket is dead, so nothing will ever arrive; - an ordinary query error followed by close(), e.g. in a finally block: the error's readyForQuery has already been handled. In the 'error' state the portal no longer exists on the server (it is dropped at the Sync handleError sends, or with the connection), so there is nothing to close. Settle straight away, as for 'done'. Fixes #2642 --- packages/pg-cursor/index.js | 5 ++++- packages/pg-cursor/test/close.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/pg-cursor/index.js b/packages/pg-cursor/index.js index 5d566882b..6d443c0e2 100644 --- a/packages/pg-cursor/index.js +++ b/packages/pg-cursor/index.js @@ -223,7 +223,10 @@ class Cursor extends EventEmitter { }) } - if (!this.connection || this.state === 'done') { + // After an error the portal is already gone: the server discards it at the + // Sync handleError sends, or the connection itself is dead. There is nothing + // left to close, and no readyForQuery will follow a Close sent now. + if (!this.connection || this.state === 'done' || this.state === 'error') { setImmediate(cb) return promise } diff --git a/packages/pg-cursor/test/close.js b/packages/pg-cursor/test/close.js index 17c584204..1333b24af 100644 --- a/packages/pg-cursor/test/close.js +++ b/packages/pg-cursor/test/close.js @@ -58,6 +58,35 @@ describe('close', function () { }) }) + it('can close a cursor after a query error', async function () { + const cursor = this.client.query(new Cursor('SELECT 1/0')) + // Close only once the error's readyForQuery has been handled, as a caller + // closing in a finally block after the rejection usually does. + const drained = new Promise((resolve) => this.client.once('drain', resolve)) + await assert.rejects(cursor.read(10), /division by zero/) + await drained + await cursor.close() + const result = await this.client.query('SELECT 1 AS value') + assert.deepStrictEqual(result.rows, [{ value: 1 }]) + }) + + it('can close a cursor after its connection is lost', async function () { + const client = this.client + client.on('error', () => {}) + const cursor = client.query(new Cursor(text)) + await cursor.read(10) + const ended = new Promise((resolve) => client.once('end', resolve)) + const other = new pg.Client() + await other.connect() + try { + await other.query('SELECT pg_terminate_backend($1)', [client.processID]) + } finally { + await other.end() + } + await ended + await cursor.close() + }) + it('is a no-op to "close" the cursor before submitting it', function (done) { const cursor = new Cursor(text) cursor.close(done)