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 }).
Summary
After the connection closes while a query of a
sql.begin()transaction is in flight,sql.begin()rejects withCONNECTION_CLOSED(correct), but a latersql.end()called withouttimeoutnever 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.9from 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.Output on 3.4.9:
The open fixes do not cover this path
I applied the
src/connection.jschanges of the two related open PRs to a copy of 3.4.9 and re-ran the same script:nextWritesql.end()sql.end({ timeout: 2 })So #1209 removes the stray exception (#1208) and #1142 fixes the non-transaction
ECONNRESETcase, but the hang remains when the close happens insidebegin(). My reading, not verified in the source beyond the experiment above:begin()issues its ownROLLBACKafter the in-flight query fails, on a connection whose socket is already gone, and that query is never settled, soend()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 atimeout.Workaround
Always pass a timeout:
await sql.end({ timeout: 5 }).