Skip to content

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

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 }).

No activity

Activity on this issue will appear here.

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