Repository navigation
Description
Bug Description
A synchronous request validation failure can leave a free Client permanently marked as needing drain inside a Pool. Subsequent valid requests stay queued even though no request is running.
With connections: 1, one rejected header value is enough to reproduce this. With a larger finite limit, repeated validation failures can exhaust the available client slots. This reproduces with the stock Pool and request() APIs; no custom dispatcher, interceptors, or diagnostics subscribers are involved.
Reproduction
Install undici@7.30.1, save the following as repro.cjs, and run:
npm install --save-exact undici@7.30.1
node --test repro.cjsThe test asserts the expected behavior and currently fails.
'use strict'
const { test } = require('node:test')
const assert = require('node:assert/strict')
const { createServer } = require('node:http')
const { once } = require('node:events')
const { setImmediate: nextTurn } = require('node:timers/promises')
const { Pool } = require('undici')
test('Pool remains usable after a request validation error', { timeout: 5000 }, async (t) => {
let received = 0
const server = createServer((req, res) => {
received++
res.end('ok')
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const pool = new Pool(`http://127.0.0.1:${server.address().port}`, { connections: 1 })
t.after(async () => {
await pool.destroy()
server.closeAllConnections()
await new Promise(resolve => server.close(resolve))
})
// Warm the only connection and fully consume its response.
const warm = await pool.request({ path: '/', method: 'GET' })
assert.equal(await warm.body.text(), 'ok')
await nextTurn()
assert.equal(pool.stats.free, 1)
await assert.rejects(
pool.request({ path: '/', method: 'GET', headers: { 'x-invalid': '\n' } }),
{ code: 'UND_ERR_INVALID_ARG' }
)
const next = pool.request({ path: '/', method: 'GET' })
.then(async ({ body }) => body.text())
let timer
const outcome = await Promise.race([
next,
new Promise(resolve => { timer = setTimeout(() => resolve('TIMEOUT'), 500) })
]).finally(() => clearTimeout(timer))
const { connected, free, running, pending, queued, size } = pool.stats
console.log({ outcome, received, connected, free, running, pending, queued, size })
assert.equal(outcome, 'ok')
assert.equal(received, 2)
})Expected Behavior
The invalid request rejects with UND_ERR_INVALID_ARG, is not sent to the server, and does not consume a pool slot. The following valid request completes with ok.
Actual Behavior
The invalid request rejects as expected, but the following valid request never reaches the server. The reproduction prints:
{
outcome: 'TIMEOUT',
received: 1,
connected: 1,
free: 0,
running: 0,
pending: 1,
queued: 1,
size: 1
}
Only the warm-up request reaches the server. Removing the assert.rejects(...) block makes the same test pass.
Environment
- OS: Windows, x64
- Node.js: v24.18.0
- Undici: 7.30.1, installed from npm
Additional context
The apparent sequence in the v7.30.1 source is:
- Client dispatch constructs
Requestbefore adding it to the client queue. - An invalid header causes the constructor to throw. DispatcherBase.dispatch calls
handler.onError(err)and returnsfalse. - PoolBase dispatch treats that
falseas backpressure and sets the pool's per-clientkNeedDrainflag. - No request was enqueued, so there is no corresponding drain to make that slot available again.
It looks like a validation failure before enqueueing should be distinguished from actual backpressure, or the pool should otherwise restore the slot's readiness.
Could a fix also be backported to the 7.x branch?
Activity
deps: batch soaked Wails, D-Bus, and undici updates (#2300)