Versions
postgres 3.4.9 (the code is unchanged on master 411429e) · Node 24.19.0 · PostgreSQL 18.3
What happens
If a new connection's backend is terminated while the driver fetches array types (fetch_types, on by default), the driver reconnects, and the reconnect succeeds. But the first query on the new connection still fails with the old backend's error, and the same error also escapes as an unhandled rejection:
select 1 rejected: 57P01 terminating connection due to administrator command
unhandledRejection: 57P01 terminating connection due to administrator command
On Node 15+ that unhandled rejection exits the process by default. We hit it during pool warm-up after a database restart/failover, when pg_terminate_backend lands on a connection that is still starting up.
Reproduction
A TCP proxy in front of a real Postgres. The first time the driver sends the array-type query, the proxy answers with the exact ErrorResponse that pg_terminate_backend produces and closes the socket, the same as a real termination at that moment. Every later connection is passed through untouched.
import net from 'node:net'
import postgres from 'postgres'
const PGHOST = process.env.PGHOST || '127.0.0.1'
const PGPORT = Number(process.env.PGPORT || 5432)
// The ErrorResponse Postgres sends before closing a session killed by pg_terminate_backend.
function terminated() {
const field = (code, value) => Buffer.from(code + value + '\0')
const body = Buffer.concat([
field('S', 'FATAL'), field('V', 'FATAL'), field('C', '57P01'),
field('M', 'terminating connection due to administrator command'),
Buffer.from('\0')
])
const header = Buffer.alloc(5)
header.write('E')
header.writeInt32BE(body.length + 4, 1)
return Buffer.concat([header, body])
}
// A TCP proxy to Postgres that kills the first connection while it fetches array types (fetch_types).
let killed = false
const proxy = net.createServer(client => {
const server = net.connect(PGPORT, PGHOST)
client.on('data', chunk => {
if (!killed && chunk.includes('typarray')) {
killed = true
client.end(terminated())
server.destroy()
return
}
server.write(chunk)
})
server.on('data', chunk => client.writable && client.write(chunk))
client.on('error', () => server.destroy())
server.on('error', () => client.destroy())
client.on('close', () => server.destroy())
server.on('close', () => client.destroy())
})
await new Promise(r => proxy.listen(0, '127.0.0.1', r))
process.on('unhandledRejection', e => console.log('unhandledRejection:', e.code, e.message))
const sql = postgres({ host: '127.0.0.1', port: proxy.address().port, max: 1 })
try {
const [{ ok }] = await sql`select 1 as ok`
console.log('select 1 ->', ok)
} catch (e) {
console.log('select 1 rejected:', e.code, e.message)
}
console.log(await Promise.race([
sql.end({ timeout: 1 }).then(() => 'sql.end() resolved'),
new Promise(r => setTimeout(r, 3000, 'sql.end() still pending after 3s'))
]))
proxy.close()
process.exit(0)
Run it against any Postgres; credentials come from the usual PGUSER / PGPASSWORD / PGDATABASE:
docker run --rm -d --name pg-startup-kill -p 127.0.0.1:5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust postgres:18
PGUSER=postgres node repro.mjs
|
Output |
| 3.4.9 / master |
select 1 rejected: 57P01 … + unhandledRejection: 57P01 … |
| 3.4.9 + #1215 |
same as above (#1215 does not cover this path) |
| 3.4.9 + the patch below |
select 1 -> 1 |
Deterministic: the same result on every run.
Why
- During startup,
ReadyForQuery calls fetchArrayTypes(), which runs its own internal query. The user's query is parked as initial. The promise from fetchArrayTypes() is not awaited by anyone.
- The FATAL
ErrorResponse arrives while that internal query is query, so it is stored in errorResponse and waits for a ReadyForQuery that never comes, because the backend closes the socket.
- In
closed(), if (initial) return reconnect() returns before anything clears query or errorResponse.
- On the new socket, the first
ReadyForQuery (end of authentication) finds the stale query + errorResponse and calls errored(errorResponse). That rejects the dead internal query, which nobody handles (the unhandled rejection), and then rejects initial (the user's select 1) with the old backend's 57P01.
connected() resets needsTypes, so simply discarding the stale state is enough: the new connection fetches the types again and the parked query runs normally.
Suggested fix
Clear the per-query state in the initial branch too, the same state #1215 clears on the non-initial path:
- if (initial)
+ if (initial) {
+ query = errorResponse = null
return reconnect()
+ }
This is the same branch #1193 changes (for a different symptom: a clean close with a pending initial loops forever). Whatever shape the fix for #1193 takes, it would need to clear this state too.
Related, already reported
While investigating this we also hit the other connection-close bugs that are already open, and we fixed all of them together in one local patch (attached / below):
Attachment: postgres@3.4.9.patch
Happy to open a PR for the startup-path fix (with a regression test based on the proxy above) if that helps, either standalone or on top of #1215.
Versions
postgres
3.4.9(the code is unchanged on master411429e) · Node24.19.0· PostgreSQL18.3What happens
If a new connection's backend is terminated while the driver fetches array types (
fetch_types, on by default), the driver reconnects, and the reconnect succeeds. But the first query on the new connection still fails with the old backend's error, and the same error also escapes as an unhandled rejection:On Node 15+ that unhandled rejection exits the process by default. We hit it during pool warm-up after a database restart/failover, when
pg_terminate_backendlands on a connection that is still starting up.Reproduction
A TCP proxy in front of a real Postgres. The first time the driver sends the array-type query, the proxy answers with the exact
ErrorResponsethatpg_terminate_backendproduces and closes the socket, the same as a real termination at that moment. Every later connection is passed through untouched.Run it against any Postgres; credentials come from the usual
PGUSER/PGPASSWORD/PGDATABASE:select 1 rejected: 57P01 …+unhandledRejection: 57P01 …select 1 -> 1Deterministic: the same result on every run.
Why
ReadyForQuerycallsfetchArrayTypes(), which runs its own internal query. The user's query is parked asinitial. The promise fromfetchArrayTypes()is not awaited by anyone.ErrorResponsearrives while that internal query isquery, so it is stored inerrorResponseand waits for aReadyForQuerythat never comes, because the backend closes the socket.closed(),if (initial) return reconnect()returns before anything clearsqueryorerrorResponse.ReadyForQuery(end of authentication) finds the stalequery+errorResponseand callserrored(errorResponse). That rejects the dead internal query, which nobody handles (the unhandled rejection), and then rejectsinitial(the user'sselect 1) with the old backend's 57P01.connected()resetsneedsTypes, so simply discarding the stale state is enough: the new connection fetches the types again and the parked query runs normally.Suggested fix
Clear the per-query state in the
initialbranch too, the same state #1215 clears on the non-initial path:This is the same branch #1193 changes (for a different symptom: a clean close with a pending
initialloops forever). Whatever shape the fix for #1193 takes, it would need to clear this state too.Related, already reported
While investigating this we also hit the other connection-close bugs that are already open, and we fixed all of them together in one local patch (attached / below):
socketinnextWrite()→ uncaught TypeError: nextWrite() throws an uncaughtException when a reserved connection's backend dies #1208, Error when attempting to write to a null socket #1154, TypeError: null is not an object (evaluating 'socket.write') #1066 (PRs Guard nextWrite against a closed socket - fixes #1208 #1209, Guard nextWrite against null socket after async close #1168)chunk/nextWriteTimer, and a stalequery/errorResponsereplayed after reconnect: covered by Reject transaction work after its connection closes #1215Attachment: postgres@3.4.9.patch
Happy to open a PR for the startup-path fix (with a regression test based on the proxy above) if that helps, either standalone or on top of #1215.