Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@ const makeCloseHandler =
if (incoming.errored) {
recordBodyBufferedBeforeDisconnect(incoming)
req[abortRequest](incoming.errored.toString())
} else if (!outgoing.writableFinished) {
} else if (
!outgoing.writableFinished ||
// An HTTP/2 response reports writableFinished once its stream is destroyed, including
// when the client resets the stream mid-response. `aborted` is only set in that case.
(incoming instanceof Http2ServerRequest && incoming.aborted)
) {
recordBodyBufferedBeforeDisconnect(incoming)
req[abortRequest]('Client connection prematurely closed.')
}
Expand Down
49 changes: 49 additions & 0 deletions test/listener.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { EventEmitter } from 'node:events'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import {
connect as http2Connect,
constants as http2Constants,
createServer as createHttp2Server,
} from 'node:http2'
import type { AddressInfo } from 'node:net'
import { Readable } from 'node:stream'
import { GlobalHeaders } from '../src/headers'
import { getRequestListener } from '../src/listener'
Expand Down Expand Up @@ -405,6 +411,49 @@ describe('Abort request', () => {
})
await expect(req).rejects.toThrow()
})

it.each(['stream', 'session'] as const)(
'should emit an abort event when an HTTP/2 client closes the %s',
async (target) => {
let resolveReady!: () => void
const ready = new Promise<void>((r) => {
resolveReady = r
})
let resolveAborted!: (req: Request) => void
const aborted = new Promise<Request>((r) => {
resolveAborted = r
})

const server = createHttp2Server(
getRequestListener(async (req: Request) => {
req.signal.addEventListener('abort', () => resolveAborted(req))
resolveReady()
await new Promise(() => {}) // never resolve
})
)
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r))
const { port } = server.address() as AddressInfo
const client = http2Connect(`http://127.0.0.1:${port}`)

try {
const stream = client.request({ ':path': '/abort' })
stream.on('error', () => {})
await withTimeout(ready, 'request did not reach the handler')

if (target === 'stream') {
stream.close(http2Constants.NGHTTP2_CANCEL)
} else {
client.destroy()
}

const req = await withTimeout(aborted, 'abort signal did not fire')
expect(req.signal.aborted).toBe(true)
} finally {
client.destroy()
await new Promise<void>((r) => server.close(() => r()))
}
}
)
})

describe('Abort request - error path', () => {
Expand Down