Skip to content

Commit 72de8bb

Browse files
committed
http2: ensure session.request() does not throw synchronously in stream close callbacks
1 parent 71ca439 commit 72de8bb

2 files changed

Lines changed: 33 additions & 37 deletions

File tree

lib/internal/http2/core.js

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,30 +1297,24 @@ function closeSession(session, code, error) {
12971297
session.setTimeout(0);
12981298
session.removeAllListeners('timeout');
12991299

1300+
// Destroy any pending and open streams
1301+
if (state.pendingStreams.size > 0 || state.streams.size > 0) {
1302+
const cancel = new ERR_HTTP2_STREAM_CANCEL(error);
1303+
state.pendingStreams.forEach((stream) => stream.destroy(cancel));
1304+
state.streams.forEach((stream) => stream.destroy(error));
1305+
}
1306+
13001307
// Disassociate from the socket and server.
13011308
const socket = session[kSocket];
13021309
const handle = session[kHandle];
13031310

1304-
// Destroy the handle *before* destroying streams. When the socket is already
1305-
// closed the handle's ondone callback (finishSessionClose) is invoked
1306-
// synchronously from handle.destroy(), which schedules the session 'close'
1307-
// event via process.nextTick. Destroying the streams afterwards ensures their
1308-
// 'close' events are queued on the nextTick queue *after* the session 'close'
1309-
// event, so user code receives the session-level signal before any stream
1310-
// callbacks observe session.closed/session.destroyed as true.
1311+
// Destroy the handle if it exists at this point.
13111312
if (handle !== undefined) {
13121313
handle.ondone = finishSessionClose.bind(null, session, error);
13131314
handle.destroy(code, socket.destroyed);
13141315
} else {
13151316
finishSessionClose(session, error);
13161317
}
1317-
1318-
// Destroy any pending and open streams
1319-
if (state.pendingStreams.size > 0 || state.streams.size > 0) {
1320-
const cancel = new ERR_HTTP2_STREAM_CANCEL(error);
1321-
state.pendingStreams.forEach((stream) => stream.destroy(cancel));
1322-
state.streams.forEach((stream) => stream.destroy(error));
1323-
}
13241318
}
13251319

13261320
// Upon creation, the Http2Session takes ownership of the socket. The session

test/parallel/test-http2-session-close-before-stream-close.js

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
'use strict';
22

33
// Regression test: when the server abruptly destroys the underlying socket,
4-
// the client session 'close' event must fire before any stream 'close'
5-
// callback can observe session.closed/session.destroyed as true.
6-
// See https://github.com/nodejs/node/issues/<issue>
4+
// session.request() must not throw synchronously inside a stream 'close'
5+
// callback. Instead it should return a stream that emits 'error' async with
6+
// ERR_HTTP2_INVALID_SESSION, giving session lifecycle handlers a chance to
7+
// clear their cached session reference before any retry occurs.
78

89
const common = require('../common');
910
if (!common.hasCrypto)
@@ -36,33 +37,34 @@ server.on('stream', (stream, headers) => {
3637
server.listen(0, common.mustCall(() => {
3738
const session = http2.connect(`http://localhost:${server.address().port}`);
3839

39-
let sessionCloseFired = false;
40-
41-
session.on('close', common.mustCall(() => {
42-
sessionCloseFired = true;
43-
server.close();
44-
}));
45-
46-
// We accept that an error may or may not fire, but it must fire before
47-
// any stream close callback sees session.destroyed.
40+
session.on('close', common.mustCall(() => server.close()));
4841
session.on('error', () => {});
4942

5043
const req = session.request({ ':path': '/close' });
5144
req.resume();
5245
req.on('response', () => {});
46+
req.on('error', () => {}); // socket may emit ECONNRESET on abrupt close
5347

54-
// The stream 'close' event must not observe the session as destroyed
55-
// before the session 'close' event has fired.
5648
req.on('close', common.mustCall(() => {
57-
// If the session is already destroyed, the session 'close' event must
58-
// have already been emitted (sessionCloseFired === true).
59-
if (session.destroyed) {
60-
assert.strictEqual(
61-
sessionCloseFired,
62-
true,
63-
'session "close" event must fire before stream "close" callback ' +
64-
'observes session.destroyed === true',
65-
);
49+
if (!session.destroyed) return;
50+
51+
// session.request() must NOT throw synchronously even though the session
52+
// is already destroyed. It should return a stream that errors async.
53+
let threw = false;
54+
let req2;
55+
try {
56+
req2 = session.request({ ':path': '/again' });
57+
} catch {
58+
threw = true;
6659
}
60+
61+
assert.strictEqual(threw, false,
62+
'session.request() must not throw synchronously inside a stream close callback');
63+
64+
// The returned stream should asynchronously emit ERR_HTTP2_INVALID_SESSION
65+
req2.on('error', common.mustCall((err) => {
66+
assert.strictEqual(err.code, 'ERR_HTTP2_INVALID_SESSION');
67+
}));
68+
req2.resume();
6769
}));
6870
}));

0 commit comments

Comments
 (0)