Skip to content

fetch_types: Connection killed during startup replays its 57P01 onto the first query after reconnect and an unhandled rejection #1223

Description

@cat-a-guerra

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

  1. 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.
  2. 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.
  3. In closed(), if (initial) return reconnect() returns before anything clears query or errorResponse.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions