From 71136cec7e11b341f15e80a47f29dee4d1e0d695 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 4 Feb 2026 14:38:15 +0000 Subject: [PATCH] Fix clientTtl cleanup race --- lib/dispatcher/agent.js | 4 +++- lib/dispatcher/pool-base.js | 14 ++++++++---- test/issue-4806.js | 44 +++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 test/issue-4806.js diff --git a/lib/dispatcher/agent.js b/lib/dispatcher/agent.js index 781bc8cf0d1..939b0ad55d3 100644 --- a/lib/dispatcher/agent.js +++ b/lib/dispatcher/agent.js @@ -92,7 +92,9 @@ class Agent extends DispatcherBase { if (connected) result.count -= 1 if (result.count <= 0) { this[kClients].delete(key) - result.dispatcher.close() + if (!result.dispatcher.destroyed) { + result.dispatcher.close() + } } this[kOrigins].delete(key) } diff --git a/lib/dispatcher/pool-base.js b/lib/dispatcher/pool-base.js index 4de14f920e8..6c1f2388766 100644 --- a/lib/dispatcher/pool-base.js +++ b/lib/dispatcher/pool-base.js @@ -48,9 +48,12 @@ class PoolBase extends DispatcherBase { } if (this[kClosedResolve] && queue.isEmpty()) { - const closeAll = new Array(this[kClients].length) + const closeAll = [] for (let i = 0; i < this[kClients].length; i++) { - closeAll[i] = this[kClients][i].close() + const client = this[kClients][i] + if (!client.destroyed) { + closeAll.push(client.close()) + } } return Promise.all(closeAll) .then(this[kClosedResolve]) @@ -119,9 +122,12 @@ class PoolBase extends DispatcherBase { [kClose] () { if (this[kQueue].isEmpty()) { - const closeAll = new Array(this[kClients].length) + const closeAll = [] for (let i = 0; i < this[kClients].length; i++) { - closeAll[i] = this[kClients][i].close() + const client = this[kClients][i] + if (!client.destroyed) { + closeAll.push(client.close()) + } } return Promise.all(closeAll) } else { diff --git a/test/issue-4806.js b/test/issue-4806.js new file mode 100644 index 00000000000..f13405b4aaf --- /dev/null +++ b/test/issue-4806.js @@ -0,0 +1,44 @@ +'use strict' + +const { test, after } = require('node:test') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { tspl } = require('@matteo.collina/tspl') + +const { Agent, request } = require('..') + +// https://github.com/nodejs/undici/issues/4806 +test('Agent clientTtl cleanup does not trigger unhandled rejections', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.end('ok') + }) + + after(() => server.close()) + + server.listen(0, async () => { + const agent = new Agent({ clientTtl: 10 }) + after(async () => agent.close()) + + const onUnhandled = (err) => t.fail(err) + process.once('unhandledRejection', onUnhandled) + after(() => process.removeListener('unhandledRejection', onUnhandled)) + + const origin = `http://localhost:${server.address().port}` + + const res1 = await request(origin, { dispatcher: agent }) + t.strictEqual(res1.statusCode, 200) + + await new Promise(resolve => setTimeout(resolve, 20)) + + const res2 = await request(origin, { dispatcher: agent }) + t.strictEqual(res2.statusCode, 200) + res2.body.resume() + await once(res2.body, 'end') + + await new Promise(resolve => setTimeout(resolve, 20)) + }) + + await t.completed +})