@ 261107.5
current
'use strict'

const { test } = require('node:test')
const { tspl } = require('@matteo.collina/tspl')

const { RequestHandler } = require('../lib/api/api-request')

// Regression test for https://github.com/nodejs/undici/issues/5356
//
// When a request is aborted while its body is being consumed (e.g.
// `body.json()`), a chunk flushed afterwards (for example asynchronously by the
// decompress interceptor) reached the already torn-down response stream and
// crashed synchronously with "Cannot read properties of null (reading 'push')"
// instead of the consume promise simply rejecting with the AbortError.
//
// Root cause: the abort-signal listener destroyed the response stream in place
// but, unlike `onResponseError`, left `this.res` set, so the late chunk slipped
// past the `!this.res` guard in onResponseData. The fix nulls `this.res` in the
// abort listener, so the guard drops the chunk at the handler.
test('a late chunk after an aborted consume is dropped, not pushed into the destroyed stream', { timeout: 30000 }, async (t) => {
  t = tspl(t, { plan: 2 })

  const ac = new AbortController()

  let body = null
  const handler = new RequestHandler(
    { method: 'GET', path: '/', signal: ac.signal },
    (err, data) => {
      if (err == null && data != null) {
        body = data.body
      }
    }
  )

  const controller = {
    resume () {},
    pause () {},
    abort () {}
  }

  handler.onRequestStart(controller, null)
  handler.onResponseStart(controller, 200, { 'content-type': 'application/json' }, 'OK')

  // Start consuming the body, then abort while it is still in flight. The abort
  // listener destroys `res` (the body) and nulls `handler.res`.
  const consumed = body.json()
  ac.abort()

  // Wait for the destroy to tear the consume down (consume.body becomes null).
  // Listen for 'close' directly: the stream emits 'error' first (the abort
  // reason, already handled by the consume), and events.once would reject on it.
  await new Promise(resolve => body.once('close', resolve))

  // A late chunk - e.g. an asynchronous decompressor flush after teardown - must
  // be dropped because `handler.res` is null, not pushed into the torn-down stream.
  t.doesNotThrow(() => {
    handler.onResponseData(controller, Buffer.from('{"late":true}'))
  })

  await t.rejects(consumed, (err) => err.name === 'AbortError')

  await t.completed
})