Skip to content

Commit 46b084a

Browse files
authored
fix(ClientRequest): correctly free HTTP parsers (#757)
1 parent 7513097 commit 46b084a

File tree

6 files changed

+94
-28
lines changed

6 files changed

+94
-28
lines changed

.github/workflows/ci.yml

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,21 @@ jobs:
1111
runs-on: macos-latest
1212
strategy:
1313
matrix:
14-
node: [18, 20]
14+
node: [20, 22]
1515
steps:
1616
- name: Checkout
17-
uses: actions/checkout@v3
18-
19-
- name: Set up Node.js
20-
uses: actions/setup-node@v3
21-
with:
22-
node-version: ${{ matrix.node }}
17+
uses: actions/checkout@v4
2318

2419
- name: Set up pnpm
2520
uses: pnpm/action-setup@v4
2621
with:
27-
version: 9.14.0
22+
version: 9.15.0
23+
24+
- name: Set up Node.js
25+
uses: actions/setup-node@v4
26+
with:
27+
node-version: ${{ matrix.node }}
28+
cache: 'pnpm'
2829

2930
- name: Install dependencies
3031
run: pnpm install
@@ -39,7 +40,7 @@ jobs:
3940
run: pnpm test:node
4041

4142
- name: Install Playwright browsers
42-
run: npx playwright install
43+
run: npx playwright install chromium --with-deps --only-shell
4344

4445
- name: Browser tests
4546
run: pnpm test:browser

.nvmrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v20
1+
v22

_http_common.d.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { Socket } from 'node:net'
2+
import type { IncomingMessage, OutgoingMessage } from 'node:http'
3+
14
declare var HTTPParser: {
25
new (): HTTPParser<number>
36
REQUEST: 0
@@ -18,26 +21,35 @@ declare var HTTPParser: {
1821
export interface HTTPParser<ParserType extends number> {
1922
new (): HTTPParser<ParserType>
2023

21-
[HTTPParser.kOnMessageBegin]: () => void
22-
[HTTPParser.kOnHeaders]: HeadersCallback
24+
[HTTPParser.kOnMessageBegin]: (() => void) | null
25+
[HTTPParser.kOnHeaders]: HeadersCallback | null
2326
[HTTPParser.kOnHeadersComplete]: ParserType extends 0
24-
? RequestHeadersCompleteCallback
25-
: ResponseHeadersCompleteCallback
26-
[HTTPParser.kOnBody]: (chunk: Buffer) => void
27-
[HTTPParser.kOnMessageComplete]: () => void
28-
[HTTPParser.kOnExecute]: () => void
29-
[HTTPParser.kOnTimeout]: () => void
27+
? RequestHeadersCompleteCallback | null
28+
: ResponseHeadersCompleteCallback | null
29+
[HTTPParser.kOnBody]: ((chunk: Buffer) => void) | null
30+
[HTTPParser.kOnMessageComplete]: (() => void) | null
31+
[HTTPParser.kOnExecute]: (() => void) | null
32+
[HTTPParser.kOnTimeout]: (() => void) | null
3033

3134
initialize(type: ParserType, asyncResource: object): void
3235
execute(buffer: Buffer): void
36+
_url: string
37+
_headers?: Array<unknown>
38+
_consumed?: boolean
39+
maxHeaderPairs: number
40+
socket?: Socket | null
41+
incoming?: IncomingMessage | null
42+
outgoing?: OutgoingMessage | null
43+
onIncoming?: (() => void) | null
44+
joinDuplicateHeaders?: unknown
3345
finish(): void
46+
unconsume(): void
47+
remove(): void
48+
close(): void
3449
free(): void
3550
}
3651

37-
export type HeadersCallback = (
38-
rawHeaders: Array<string>,
39-
url: string
40-
) => void
52+
export type HeadersCallback = (rawHeaders: Array<string>, url: string) => void
4153

4254
export type RequestHeadersCompleteCallback = (
4355
versionMajor: number,

src/interceptors/ClientRequest/MockHttpSocket.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { getRawFetchHeaders } from './utils/recordRawHeaders'
1919
import { FetchResponse } from '../../utils/fetchUtils'
2020
import { setRawRequest } from '../../getRawRequest'
2121
import { setRawRequestBodyStream } from '../../utils/node'
22+
import { freeParser } from './utils/parserUtils'
2223

2324
type HttpConnectionOptions = any
2425

@@ -136,7 +137,7 @@ export class MockHttpSocket extends MockSocket {
136137

137138
// Once the socket is finished, nothing can write to it
138139
// anymore. It has also flushed any buffered chunks.
139-
this.once('finish', () => this.requestParser.free())
140+
this.once('finish', () => freeParser(this.requestParser, this))
140141

141142
if (this.baseUrl.protocol === 'https:') {
142143
Reflect.set(this, 'encrypted', true)
@@ -146,7 +147,11 @@ export class MockHttpSocket extends MockSocket {
146147
Reflect.set(this, 'getProtocol', () => 'TLSv1.3')
147148
Reflect.set(this, 'getSession', () => undefined)
148149
Reflect.set(this, 'isSessionReused', () => false)
149-
Reflect.set(this, 'getCipher', () => ({ name: 'AES256-SHA', standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', version: 'TLSv1.3' }))
150+
Reflect.set(this, 'getCipher', () => ({
151+
name: 'AES256-SHA',
152+
standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA',
153+
version: 'TLSv1.3',
154+
}))
150155
}
151156
}
152157

@@ -165,7 +170,7 @@ export class MockHttpSocket extends MockSocket {
165170
// Destroy the response parser when the socket gets destroyed.
166171
// Normally, we should listen to the "close" event but it
167172
// can be suppressed by using the "emitClose: false" option.
168-
this.responseParser.free()
173+
freeParser(this.responseParser, this)
169174

170175
if (error) {
171176
this.emit('error', error)
@@ -259,7 +264,7 @@ export class MockHttpSocket extends MockSocket {
259264
'getProtocol',
260265
'getSession',
261266
'isSessionReused',
262-
'getCipher'
267+
'getCipher',
263268
]
264269

265270
tlsProperties.forEach((propertyName) => {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { Socket } from 'node:net'
2+
import { HTTPParser } from '_http_common'
3+
4+
/**
5+
* @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/_http_common.js#L180
6+
*/
7+
export function freeParser(parser: HTTPParser<any>, socket?: Socket): void {
8+
if (parser._consumed) {
9+
parser.unconsume()
10+
}
11+
12+
parser._headers = []
13+
parser._url = ''
14+
parser.socket = null
15+
parser.incoming = null
16+
parser.outgoing = null
17+
parser.maxHeaderPairs = 2000
18+
parser._consumed = false
19+
parser.onIncoming = null
20+
21+
parser[HTTPParser.kOnHeaders] = null
22+
parser[HTTPParser.kOnHeadersComplete] = null
23+
parser[HTTPParser.kOnMessageBegin] = null
24+
parser[HTTPParser.kOnMessageComplete] = null
25+
parser[HTTPParser.kOnBody] = null
26+
parser[HTTPParser.kOnExecute] = null
27+
parser[HTTPParser.kOnTimeout] = null
28+
29+
parser.remove()
30+
parser.free()
31+
32+
if (socket) {
33+
/**
34+
* @note Unassigning the socket's parser will fail this assertion
35+
* if there's still some data being processed on the socket:
36+
* @see https://github.com/nodejs/node/blob/4e1f39b678b37017ac9baa0971e3aeecd3b67b51/lib/_http_client.js#L613
37+
*/
38+
if (socket.destroyed) {
39+
// @ts-expect-error Node.js internals.
40+
socket.parser = null
41+
} else {
42+
socket.once('end', () => {
43+
// @ts-expect-error Node.js internals.
44+
socket.parser = null
45+
})
46+
}
47+
}
48+
}

src/interceptors/XMLHttpRequest/utils/getBodyByteLength.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ it('calculates body length from the FormData request body', async () => {
8888
body: formData,
8989
})
9090
)
91-
).resolves.toBe(127)
91+
).resolves.toBe(129)
9292
})
9393

9494
it('calculates body length from the ReadableStream request body', async () => {
@@ -149,7 +149,7 @@ it('calculates body length from the FormData response body', async () => {
149149
const formData = new FormData()
150150
formData.append('hello', 'world')
151151

152-
await expect(getBodyByteLength(new Response(formData))).resolves.toBe(127)
152+
await expect(getBodyByteLength(new Response(formData))).resolves.toBe(129)
153153
})
154154

155155
it('calculates body length from the ReadableStream response body', async () => {

0 commit comments

Comments
 (0)