Skip to content

Pool permanently stalls after synchronous request validation errors #6054

Description

@justsl

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.cjs

The 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:

  1. Client dispatch constructs Request before adding it to the client queue.
  2. An invalid header causes the constructor to throw. DispatcherBase.dispatch calls handler.onError(err) and returns false.
  3. PoolBase dispatch treats that false as backpressure and sets the pool's per-client kNeedDrain flag.
  4. 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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions