From 30dfe3f507a7f97f1ee3cb9f8e21ccee089f2e5d Mon Sep 17 00:00:00 2001 From: DijieDeng <43828578+DijieDeng@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:55:55 +0800 Subject: [PATCH] fix(pg-pool): honor connectionTimeoutMillis and prevent connection leaks/hangs The connection timeout in newClient() had two flaws that caused pool.connect() to hang indefinitely and leak connections under high concurrency: 1. The timeout callback only tore down the connection when client.connection existed OR !client.isConnected(). When neither condition held (e.g. during a slow DNS lookup), the timer fired but did nothing, so pool.connect() hung forever (brianc/node-postgres#3197). 2. The client.connect() success path never checked timeoutHit, so a connection that finished establishing after the deadline was handed back to the caller anyway and kept in the pool, leaking a connection on the Postgres side and eventually exhausting the pool (brianc/node-postgres#3543). 3. client.connection.stream.destroy() left zombie connections on the Postgres side; connection.end() closes them cleanly. The timeout now always rejects the checkout at the deadline (except for the native-client exemption), removes the client from the pool, pulses the queue, and ends the connection cleanly. The success path discards any client that connects after the timeout. Refs: brianc/node-postgres#3197, brianc/node-postgres#3543, brianc/node-postgres#2338, brianc/node-postgres#2243 --- packages/pg-pool/index.js | 31 +++++++++++++++------ packages/pg-pool/test/connection-timeout.js | 29 +++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/packages/pg-pool/index.js b/packages/pg-pool/index.js index ab514fa88..91356d41a 100644 --- a/packages/pg-pool/index.js +++ b/packages/pg-pool/index.js @@ -249,14 +249,24 @@ class Pool extends EventEmitter { let timeoutHit = false if (this.options.connectionTimeoutMillis) { tid = setTimeout(() => { + // the native client reports itself connected before its connect callback fires + if (!client.connection && client.isConnected()) { + return + } + + this.log('ending client due to timeout') + timeoutHit = true + this._clients = this._clients.filter((c) => c !== client) + if (!pendingItem.timedOut) { + pendingItem.timedOut = true + pendingItem.callback(new Error('Connection terminated due to connection timeout'), undefined, NOOP) + } + this._pulseQueue() + client.removeAllListeners('error') + client.on('error', () => {}) if (client.connection) { - this.log('ending client due to timeout') - timeoutHit = true - client.connection.stream.destroy() - } else if (!client.isConnected()) { - this.log('ending client due to timeout') - timeoutHit = true - // force kill the node driver, and let libpq do its teardown + client.connection.end() + } else { client.end() } }, this.options.connectionTimeoutMillis) @@ -276,12 +286,17 @@ class Pool extends EventEmitter { err = new Error('Connection terminated due to connection timeout', { cause: err }) } - // this client won’t be released, so move on immediately + // this client won't be released, so move on immediately this._pulseQueue() if (!pendingItem.timedOut) { pendingItem.callback(err, undefined, NOOP) } + } else if (timeoutHit) { + this.log('client connected after timeout, discarding') + client.removeAllListeners('error') + client.on('error', () => {}) + client.end() } else { this.log('new client connected') diff --git a/packages/pg-pool/test/connection-timeout.js b/packages/pg-pool/test/connection-timeout.js index c4fd1832b..c4ae87dd7 100644 --- a/packages/pg-pool/test/connection-timeout.js +++ b/packages/pg-pool/test/connection-timeout.js @@ -227,6 +227,35 @@ describe('connection timeout', () => { }) }) + it('does not retain a connection that establishes after the timeout (#3543)', (done) => { + const Client = require('pg').Client + const orgConnect = Client.prototype.connect + + // Force the first connection to finish establishing only *after* the + // connection timeout has already fired (200ms > 100ms). + let first = true + Client.prototype.connect = function (cb) { + if (first) { + first = false + return setTimeout(() => orgConnect.call(this, cb), 200) + } + return orgConnect.call(this, cb) + } + + const pool = new Pool({ connectionTimeoutMillis: 100, max: 1 }) + pool.connect((err, client) => { + Client.prototype.connect = orgConnect + // The checkout must fail with a timeout rather than silently resolving + // ~200ms later, and the timed-out connection must not be retained. + expect(err).to.be.an(Error) + expect(err.message).to.contain('timeout') + expect(client).to.be(undefined) + expect(pool.totalCount).to.equal(0) + expect(pool.idleCount).to.equal(0) + pool.end(done) + }) + }) + it('should connect if timeout is passed, but native client in connected state', (done) => { const Client = require('pg').native.Client