Skip to content

Commit 2d29ebc

Browse files
authored
bug fix for #424 with test (#426)
1 parent a1ffd4d commit 2d29ebc

File tree

2 files changed

+127
-0
lines changed

2 files changed

+127
-0
lines changed

index.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use strict'
22

3+
const http2 = require('node:http2')
34
const fp = require('fastify-plugin')
45
const { LruMap } = require('toad-cache')
56
const querystring = require('fast-querystring')
@@ -24,6 +25,8 @@ const {
2425
BadGatewayError
2526
} = require('./lib/errors')
2627

28+
const { NGHTTP2_CANCEL } = http2.constants
29+
2730
const fastifyReplyFrom = fp(function from (fastify, opts, next) {
2831
const contentTypesToEncode = new Set([
2932
'application/json',
@@ -206,6 +209,13 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
206209
onError(this, { error: new BadGatewayError() })
207210
this.request.log.warn(err, 'response has invalid status code')
208211
}
212+
if (this.request.raw.aborted && res.stream) {
213+
// the request could have been canceled before we got a response from the target
214+
// forward this to the upstream server and close the stream to prevent leaks
215+
res.stream.close(NGHTTP2_CANCEL)
216+
// no need to send a reply for aborted requests or call the onResponse callback
217+
return
218+
}
209219
if (onResponse) {
210220
onResponse(this.request, this, res)
211221
} else {
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
'use strict'
2+
3+
const t = require('node:test')
4+
const Fastify = require('fastify')
5+
const From = require('..')
6+
const fs = require('node:fs')
7+
const path = require('node:path')
8+
const http2 = require('node:http2')
9+
const { once } = require('events')
10+
const certs = {
11+
key: fs.readFileSync(path.join(__dirname, 'fixtures', 'fastify.key')),
12+
cert: fs.readFileSync(path.join(__dirname, 'fixtures', 'fastify.cert'))
13+
}
14+
const { HTTP2_HEADER_STATUS, HTTP2_HEADER_PATH } = http2.constants
15+
16+
function makeRequest (client, counter) {
17+
return new Promise((resolve, reject) => {
18+
const controller = new AbortController()
19+
const signal = controller.signal
20+
const cancelRequestEarly = counter % 2 === 0 // cancel early every other request
21+
let responseCounter = 0
22+
23+
const clientStream = client.request({ [HTTP2_HEADER_PATH]: '/' }, { signal })
24+
25+
clientStream.end()
26+
27+
clientStream.on('data', chunk => {
28+
const s = chunk.toString()
29+
// Sometimes we just get NGHTTP2_ENHANCE_YOUR_CALM internal server errors
30+
if (s.startsWith('{"statusCode":500')) reject(new Error('got internal server error'))
31+
else responseCounter++
32+
})
33+
34+
clientStream.on('error', err => {
35+
if (err instanceof Error && err.name === 'AbortError') {
36+
if (responseCounter === 0 && !cancelRequestEarly) {
37+
// if we didn´t cancel early we should have received at least one response from the target
38+
// if not, this indicated the stream resource leak
39+
reject(new Error('no response'))
40+
} else resolve()
41+
} else reject(err instanceof Error ? err : new Error(JSON.stringify(err)))
42+
})
43+
44+
clientStream.on('end', () => { resolve() })
45+
46+
setTimeout(() => { controller.abort() }, cancelRequestEarly ? 20 : 200)
47+
})
48+
}
49+
50+
const httpsOptions = {
51+
...certs,
52+
settings: {
53+
maxConcurrentStreams: 10, // lower the default so we can reproduce the problem quicker
54+
}
55+
}
56+
57+
t.test('http2 -> http2', async (t) => {
58+
const instance = Fastify({
59+
http2: true,
60+
https: httpsOptions
61+
})
62+
63+
t.after(() => instance.close())
64+
65+
const target = http2.createSecureServer(httpsOptions)
66+
67+
target.on('stream', (stream, _headers, _flags) => {
68+
let counter = 0
69+
let headerSent = false
70+
71+
// deliberately delay sending the headers
72+
const sendData = () => {
73+
if (!headerSent) {
74+
stream.respond({ [HTTP2_HEADER_STATUS]: 200, })
75+
headerSent = true
76+
}
77+
stream.write(counter + '\n')
78+
counter = counter + 1
79+
}
80+
81+
const intervalId = setInterval(sendData, 50)
82+
83+
// ignore write after end errors
84+
stream.on('error', _err => { })
85+
86+
stream.on('close', () => { clearInterval(intervalId) })
87+
})
88+
89+
instance.get('/', (_request, reply) => {
90+
reply.from()
91+
})
92+
93+
t.after(() => target.close())
94+
95+
target.listen()
96+
await once(target, 'listening')
97+
98+
instance.register(From, {
99+
base: `https://localhost:${target.address().port}`,
100+
http2: true,
101+
rejectUnauthorized: false
102+
})
103+
104+
const url = await instance.listen({ port: 0 })
105+
106+
const client = http2.connect(url, {
107+
rejectUnauthorized: false,
108+
})
109+
110+
// see https://github.com/fastify/fastify-reply-from/issues/424
111+
// without the bug fix this will fail after about 15 requests
112+
for (let i = 0; i < 30; i++) { await makeRequest(client, i) }
113+
114+
client.close()
115+
instance.close()
116+
target.close()
117+
})

0 commit comments

Comments
 (0)