Bug Description
Since undici 8.8.0, reusing an idle keep-alive socket can stall for up to ~500ms before the
request is written to the wire. The request is created, queued, and then simply sits there.
This was discovered using a pool with a single connection.
I think cause is scheduleIdleSocketValidation() in lib/dispatcher/client-h1.js, which was changed
from an unref'd setTimeout(…, 0) to an unref'd setImmediate().
Used / Tested Environment
|
|
| undici |
8.9.0 (also reproduces on 8.8.0) |
| last good |
8.7.0 |
| Node.js |
v24.18.0 |
| Platform |
macOS (darwin 25.5.0, arm64) |
Reproduction
Standalone reproduction script:
import assert from 'node:assert'
import http from 'node:http'
import { test } from 'node:test'
import { Pool } from 'undici'
const REQUESTS = 6
const SERVER_DELAY_MS = 10
const MAX_TOTAL_MS = 400
test('reusing an idle keep-alive socket must not stall', async (t) => {
const server = http.createServer((req, res) => {
setTimeout(() => {
res.writeHead(200, { 'content-length': 2 })
res.end('ok')
}, SERVER_DELAY_MS)
})
await new Promise((resolve) => server.listen(0, resolve))
const pool = new Pool(`http://127.0.0.1:${server.address().port}`, { connections: 1 })
t.after(async () => {
await pool.close()
server.close()
})
const durations = []
const start = Date.now()
for (let i = 0; i < REQUESTS; i++) {
const requestStart = Date.now()
const res = await pool.request({ path: '/', method: 'GET' })
await res.body.text()
durations.push(Date.now() - requestStart)
}
const total = Date.now() - start
t.diagnostic(`per-request: ${durations.map((d) => `${d}ms`).join(', ')}`)
t.diagnostic(`total: ${total}ms`)
assert.ok(
total < MAX_TOTAL_MS,
`${REQUESTS} sequential requests took ${total}ms (per-request: ${durations.join('ms, ')}ms). ` +
`The server replies in ${SERVER_DELAY_MS}ms, so this should be well under ${MAX_TOTAL_MS}ms.`
)
})
Expected Behavior
reusing an idle keep-alive socket must not stall (84.42725ms)
per-request: 20ms, 13ms, 13ms, 12ms, 13ms, 13ms
total: 84ms
The server takes 10ms to respond, so every request should be ~10–20ms - and on 8.7.0 every one is.
On 8.9.0, reuses of the keep-alive socket cost hundreds of milliseconds each.
Actual Behavior
reusing an idle keep-alive socket must not stall (1482.400041ms)
per-request: 20ms, 458ms, 34ms, 51ms, 450ms, 464ms
total: 1477ms
AssertionError [ERR_ASSERTION]: 6 sequential requests took 1477ms
(per-request: 20ms, 458ms, 34ms, 51ms, 450ms, 464ms).
The server replies in 10ms, so this should be well under 400ms.
Bug Description
Since undici 8.8.0, reusing an idle keep-alive socket can stall for up to ~500ms before the
request is written to the wire. The request is created, queued, and then simply sits there.
This was discovered using a pool with a single connection.
I think cause is
scheduleIdleSocketValidation()inlib/dispatcher/client-h1.js, which was changedfrom an unref'd
setTimeout(…, 0)to an unref'dsetImmediate().Used / Tested Environment
Reproduction
Standalone reproduction script:
Expected Behavior
The server takes 10ms to respond, so every request should be ~10–20ms - and on 8.7.0 every one is.
On 8.9.0, reuses of the keep-alive socket cost hundreds of milliseconds each.
Actual Behavior
The server replies in 10ms, so this should be well under 400ms.