Skip to content

Commit e663630

Browse files
usualomayusukebe
authored andcommitted
fix: request draining for early 413 responses (#329)
* fix: request draining for early 413 responses * fix: close HTTP/2 streams immediately in drainIncoming to avoid ReadableStream controller conflict on Node.js v20 * test: assert http2 early 413 closes without reset * test: cleanup early 413 upload timer * test: skip flaky early 413 check on windows
1 parent 04b98c4 commit e663630

4 files changed

Lines changed: 313 additions & 8 deletions

File tree

src/listener.ts

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { IncomingMessage, ServerResponse, OutgoingHttpHeaders } from 'node:http'
2-
import { Http2ServerRequest } from 'node:http2'
2+
import { Http2ServerRequest, constants as h2constants } from 'node:http2'
33
import type { Http2ServerResponse } from 'node:http2'
44
import type { Writable } from 'node:stream'
55
import type { IncomingMessageWithWrapBodyStream } from './request'
@@ -22,9 +22,68 @@ import {
2222
import { X_ALREADY_SENT } from './utils/response/constants'
2323

2424
const outgoingEnded = Symbol('outgoingEnded')
25+
const incomingDraining = Symbol('incomingDraining')
2526
type OutgoingHasOutgoingEnded = Http2ServerResponse & {
2627
[outgoingEnded]?: () => void
2728
}
29+
type IncomingHasDrainState = (IncomingMessage | Http2ServerRequest) & {
30+
[incomingDraining]?: boolean
31+
}
32+
33+
const DRAIN_TIMEOUT_MS = 500
34+
const MAX_DRAIN_BYTES = 64 * 1024 * 1024
35+
36+
const drainIncoming = (incoming: IncomingMessage | Http2ServerRequest): void => {
37+
const incomingWithDrainState = incoming as IncomingHasDrainState
38+
if (incoming.destroyed || incomingWithDrainState[incomingDraining]) {
39+
return
40+
}
41+
incomingWithDrainState[incomingDraining] = true
42+
43+
// HTTP/2: streams are multiplexed, so we can close immediately
44+
// without risking TCP RST racing the response.
45+
if (incoming instanceof Http2ServerRequest) {
46+
try {
47+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
48+
;(incoming as any).stream?.close?.(h2constants.NGHTTP2_NO_ERROR)
49+
} catch {
50+
// stream may already be closed
51+
}
52+
return
53+
}
54+
55+
let bytesRead = 0
56+
const cleanup = () => {
57+
clearTimeout(timer)
58+
incoming.off('data', onData)
59+
incoming.off('end', cleanup)
60+
incoming.off('error', cleanup)
61+
}
62+
63+
const forceClose = () => {
64+
cleanup()
65+
const socket = incoming.socket
66+
if (socket && !socket.destroyed) {
67+
socket.destroySoon()
68+
}
69+
}
70+
71+
const timer = setTimeout(forceClose, DRAIN_TIMEOUT_MS)
72+
timer.unref?.()
73+
74+
const onData = (chunk: Buffer) => {
75+
bytesRead += chunk.length
76+
if (bytesRead > MAX_DRAIN_BYTES) {
77+
forceClose()
78+
}
79+
}
80+
81+
incoming.on('data', onData)
82+
incoming.on('end', cleanup)
83+
incoming.on('error', cleanup)
84+
85+
incoming.resume()
86+
}
2887

2988
const handleRequestError = (): Response =>
3089
new Response(null, {
@@ -264,15 +323,21 @@ export const getRequestListener = (
264323
// and end is called at this point. At that point, nothing is done.
265324
if (!incomingEnded) {
266325
setTimeout(() => {
267-
incoming.destroy()
268-
// a Http2ServerResponse instance will not terminate without also calling outgoing.destroy()
269-
outgoing.destroy()
326+
drainIncoming(incoming)
270327
})
271328
}
272329
})
273330
}
274331
}
275332
}
333+
334+
// Drain incoming as soon as the response is flushed to the OS,
335+
// before the socket is closed, to prevent TCP RST racing the response.
336+
outgoing.on('finish', () => {
337+
if (!incomingEnded) {
338+
drainIncoming(incoming)
339+
}
340+
})
276341
}
277342

278343
// Detect if request was aborted.
@@ -294,7 +359,7 @@ export const getRequestListener = (
294359
// and end is called at this point. At that point, nothing is done.
295360
if (!incomingEnded) {
296361
setTimeout(() => {
297-
incoming.destroy()
362+
drainIncoming(incoming)
298363
})
299364
}
300365
})

test/app.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ app.post('/partially-consumed-and-cancelled', async (c) => {
5252
reader.cancel()
5353
return c.text('Partially consumed and cancelled')
5454
})
55+
app.post('/early-413', (c) => {
56+
if (!c.req.raw.body) {
57+
// force create new request object
58+
throw new Error('No body consumed')
59+
}
60+
return c.text('Payload Too Large', 413)
61+
})
5562
app.delete('/posts/:id', (c) => {
5663
return c.text(`DELETE ${c.req.param('id')}`)
5764
})

0 commit comments

Comments
 (0)