Skip to content

Commit 764c392

Browse files
committed
fix: preserve h2 queue on out-of-order completion
Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent b4c287b commit 764c392

3 files changed

Lines changed: 94 additions & 8 deletions

File tree

‎lib/dispatcher/client-h2.js‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,21 @@ function requeueUnsentRequest (client, request) {
152152
client[kQueue].splice(client[kPendingIdx] + 1, 0, request)
153153
}
154154

155+
function completeRequest (client, request, resetPendingIdx = false) {
156+
const index = client[kQueue].indexOf(request, client[kRunningIdx])
157+
158+
if (index === -1 || index >= client[kPendingIdx]) {
159+
return
160+
}
161+
162+
client[kQueue].splice(index, 1)
163+
client[kPendingIdx]--
164+
165+
if (resetPendingIdx) {
166+
client[kPendingIdx] = client[kRunningIdx]
167+
}
168+
}
169+
155170
function canRetryRequestAfterGoAway (request) {
156171
const { body } = request
157172

@@ -479,7 +494,9 @@ function onHttp2SessionClose () {
479494
const requests = client[kQueue].splice(client[kRunningIdx])
480495
for (let i = 0; i < requests.length; i++) {
481496
const request = requests[i]
482-
util.errorRequest(client, request, err)
497+
if (request != null) {
498+
util.errorRequest(client, request, err)
499+
}
483500
}
484501
}
485502
}
@@ -742,11 +759,7 @@ function writeH2 (client, request) {
742759
}
743760

744761
requestFinalized = true
745-
client[kQueue][client[kRunningIdx]++] = null
746-
747-
if (resetPendingIdx) {
748-
client[kPendingIdx] = client[kRunningIdx]
749-
}
762+
completeRequest(client, request, resetPendingIdx)
750763

751764
client[kResume]()
752765
}

‎lib/dispatcher/client.js‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,9 @@ class Client extends DispatcherBase {
395395
const requests = this[kQueue].splice(this[kPendingIdx])
396396
for (let i = 0; i < requests.length; i++) {
397397
const request = requests[i]
398-
util.errorRequest(this, request, err)
398+
if (request != null) {
399+
util.errorRequest(this, request, err)
400+
}
399401
}
400402

401403
const callback = () => {
@@ -434,7 +436,9 @@ function onError (client, err) {
434436

435437
for (let i = 0; i < requests.length; i++) {
436438
const request = requests[i]
437-
util.errorRequest(client, request, err)
439+
if (request != null) {
440+
util.errorRequest(client, request, err)
441+
}
438442
}
439443
assert(client[kSize] === 0)
440444
}

‎test/issue-5404.js‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
'use strict'
2+
3+
const { test } = require('node:test')
4+
const assert = require('node:assert')
5+
const { once } = require('node:events')
6+
const { createServer } = require('node:http2')
7+
8+
const { Client } = require('..')
9+
const { kQueue } = require('../lib/core/symbols')
10+
11+
// Regression test for https://github.com/nodejs/undici/issues/5404.
12+
// HTTP/2 streams can complete out of order. Completing the second stream first
13+
// must not clear the first request's queue slot; otherwise destroying the
14+
// client can lose or mis-error the still-running request.
15+
test('h2: out-of-order completion preserves running requests during destroy', async () => {
16+
const server = createServer()
17+
let firstStream
18+
19+
server.on('sessionError', () => {})
20+
server.on('stream', (stream, headers) => {
21+
switch (headers[':path']) {
22+
case '/first':
23+
firstStream = stream
24+
break
25+
case '/second':
26+
stream.respond({ ':status': 200 })
27+
stream.end('second')
28+
break
29+
case '/third':
30+
stream.respond({ ':status': 200 })
31+
stream.end('third')
32+
break
33+
default:
34+
stream.respond({ ':status': 404 })
35+
stream.end()
36+
}
37+
})
38+
39+
await once(server.listen(0), 'listening')
40+
41+
const client = new Client(`http://localhost:${server.address().port}`, {
42+
allowH2: true,
43+
useH2c: true,
44+
maxConcurrentStreams: 2
45+
})
46+
47+
try {
48+
const first = client.request({ path: '/first', method: 'GET' })
49+
const firstError = first.then(
50+
() => null,
51+
err => err
52+
)
53+
54+
const second = await client.request({ path: '/second', method: 'GET' })
55+
assert.strictEqual(await second.body.text(), 'second')
56+
57+
const third = await client.request({ path: '/third', method: 'GET' })
58+
assert.strictEqual(await third.body.text(), 'third')
59+
60+
assert.strictEqual(firstStream.destroyed, false)
61+
assert.deepStrictEqual(client[kQueue].map(request => request?.path), ['/first'])
62+
63+
await client.destroy(new Error('boom'))
64+
assert.strictEqual((await firstError).message, 'boom')
65+
} finally {
66+
await client.destroy().catch(() => {})
67+
server.close()
68+
}
69+
})

0 commit comments

Comments
 (0)