Skip to content

sql.end() never resolves after the connection closes inside sql.begin() (still hangs with #1142 and #1209 applied) #1242

Open

Description

@Automatice

Summary

After the connection closes while a query of a sql.begin() transaction is in flight, sql.begin() rejects with CONNECTION_CLOSED (correct), but a later sql.end() called without timeout never resolves. sql.end({ timeout: n }) does resolve.

In a long-lived process this is silent: code shaped like try { await sql.begin(...) } finally { await sql.end() } neither returns nor throws.

Tested with postgres@3.4.9 from the npm tarball (unmodified), Node.js v25.6.1, macOS. First seen behind a connection pooler that dropped connections mid-transaction.

Reproduction (no PostgreSQL needed)

A tiny fake server completes the startup handshake, answers BEGIN, goes silent on the first extended-protocol query and destroys the socket one second later.

// postgres@3.4.9 — sql.end() (no timeout) never resolves after the connection
// drops while a query of a sql.begin() transaction is in flight.
// No PostgreSQL needed: a tiny fake server completes the handshake, answers
// BEGIN, goes silent on the first extended-protocol query, then destroys the socket.
import net from 'node:net'
import postgres from 'postgres'

const msg = (t, b) => { const x = Buffer.alloc(5 + b.length); x.write(t, 0); x.writeInt32BE(4 + b.length, 1); b.copy(x, 5); return x }
const ready = s => msg('Z', Buffer.from(s))
const done = tag => msg('C', Buffer.from(tag + '\0'))

const server = net.createServer(sock => {
  let started = false, buf = Buffer.alloc(0), stalled = false
  sock.on('error', () => {})
  sock.on('data', d => {
    buf = Buffer.concat([buf, d])
    if (!started) {
      if (buf.length < 8) return
      const len = buf.readInt32BE(0); if (buf.length < len) return
      buf = buf.subarray(len); started = true
      sock.write(Buffer.concat([msg('R', Buffer.from([0, 0, 0, 0])), msg('S', Buffer.from('server_version\x0016.0\0')), msg('K', Buffer.alloc(8)), ready('I')]))
    }
    while (buf.length >= 5) {
      const type = String.fromCharCode(buf[0]), len = buf.readInt32BE(1)
      if (buf.length < 1 + len) return
      const body = buf.subarray(5, 1 + len); buf = buf.subarray(1 + len)
      if (stalled) continue
      if (type === 'Q') {
        const q = body.toString().replace(/\0$/, '').trim().toLowerCase()
        sock.write(q.startsWith('begin') ? Buffer.concat([done('BEGIN'), ready('T')]) : Buffer.concat([done('OK'), ready('I')]))
      } else if (type === 'P') { stalled = true; setTimeout(() => sock.destroy(), 1000) }
    }
  })
})
await new Promise(r => server.listen(0, '127.0.0.1', r))

// the stray ROLLBACK write on the null socket (#1208) — keep the process alive to observe end()
process.on('uncaughtException', e => console.log('uncaughtException:', e.message))

const sql = postgres(`postgres://u:p@127.0.0.1:${server.address().port}/db`, { max: 5, fetch_types: false })
const sleep = (ms, v) => new Promise(r => setTimeout(r, ms, v))

const tx = await sql.begin(tx => tx`select ${1}`).then(() => 'resolved', e => 'rejected: ' + e.code)
console.log('sql.begin():', tx)

const ended = await Promise.race([sql.end().then(() => 'resolved'), sleep(10000, 'STILL PENDING after 10 s')])
console.log('sql.end():', ended)
process.exit(0)

Output on 3.4.9:

sql.begin(): rejected: CONNECTION_CLOSED
uncaughtException: Cannot read properties of null (reading 'write')
sql.end(): STILL PENDING after 10 s

The open fixes do not cover this path

I applied the src/connection.js changes of the two related open PRs to a copy of 3.4.9 and re-ran the same script:

build uncaughtException from nextWrite sql.end()
3.4.9 yes never resolves
3.4.9 + #1142 yes never resolves
3.4.9 + #1209 no never resolves
3.4.9 + #1142 + #1209 no never resolves
3.4.9, sql.end({ timeout: 2 }) yes resolves

So #1209 removes the stray exception (#1208) and #1142 fixes the non-transaction ECONNRESET case, but the hang remains when the close happens inside begin(). My reading, not verified in the source beyond the experiment above: begin() issues its own ROLLBACK after the in-flight query fails, on a connection whose socket is already gone, and that query is never settled, so end() keeps waiting for it. Possibly the same family as #1186.

Expected

sql.end() resolves once every connection is closed or known dead, as it does with a timeout.

Workaround

Always pass a timeout: await sql.end({ timeout: 5 }).

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