From 14c11fb777aa7125059d0540c1fc03d5f471d091 Mon Sep 17 00:00:00 2001 From: Affan Momin Date: Sun, 4 Oct 2026 18:27:24 +0530 Subject: [PATCH] fix(listener): abort the request signal when an HTTP/2 client resets the stream --- src/listener.ts | 7 ++++++- test/listener.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/listener.ts b/src/listener.ts index cb8076d..f48f8c3 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -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.') } diff --git a/test/listener.test.ts b/test/listener.test.ts index 14a7690..1dd17d0 100644 --- a/test/listener.test.ts +++ b/test/listener.test.ts @@ -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' @@ -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((r) => { + resolveReady = r + }) + let resolveAborted!: (req: Request) => void + const aborted = new Promise((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((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((r) => server.close(() => r())) + } + } + ) }) describe('Abort request - error path', () => {