current commit
260930.1 · feat: Added @perseveranza-pets/milo as alternative experimental HTTP parser (#5492)
'use strict'

const { tspl } = require('@matteo.collina/tspl')
const { test, after } = require('node:test')
const { EventEmitter } = require('node:events')
const { createServer } = require('node:http')
const net = require('node:net')
const {
  finished,
  PassThrough,
  Readable
} = require('node:stream')
const { promisify } = require('node:util')
const {
  kBusy,
  kPending,
  kRunning,
  kSize,
  kUrl
} = require('../lib/core/symbols')
const {
  Client,
  Pool,
  errors
} = require('..')

// RFC 9112 requires a space after the status code even when the reason phrase is empty.
const SWITCHING_PROTOCOLS = 'HTTP/1.1 101 Switching Protocols\r\n'

test('throws when connection is infinite', async (t) => {
  t = tspl(t, { plan: 2 })

  try {
    new Pool(null, { connections: 0 / 0 }) // eslint-disable-line
  } catch (e) {
    t.ok(e instanceof errors.InvalidArgumentError)
    t.strictEqual(e.message, 'invalid connections')
  }
})

test('throws when connections is negative', async (t) => {
  t = tspl(t, { plan: 2 })

  try {
    new Pool(null, { connections: -1 }) // eslint-disable-line no-new
  } catch (e) {
    t.ok(e instanceof errors.InvalidArgumentError)
    t.strictEqual(e.message, 'invalid connections')
  }
})

test('throws when connection is not number', async (t) => {
  t = tspl(t, { plan: 2 })

  try {
    new Pool(null, { connections: true }) // eslint-disable-line no-new
  } catch (e) {
    t.ok(e instanceof errors.InvalidArgumentError)
    t.strictEqual(e.message, 'invalid connections')
  }
})

test('throws when factory is not a function', async (t) => {
  t = tspl(t, { plan: 2 })

  try {
    new Pool(null, { factory: '' }) // eslint-disable-line no-new
  } catch (e) {
    t.ok(e instanceof errors.InvalidArgumentError)
    t.strictEqual(e.message, 'factory must be a function.')
  }
})

test('does not throw when connect is a function', async (t) => {
  t = tspl(t, { plan: 1 })

  t.doesNotThrow(() => new Pool('http://localhost', { connect: () => {} }))
})

test('passes socketPath to custom connect function', async (t) => {
  t = tspl(t, { plan: 2 })

  const connectError = new Error('custom connect error')
  const socketPath = '/var/run/test.sock'
  const pool = new Pool('http://localhost', {
    socketPath,
    connect (opts, cb) {
      t.strictEqual(opts.socketPath, socketPath)
      cb(connectError, null)
    }
  })
  after(() => pool.close())

  pool.request({
    path: '/',
    method: 'GET'
  }, (err) => {
    t.strictEqual(err, connectError)
  })

  await t.completed
})

test('connect/disconnect event(s)', async (t) => {
  const clients = 2

  t = tspl(t, { plan: clients * 6 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.writeHead(200, {
      Connection: 'keep-alive',
      'Keep-Alive': 'timeout=1s'
    })
    res.end('ok')
  })
  after(() => server.close())

  server.listen(0, () => {
    const pool = new Pool(`http://localhost:${server.address().port}`, {
      connections: clients,
      keepAliveTimeoutThreshold: 100
    })
    after(() => pool.close())

    pool.on('connect', (origin, [pool, client]) => {
      t.strictEqual(client instanceof Client, true)
    })
    pool.on('disconnect', (origin, [pool, client], error) => {
      t.ok(client instanceof Client)
      t.ok(error instanceof errors.InformationalError)
      t.strictEqual(error.code, 'UND_ERR_INFO')
      t.strictEqual(error.message, 'socket idle timeout')
    })

    for (let i = 0; i < clients; i++) {
      pool.request({
        path: '/',
        method: 'GET'
      }, (err, { headers, body }) => {
        t.ifError(err)
        body.resume()
      })
    }
  })

  await t.completed
})

test('basic get', async (t) => {
  t = tspl(t, { plan: 14 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.strictEqual('/', req.url)
    t.strictEqual('GET', req.method)
    res.setHeader('content-type', 'text/plain')
    res.end('hello')
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    t.strictEqual(client[kUrl].origin, `http://localhost:${server.address().port}`)

    client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => {
      t.ifError(err)
      t.strictEqual(statusCode, 200)
      t.strictEqual(headers['content-type'], 'text/plain')
      const bufs = []
      body.on('data', (buf) => {
        bufs.push(buf)
      })
      body.on('end', () => {
        t.strictEqual('hello', Buffer.concat(bufs).toString('utf8'))
      })
    })

    t.strictEqual(client.destroyed, false)
    t.strictEqual(client.closed, false)
    client.close((err) => {
      t.ifError(err)
      t.strictEqual(client.destroyed, true)
      client.destroy((err) => {
        t.ifError(err)
        client.close((err) => {
          t.ok(err instanceof errors.ClientDestroyedError)
        })
      })
    })
    t.strictEqual(client.closed, true)
  })

  await t.completed
})

test('URL as arg', async (t) => {
  t = tspl(t, { plan: 9 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.strictEqual('/', req.url)
    t.strictEqual('GET', req.method)
    res.setHeader('content-type', 'text/plain')
    res.end('hello')
  })
  after(() => server.close())

  server.listen(0, async () => {
    const url = new URL('http://localhost')
    url.port = server.address().port
    const client = new Pool(url)
    after(() => client.destroy())

    client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => {
      t.ifError(err)
      t.strictEqual(statusCode, 200)
      t.strictEqual(headers['content-type'], 'text/plain')
      const bufs = []
      body.on('data', (buf) => {
        bufs.push(buf)
      })
      body.on('end', () => {
        t.strictEqual('hello', Buffer.concat(bufs).toString('utf8'))
      })
    })

    client.close((err) => {
      t.ifError(err)
      client.destroy((err) => {
        t.ifError(err)
        client.close((err) => {
          t.ok(err instanceof errors.ClientDestroyedError)
        })
      })
    })
  })

  await t.completed
})

test('basic get error async/await', async (t) => {
  t = tspl(t, { plan: 2 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.destroy()
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    await client.request({ path: '/', method: 'GET' })
      .catch((err) => {
        t.ok(err)
      })

    await client.destroy()

    await client.close().catch((err) => {
      t.ok(err instanceof errors.ClientDestroyedError)
    })
  })

  await t.completed
})

test('basic get with async/await', async (t) => {
  t = tspl(t, { plan: 4 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.strictEqual('/', req.url)
    t.strictEqual('GET', req.method)
    res.setHeader('content-type', 'text/plain')
    res.end('hello')
  })
  after(() => server.close())

  await promisify(server.listen.bind(server))(0)
  const client = new Pool(`http://localhost:${server.address().port}`)
  after(() => client.destroy())

  const { statusCode, headers, body } = await client.request({ path: '/', method: 'GET' })
  t.strictEqual(statusCode, 200)
  t.strictEqual(headers['content-type'], 'text/plain')

  body.resume()
  await promisify(finished)(body)

  await client.close()
  await client.destroy()
})

test('stream get async/await', async (t) => {
  t = tspl(t, { plan: 4 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.strictEqual('/', req.url)
    t.strictEqual('GET', req.method)
    res.setHeader('content-type', 'text/plain')
    res.end('hello')
  })
  after(() => server.close())

  await promisify(server.listen.bind(server))(0)
  const client = new Pool(`http://localhost:${server.address().port}`)
  after(() => client.destroy())

  await client.stream({ path: '/', method: 'GET' }, ({ statusCode, headers }) => {
    t.strictEqual(statusCode, 200)
    t.strictEqual(headers['content-type'], 'text/plain')
    return new PassThrough()
  })

  await t.completed
})

test('stream get error async/await', async (t) => {
  t = tspl(t, { plan: 1 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.destroy()
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    await client.stream({ path: '/', method: 'GET' }, () => {

    })
      .catch((err) => {
        t.ok(err)
      })
  })

  await t.completed
})

test('pipeline get', async (t) => {
  t = tspl(t, { plan: 5 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.strictEqual('/', req.url)
    t.strictEqual('GET', req.method)
    res.setHeader('content-type', 'text/plain')
    res.end('hello')
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    const bufs = []
    client.pipeline({ path: '/', method: 'GET' }, ({ statusCode, headers, body }) => {
      t.strictEqual(statusCode, 200)
      t.strictEqual(headers['content-type'], 'text/plain')
      return body
    })
      .end()
      .on('data', (buf) => {
        bufs.push(buf)
      })
      .on('end', () => {
        t.strictEqual('hello', Buffer.concat(bufs).toString('utf8'))
      })
  })

  await t.completed
})

test('backpressure algorithm', async (t) => {
  t = tspl(t, { plan: 12 })

  const seen = []
  let total = 0

  let writeMore = true

  class FakeClient extends EventEmitter {
    constructor () {
      super()

      this.id = total++
    }

    dispatch (req, handler) {
      seen.push({ req, client: this, id: this.id })
      return writeMore
    }
  }

  const noopHandler = {
    onResponseError (_controller, err) {
      throw err
    }
  }

  const pool = new Pool('http://notahost', {
    factory: () => new FakeClient()
  })

  pool.dispatch({}, noopHandler)
  pool.dispatch({}, noopHandler)

  const d1 = seen.shift() // d1 = c0
  t.strictEqual(d1.id, 0)
  const d2 = seen.shift() // d2 = c0
  t.strictEqual(d2.id, 0)

  t.strictEqual(d1.id, d2.id)

  writeMore = false

  pool.dispatch({}, noopHandler) // d3 = c0

  pool.dispatch({}, noopHandler) // d4 = c1

  const d3 = seen.shift()
  t.strictEqual(d3.id, 0)
  const d4 = seen.shift()
  t.strictEqual(d4.id, 1)

  t.strictEqual(d3.id, d2.id)
  t.notEqual(d3.id, d4.id)

  writeMore = true

  d4.client.emit('drain', new URL('http://notahost'), [d4.client])

  pool.dispatch({}, noopHandler) // d5 = c1

  d3.client.emit('drain', new URL('http://notahost'), [d3.client])

  pool.dispatch({}, noopHandler) // d6 = c0

  const d5 = seen.shift()
  t.strictEqual(d5.id, 1)
  const d6 = seen.shift()
  t.strictEqual(d6.id, 0)

  t.strictEqual(d5.id, d4.id)
  t.strictEqual(d3.id, d6.id)

  t.strictEqual(total, 3)

  t.end()
})

test('busy', async (t) => {
  t = tspl(t, { plan: 8 * 16 + 2 + 1 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.strictEqual('/', req.url)
    t.strictEqual('GET', req.method)
    res.setHeader('content-type', 'text/plain')
    res.end('hello')
  })
  after(() => server.close())

  const connections = 2

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`, {
      connections,
      pipelining: 2
    })
    client.on('drain', () => {
      t.ok(true, 'pass')
    })
    client.on('connect', () => {
      t.ok(true, 'pass')
    })
    after(() => client.destroy())

    for (let n = 1; n <= 8; ++n) {
      client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => {
        t.ifError(err)
        t.strictEqual(statusCode, 200)
        t.strictEqual(headers['content-type'], 'text/plain')
        const bufs = []
        body.on('data', (buf) => {
          bufs.push(buf)
        })
        body.on('end', () => {
          t.strictEqual('hello', Buffer.concat(bufs).toString('utf8'))
        })
      })
      t.strictEqual(client[kPending], n)
      t.strictEqual(client[kBusy], n > 1)
      t.strictEqual(client[kSize], n)
      t.strictEqual(client[kRunning], 0)

      t.strictEqual(client.stats.connected, 0)
      t.strictEqual(client.stats.free, 0)
      t.strictEqual(client.stats.queued, Math.max(n - connections, 0))
      t.strictEqual(client.stats.pending, n)
      t.strictEqual(client.stats.size, n)
      t.strictEqual(client.stats.running, 0)
    }
  })

  await t.completed
})

test('invalid pool dispatch options', async (t) => {
  t = tspl(t, { plan: 2 })
  const pool = new Pool('http://notahost')
  t.throws(() => pool.dispatch({}), errors.InvalidArgumentError, 'throws on invalid handler')
  t.throws(() => pool.dispatch({}, {}), errors.InvalidArgumentError, 'throws on invalid handler')
})

test('pool upgrade promise', async (t) => {
  t = tspl(t, { plan: 2 })

  const server = net.createServer({ joinDuplicateHeaders: true }, (c) => {
    c.on('data', (d) => {
      c.write(SWITCHING_PROTOCOLS)
      c.write('hello: world\r\n')
      c.write('connection: upgrade\r\n')
      c.write('upgrade: websocket\r\n')
      c.write('\r\n')
      c.write('Body')
    })

    c.on('end', () => {
      c.end()
    })
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`)
    after(() => client.close())

    const { headers, socket } = await client.upgrade({
      path: '/',
      method: 'GET',
      protocol: 'Websocket'
    })

    let recvData = ''
    socket.on('data', (d) => {
      recvData += d
    })

    socket.on('close', () => {
      t.strictEqual(recvData.toString(), 'Body')
    })

    t.deepStrictEqual(headers, {
      hello: 'world',
      connection: 'upgrade',
      upgrade: 'websocket'
    })
    socket.end()
  })

  await t.completed
})

test('pool connect', async (t) => {
  t = tspl(t, { plan: 1 })

  const server = createServer({ joinDuplicateHeaders: true }, (c) => {
    t.fail()
  })
  server.on('connect', (req, socket, firstBodyChunk) => {
    socket.write('HTTP/1.1 200 Connection established\r\n\r\n')

    let data = firstBodyChunk.toString()
    socket.on('data', (buf) => {
      data += buf.toString()
    })

    socket.on('end', () => {
      socket.end(data)
    })
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`)
    after(() => client.close())

    const { socket } = await client.connect({
      path: '/'
    })

    let recvData = ''
    socket.on('data', (d) => {
      recvData += d
    })

    socket.on('end', () => {
      t.strictEqual(recvData.toString(), 'Body')
    })

    socket.write('Body')
    socket.end()
  })

  await t.completed
})

test('pool connect with clientTtl specified', async (t) => {
  t = tspl(t, { plan: 3 })

  const server = createServer({ joinDuplicateHeaders: true }, t.fail)
  server.on('connect', (req, socket, firstBodyChunk) => {
    socket.write('HTTP/1.1 200 Connection established\r\n\r\n')

    let data = firstBodyChunk.toString()
    socket.on('data', (buf) => {
      data += buf.toString()
    })

    socket.on('end', () => {
      socket.end(data)
    })
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`, {
      clientTtl: 10
    })

    const { socket } = await client.connect({
      path: '/'
    })

    t.strictEqual(socket.closed, false, 'client not closed yet')

    let recvData = ''
    socket.on('data', (d) => {
      recvData += d
    })

    socket.on('end', () => {
      t.strictEqual(recvData.toString(), 'Body')
    })

    socket.write('Body')
    await new Promise((resolve, reject) => socket.end((e) => e ? reject(e) : resolve()))

    t.strictEqual(socket.closed, false, 'client not closed yet')

    await new Promise(resolve => setTimeout(resolve, 10))
    t.strictEqual(socket.closed, true, 'client closed after ttl')
  })

  await t.completed
})

test('pool replaces stale client when connections limit is reached', async (t) => {
  t = tspl(t, { plan: 2 })

  const server = createServer((req, res) => {
    res.end('ok')
  })
  after(() => server.close())

  await new Promise(resolve => server.listen(0, resolve))

  const client = new Pool(`http://localhost:${server.address().port}`, {
    connections: 1,
    clientTtl: 1
  })
  after(() => client.destroy())

  async function request () {
    const { statusCode, body } = await client.request({
      path: '/',
      method: 'GET'
    })
    await body.text()
    return statusCode
  }

  t.strictEqual(await request(), 200)
  await new Promise(resolve => setTimeout(resolve, 20))

  const statusCode = await Promise.race([
    request(),
    new Promise((_resolve, reject) => {
      setTimeout(() => reject(new Error('second request hung')), 1000)
    })
  ])
  t.strictEqual(statusCode, 200)
})

test('pool does not report backpressure after removing a stale client when another client is available', async (t) => {
  t = tspl(t, { plan: 4 })

  let created = 0
  let acceptedBySecondClient = 0

  class FakeClient extends EventEmitter {
    constructor () {
      super()
      this.id = ++created
      this.closed = false
      this.destroyed = false
    }

    dispatch () {
      if (this.id === 1) {
        this.emit('connect', new URL('http://notahost'), [this])
        return false
      }

      acceptedBySecondClient++
      return true
    }

    close (cb) {
      this.closed = true
      if (cb) {
        cb()
      }
    }

    destroy () {
      this.destroyed = true
    }
  }

  const pool = new Pool('http://notahost', {
    connections: 2,
    clientTtl: 1,
    factory: () => new FakeClient()
  })
  after(() => pool.destroy())

  const handler = {
    onResponseError (_controller, err) {
      throw err
    }
  }

  t.strictEqual(pool.dispatch({ path: '/', method: 'GET' }, handler), true)
  t.strictEqual(created, 2)

  await new Promise(resolve => setTimeout(resolve, 10))

  t.strictEqual(pool.dispatch({ path: '/', method: 'GET' }, handler), true)
  t.strictEqual(acceptedBySecondClient, 1)
})

test('pool dispatch', async (t) => {
  t = tspl(t, { plan: 2 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end('asd')
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`)
    after(() => client.close())

    let buf = ''
    client.dispatch({
      path: '/',
      method: 'GET'
    }, {
      onRequestStart () {
      },
      onResponseStart (_controller, statusCode, headers) {
        t.strictEqual(statusCode, 200)
      },
      onResponseData (_controller, chunk) {
        buf += chunk
      },
      onResponseEnd () {
        t.strictEqual(buf, 'asd')
      },
      onResponseError () {
      }
    })
  })

  await t.completed
})

test('pool pipeline args validation', async (t) => {
  t = tspl(t, { plan: 2 })

  const client = new Pool('http://localhost:5000')

  const ret = client.pipeline(null, () => {})
  ret.on('error', (err) => {
    t.ok(/opts/.test(err.message))
    t.ok(err instanceof errors.InvalidArgumentError)
  })

  await t.completed
})

test('300 requests succeed', async (t) => {
  t = tspl(t, { plan: 300 * 3 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end('asd')
  })
  after(() => server.close())

  server.listen(0, () => {
    const client = new Pool(`http://localhost:${server.address().port}`, {
      connections: 1
    })
    after(() => client.destroy())

    for (let n = 0; n < 300; ++n) {
      client.request({
        path: '/',
        method: 'GET'
      }, (err, data) => {
        t.ifError(err)
        data.body.on('data', (chunk) => {
          t.strictEqual(chunk.toString(), 'asd')
        }).on('end', () => {
          t.ok(true, 'pass')
        })
      })
    }
  })

  await t.completed
})

test('pool connect error', async (t) => {
  t = tspl(t, { plan: 1 })

  const server = createServer({ joinDuplicateHeaders: true }, (c) => {
    t.fail()
  })
  server.on('connect', (req, socket, firstBodyChunk) => {
    socket.destroy()
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`)
    after(() => client.close())

    try {
      await client.connect({
        path: '/'
      })
    } catch (err) {
      t.ok(err)
    }
  })

  await t.completed
})

test('pool upgrade error', async (t) => {
  t = tspl(t, { plan: 1 })

  const server = net.createServer({ joinDuplicateHeaders: true }, (c) => {
    c.on('data', (d) => {
      c.write(SWITCHING_PROTOCOLS)
      c.write('hello: world\r\n')
      c.write('connection: upgrade\r\n')
      c.write('\r\n')
      c.write('Body')
    })
    c.on('error', () => {
      // Whether we get an error, end or close is undefined.
      // Ignore error.
    })
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`)
    after(() => client.close())

    try {
      await client.upgrade({
        path: '/',
        method: 'GET',
        protocol: 'Websocket'
      })
    } catch (err) {
      t.ok(err)
    }
  })

  await t.completed
})

test('pool dispatch error', async (t) => {
  t = tspl(t, { plan: 3 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end('asd')
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`, {
      connections: 1,
      pipelining: 1
    })
    after(() => client.close())

    client.dispatch({
      path: '/',
      method: 'GET'
    }, {
      onRequestStart () {
      },
      onResponseStart (_controller, statusCode, headers) {
        t.strictEqual(statusCode, 200)
      },
      onResponseData (_controller, chunk) {
      },
      onResponseEnd () {
        t.ok(true, 'pass')
      },
      onResponseError () {
      }
    })

    client.dispatch({
      path: '/',
      method: 'GET',
      headers: {
        'transfer-encoding': 'fail'
      }
    }, {
      onRequestStart () {
        t.fail()
      },
      onResponseStart (_controller, statusCode, headers) {
        t.fail()
      },
      onResponseData (_controller, chunk) {
        t.fail()
      },
      onResponseError (_controller, err) {
        t.strictEqual(err.code, 'UND_ERR_INVALID_ARG')
      }
    })
  })

  await t.completed
})

test('pool request abort in queue', async (t) => {
  t = tspl(t, { plan: 3 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end('asd')
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`, {
      connections: 1,
      pipelining: 1
    })
    after(() => client.close())

    client.dispatch({
      path: '/',
      method: 'GET'
    }, {
      onRequestStart () {
      },
      onResponseStart (_controller, statusCode, headers) {
        t.strictEqual(statusCode, 200)
      },
      onResponseData (_controller, chunk) {
      },
      onResponseEnd () {
        t.ok(true, 'pass')
      },
      onResponseError () {
      }
    })

    const signal = new EventEmitter()
    client.request({
      path: '/',
      method: 'GET',
      signal
    }, (err) => {
      t.strictEqual(err.code, 'UND_ERR_ABORTED')
    })
    signal.emit('abort')
  })

  await t.completed
})

test('pool stream abort in queue', async (t) => {
  t = tspl(t, { plan: 3 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end('asd')
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`, {
      connections: 1,
      pipelining: 1
    })
    after(() => client.close())

    client.dispatch({
      path: '/',
      method: 'GET'
    }, {
      onRequestStart () {
      },
      onResponseStart (_controller, statusCode, headers) {
        t.strictEqual(statusCode, 200)
      },
      onResponseData (_controller, chunk) {
      },
      onResponseEnd () {
        t.ok(true, 'pass')
      },
      onResponseError () {
      }
    })

    const signal = new EventEmitter()
    client.stream({
      path: '/',
      method: 'GET',
      signal
    }, ({ body }) => body, (err) => {
      t.strictEqual(err.code, 'UND_ERR_ABORTED')
    })
    signal.emit('abort')
  })

  await t.completed
})

test('pool pipeline abort in queue', async (t) => {
  t = tspl(t, { plan: 3 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end('asd')
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`, {
      connections: 1,
      pipelining: 1
    })
    after(() => client.close())

    client.dispatch({
      path: '/',
      method: 'GET'
    }, {
      onRequestStart () {
      },
      onResponseStart (_controller, statusCode, headers) {
        t.strictEqual(statusCode, 200)
      },
      onResponseData (_controller, chunk) {
      },
      onResponseEnd () {
        t.ok(true, 'pass')
      },
      onResponseError () {
      }
    })

    const signal = new EventEmitter()
    client.pipeline({
      path: '/',
      method: 'GET',
      signal
    }, ({ body }) => body).end().on('error', (err) => {
      t.strictEqual(err.code, 'UND_ERR_ABORTED')
    })
    signal.emit('abort')
  })

  await t.completed
})

test('pool stream constructor error destroy body', async (t) => {
  t = tspl(t, { plan: 4 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end('asd')
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`, {
      connections: 1,
      pipelining: 1
    })
    after(() => client.close())

    {
      const body = new Readable({
        read () {
        }
      })
      client.stream({
        path: '/',
        method: 'GET',
        body,
        headers: {
          'transfer-encoding': 'fail'
        }
      }, () => {
        t.fail()
      }, (err) => {
        t.strictEqual(err.code, 'UND_ERR_INVALID_ARG')
        t.strictEqual(body.destroyed, true)
      })
    }

    {
      const body = new Readable({
        read () {
        }
      })
      client.stream({
        path: '/',
        method: 'CONNECT',
        body
      }, () => {
        t.fail()
      }, (err) => {
        t.strictEqual(err.code, 'UND_ERR_INVALID_ARG')
        t.strictEqual(body.destroyed, true)
      })
    }
  })

  await t.completed
})

test('pool request constructor error destroy body', async (t) => {
  t = tspl(t, { plan: 4 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end('asd')
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`, {
      connections: 1,
      pipelining: 1
    })
    after(() => client.close())

    {
      const body = new Readable({
        read () {
        }
      })
      client.request({
        path: '/',
        method: 'GET',
        body,
        headers: {
          'transfer-encoding': 'fail'
        }
      }, (err) => {
        t.strictEqual(err.code, 'UND_ERR_INVALID_ARG')
        t.strictEqual(body.destroyed, true)
      })
    }

    {
      const body = new Readable({
        read () {
        }
      })
      client.request({
        path: '/',
        method: 'CONNECT',
        body
      }, (err) => {
        t.strictEqual(err.code, 'UND_ERR_INVALID_ARG')
        t.strictEqual(body.destroyed, true)
      })
    }
  })

  await t.completed
})

test('pool close waits for all requests', async (t) => {
  t = tspl(t, { plan: 5 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end('asd')
  })
  after(() => server.close())

  server.listen(0, () => {
    const client = new Pool(`http://localhost:${server.address().port}`, {
      connections: 1,
      pipelining: 1
    })
    after(() => client.destroy())

    client.request({
      path: '/',
      method: 'GET'
    }, (err) => {
      t.ifError(err)
    })

    client.request({
      path: '/',
      method: 'GET'
    }, (err) => {
      t.ifError(err)
    })

    client.close(() => {
      t.ok(true, 'pass')
    })

    client.close(() => {
      t.ok(true, 'pass')
    })

    client.request({
      path: '/',
      method: 'GET'
    }, (err) => {
      t.ok(err instanceof errors.ClientClosedError)
    })
  })

  await t.completed
})

test('pool destroyed', async (t) => {
  t = tspl(t, { plan: 1 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end('asd')
  })
  after(() => server.close())

  server.listen(0, () => {
    const client = new Pool(`http://localhost:${server.address().port}`, {
      connections: 1,
      pipelining: 1
    })
    after(() => client.destroy())

    client.destroy()
    client.request({
      path: '/',
      method: 'GET'
    }, (err) => {
      t.ok(err instanceof errors.ClientDestroyedError)
    })
  })

  await t.completed
})

test('pool destroy fails queued requests', async (t) => {
  t = tspl(t, { plan: 6 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    res.end('asd')
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`, {
      connections: 1,
      pipelining: 1
    })
    after(() => client.destroy())

    const _err = new Error()
    client.request({
      path: '/',
      method: 'GET'
    }, (err) => {
      t.strictEqual(err, _err)
    })

    client.request({
      path: '/',
      method: 'GET'
    }, (err) => {
      t.strictEqual(err, _err)
    })

    t.strictEqual(client.destroyed, false)
    client.destroy(_err, () => {
      t.ok(true, 'pass')
    })
    t.strictEqual(client.destroyed, true)

    client.request({
      path: '/',
      method: 'GET'
    }, (err) => {
      t.ok(err instanceof errors.ClientDestroyedError)
    })
  })
  await t.completed
})

test('stats', async (t) => {
  t = tspl(t, { plan: 11 })

  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.strictEqual('/', req.url)
    t.strictEqual('GET', req.method)
    res.setHeader('content-type', 'text/plain')
    res.end('hello')
  })
  after(() => server.close())

  server.listen(0, async () => {
    const client = new Pool(`http://localhost:${server.address().port}`)
    after(() => client.destroy())

    t.strictEqual(client[kUrl].origin, `http://localhost:${server.address().port}`)

    client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => {
      t.ifError(err)
      t.strictEqual(statusCode, 200)
      t.strictEqual(client.stats.connected, 1)
      t.strictEqual(client.stats.free, 0)
      t.strictEqual(client.stats.pending, 0)
      t.strictEqual(client.stats.queued, 0)
      t.strictEqual(client.stats.running, 1)
      t.strictEqual(client.stats.size, 1)
    })
  })

  await t.completed
})

test('pool does not dispatch to clientTtl-evicted client when stale drain fires', async (t) => {
  t = tspl(t, { plan: 5 })

  let created = 0
  const clients = []

  class FakeClient extends EventEmitter {
    constructor () {
      super()
      this.id = ++created
      this.closed = false
      this.destroyed = false
      clients.push(this)
    }

    dispatch (opts, handler) {
      if (this.closed) {
        throw new errors.ClientClosedError()
      }
      if (this.id === 1) {
        // Emit connect on first dispatch so the pool records a TTL timestamp
        if (!this.ttl) {
          this.emit('connect', new URL('http://notahost'), [this])
        }
        return true
      }
      // Simulate C2 mid-TLS-handshake: accepted the request but at capacity
      return false
    }

    close (cb) {
      this.closed = true
      if (cb) cb()
    }

    destroy () {
      this.destroyed = true
    }
  }

  const pool = new Pool('http://notahost', {
    connections: 1,
    clientTtl: 1,
    factory: () => new FakeClient()
  })
  after(() => pool.destroy().catch(() => {}))

  const handler = { onResponseError (_controller, err) { throw err } }

  // First dispatch: creates C1, C1 emits connect (TTL timestamp recorded), returns true
  pool.dispatch({ path: '/', method: 'GET' }, handler)
  t.strictEqual(created, 1, 'C1 created')

  // Wait for clientTtl to expire
  await new Promise(resolve => setTimeout(resolve, 10))

  // Second dispatch: C1's TTL expired → evicted (closed=true), C2 created.
  // C2.dispatch returns false (mid-handshake) → C2[kNeedDrain]=true → pool at capacity.
  pool.dispatch({ path: '/', method: 'GET' }, handler)
  t.strictEqual(created, 2, 'C2 created after C1 evicted by TTL')

  const c1 = clients[0]
  t.strictEqual(c1.closed, true, 'C1 closed after TTL eviction')

  // Third dispatch: C2 is at capacity and connections limit reached → item queued in pool
  pool.dispatch({ path: '/', method: 'GET' }, handler)
  t.strictEqual(pool.stats.queued, 1, 'request sits in pool queue while C2 is connecting')

  // Simulate C1 completing an in-flight response — its stale drain listener fires.
  // Without the fix kOnDrain(C1) dispatches the queued item to the closed C1,
  // throwing ClientClosedError. With the fix it returns early for closed clients.
  c1.emit('drain', new URL('http://notahost'), [c1])
  t.ok(true, 'no ClientClosedError when evicted client emits drain')
})