Skip to content

Commit 10a0315

Browse files
committed
Removed clients with unrecoverable errors from the Pool (#4088)
Signed-off-by: Matteo Collina <[email protected]>
1 parent 6139ed2 commit 10a0315

File tree

4 files changed

+459
-0
lines changed

4 files changed

+459
-0
lines changed

lib/pool.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@ class Pool extends PoolBase {
7373
? { ...options.interceptors }
7474
: undefined
7575
this[kFactory] = factory
76+
77+
this.on('connectionError', (origin, targets, error) => {
78+
// If a connection error occurs, we remove the client from the pool,
79+
// and emit a connectionError event. They will not be re-used.
80+
// Fixes https://github.com/nodejs/undici/issues/3895
81+
for (const target of targets) {
82+
// Do not use kRemoveClient here, as it will close the client,
83+
// but the client cannot be closed in this state.
84+
const idx = this[kClients].indexOf(target)
85+
if (idx !== -1) {
86+
this[kClients].splice(idx, 1)
87+
}
88+
}
89+
})
7690
}
7791

7892
[kGetDispatcher] () {
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
'use strict'
2+
3+
const tap = require('tap')
4+
const { Pool } = require('..')
5+
const { createServer } = require('node:http')
6+
const { kClients } = require('../lib/pool-base')
7+
8+
// This test verifies that clients are properly removed from the pool when they encounter connection errors,
9+
// which is the fix implemented for issue #3895 (memory leak with connection errors)
10+
tap.test('Pool client count does not grow on repeated connection errors', async (t) => {
11+
// Setup a pool pointing to a non-existent server
12+
const pool = new Pool('http://localhost:1', {
13+
connections: 10,
14+
connectTimeout: 100, // Short timeout to speed up the test
15+
bodyTimeout: 100,
16+
headersTimeout: 100
17+
})
18+
19+
t.teardown(async () => {
20+
await pool.close()
21+
})
22+
23+
const clientCounts = []
24+
25+
// Track initial client count
26+
clientCounts.push(pool[kClients].length)
27+
28+
// Make several requests that will fail with connection errors
29+
const requests = 5
30+
for (let i = 0; i < requests; i++) {
31+
try {
32+
await pool.request({
33+
path: `/${i}`,
34+
method: 'GET'
35+
})
36+
t.fail('Request should have failed with a connection error')
37+
} catch (err) {
38+
// We expect connection errors, but the error might be wrapped
39+
t.ok(
40+
err.code === 'ECONNREFUSED' ||
41+
err.cause?.code === 'ECONNREFUSED' ||
42+
err.code === 'UND_ERR_CONNECT',
43+
`Expected connection error but got: ${err.message} (${err.code})`
44+
)
45+
}
46+
47+
// Track client count after each request
48+
clientCounts.push(pool[kClients].length)
49+
50+
// Small delay to allow for client cleanup
51+
await new Promise(resolve => setTimeout(resolve, 10))
52+
}
53+
54+
// The key test: verify that client count doesn't increase monotonically,
55+
// which would indicate the memory leak that was fixed
56+
const maxCount = Math.max(...clientCounts)
57+
t.ok(
58+
clientCounts[clientCounts.length - 1] <= maxCount,
59+
`Client count should not increase continuously. Counts: ${clientCounts.join(', ')}`
60+
)
61+
62+
// Ensure the last two counts are similar (stabilized)
63+
const lastCount = clientCounts[clientCounts.length - 1]
64+
const secondLastCount = clientCounts[clientCounts.length - 2]
65+
66+
t.ok(
67+
Math.abs(lastCount - secondLastCount) <= 1,
68+
`Client count should stabilize. Last counts: ${secondLastCount}, ${lastCount}`
69+
)
70+
71+
// Additional verification: make many more requests to check for significant growth
72+
const moreRequests = 10
73+
const startCount = pool[kClients].length
74+
75+
for (let i = 0; i < moreRequests; i++) {
76+
try {
77+
await pool.request({
78+
path: `/more-${i}`,
79+
method: 'GET'
80+
})
81+
} catch (err) {
82+
// Expected error
83+
}
84+
85+
// Small delay to allow for client cleanup
86+
await new Promise(resolve => setTimeout(resolve, 10))
87+
}
88+
89+
const endCount = pool[kClients].length
90+
91+
// The maximum tolerable growth - some growth may occur due to timing issues,
92+
// but it should be limited and not proportional to the number of requests
93+
const maxGrowth = 3
94+
95+
t.ok(
96+
endCount - startCount <= maxGrowth,
97+
`Client count should not grow significantly after many failed requests. Start: ${startCount}, End: ${endCount}`
98+
)
99+
})
100+
101+
// This test specifically verifies the fix in pool-base.js for connectionError event
102+
tap.test('Pool clients are removed on connectionError event', async (t) => {
103+
// Create a server we'll use to track connection events
104+
const server = createServer((req, res) => {
105+
res.writeHead(200, { 'Content-Type': 'text/plain' })
106+
res.end('ok')
107+
})
108+
109+
await new Promise(resolve => server.listen(0, resolve))
110+
const port = server.address().port
111+
112+
t.teardown(async () => {
113+
await new Promise(resolve => server.close(resolve))
114+
})
115+
116+
const pool = new Pool(`http://localhost:${port}`, {
117+
connections: 3 // Small pool to make testing easier
118+
})
119+
120+
t.teardown(async () => {
121+
await pool.close()
122+
})
123+
124+
// Make an initial successful request to create a client
125+
await pool.request({
126+
path: '/',
127+
method: 'GET'
128+
})
129+
130+
// Save the initial number of clients
131+
const initialCount = pool[kClients].length
132+
t.ok(initialCount > 0, 'Should have at least one client after a successful request')
133+
134+
// Manually trigger a connectionError on all clients
135+
for (const client of [...pool[kClients]]) {
136+
client.emit('connectionError', 'origin', [client], new Error('Simulated connection error'))
137+
}
138+
139+
// Allow some time for the event to be processed
140+
await new Promise(resolve => setTimeout(resolve, 50))
141+
142+
// After the fix, all clients should be removed when they emit a connectionError
143+
t.equal(
144+
pool[kClients].length,
145+
0,
146+
'All clients should be removed from pool after connectionError events'
147+
)
148+
149+
// Make another request to verify the pool can create new clients
150+
await pool.request({
151+
path: '/after-error',
152+
method: 'GET'
153+
})
154+
155+
// Verify new clients were created
156+
t.ok(
157+
pool[kClients].length > 0,
158+
'Pool should create new clients after previous ones were removed'
159+
)
160+
})

0 commit comments

Comments
 (0)