Skip to content

Commit 9891606

Browse files
mcollinamarco-ippolito
authored andcommitted
http2: emit session close before stream close
Signed-off-by: Matteo Collina <hello@matteocollina.com> PR-URL: #63414 Fixes: #63412 Reviewed-By: Tim Perry <pimterry@gmail.com> Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com> Reviewed-By: Stephen Belanger <admin@stephenbelanger.com>
1 parent 33d005b commit 9891606

4 files changed

Lines changed: 110 additions & 26 deletions

File tree

doc/api/http2.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,10 +1109,12 @@ creates and returns an `Http2Stream` instance that can be used to send an
11091109
HTTP/2 request to the connected server.
11101110

11111111
When a `ClientHttp2Session` is first created, the socket may not yet be
1112-
connected. if `clienthttp2session.request()` is called during this time, the
1112+
connected. If `clienthttp2session.request()` is called during this time, the
11131113
actual request will be deferred until the socket is ready to go.
1114-
If the `session` is closed before the actual request be executed, an
1115-
`ERR_HTTP2_GOAWAY_SESSION` is thrown.
1114+
1115+
If the session becomes unavailable before the request can be created, the
1116+
returned stream will emit `ERR_HTTP2_GOAWAY_SESSION` or
1117+
`ERR_HTTP2_INVALID_SESSION` asynchronously.
11161118

11171119
This method is only available if `http2session.type` is equal to
11181120
`http2.constants.NGHTTP2_SESSION_CLIENT`.

lib/internal/http2/core.js

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,10 @@ function requestOnConnect(headersList, options) {
838838
}
839839
}
840840

841+
function requestOnError(error) {
842+
this.destroy(error);
843+
}
844+
841845
// Validates that priority options are correct, specifically:
842846
// 1. options.weight must be a number
843847
// 2. options.parent must be a positive number
@@ -1153,7 +1157,7 @@ function setupHandle(socket, type, options) {
11531157
process.nextTick(emit, this, 'connect', this, socket);
11541158
}
11551159

1156-
// Emits a close event followed by an error event if err is truthy. Used
1160+
// Emits an error event followed by a close event if err is truthy. Used
11571161
// by Http2Session.prototype.destroy()
11581162
function emitClose(self, error) {
11591163
if (error)
@@ -1220,17 +1224,16 @@ function closeSession(session, code, error) {
12201224
session.setTimeout(0);
12211225
session.removeAllListeners('timeout');
12221226

1227+
const socket = session[kSocket];
1228+
const handle = session[kHandle];
1229+
12231230
// Destroy any pending and open streams
12241231
if (state.pendingStreams.size > 0 || state.streams.size > 0) {
12251232
const cancel = new ERR_HTTP2_STREAM_CANCEL(error);
12261233
state.pendingStreams.forEach((stream) => stream.destroy(cancel));
12271234
state.streams.forEach((stream) => stream.destroy(error));
12281235
}
12291236

1230-
// Disassociate from the socket and server.
1231-
const socket = session[kSocket];
1232-
const handle = session[kHandle];
1233-
12341237
// Destroy the handle if it exists at this point.
12351238
if (handle !== undefined) {
12361239
handle.ondone = finishSessionClose.bind(null, session, error);
@@ -1800,11 +1803,15 @@ class ClientHttp2Session extends Http2Session {
18001803
request(headersParam, options) {
18011804
debugSessionObj(this, 'initiating request');
18021805

1803-
if (this.destroyed)
1804-
throw new ERR_HTTP2_INVALID_SESSION();
1805-
1806-
if (this.closed)
1807-
throw new ERR_HTTP2_GOAWAY_SESSION();
1806+
// Keep argument validation synchronous, but defer session-state failures
1807+
// to the returned stream so request retries from stream callbacks do not
1808+
// throw before session lifecycle handlers run.
1809+
let requestError;
1810+
if (this.destroyed) {
1811+
requestError = new ERR_HTTP2_INVALID_SESSION();
1812+
} else if (this.closed) {
1813+
requestError = new ERR_HTTP2_GOAWAY_SESSION();
1814+
}
18081815

18091816
this[kUpdateTimer]();
18101817

@@ -1890,19 +1897,24 @@ class ClientHttp2Session extends Http2Session {
18901897
}
18911898
}
18921899

1893-
const onConnect = reqAsync.bind(requestOnConnect.bind(stream, headersList, options));
1894-
if (this.connecting) {
1895-
if (this[kPendingRequestCalls] !== null) {
1896-
this[kPendingRequestCalls].push(onConnect);
1900+
if (requestError) {
1901+
process.nextTick(reqAsync.bind(requestOnError.bind(stream, requestError)));
1902+
} else {
1903+
const onConnect = reqAsync.bind(
1904+
requestOnConnect.bind(stream, headersList, options));
1905+
if (this.connecting) {
1906+
if (this[kPendingRequestCalls] !== null) {
1907+
this[kPendingRequestCalls].push(onConnect);
1908+
} else {
1909+
this[kPendingRequestCalls] = [onConnect];
1910+
this.once('connect', () => {
1911+
this[kPendingRequestCalls].forEach((f) => f());
1912+
this[kPendingRequestCalls] = null;
1913+
});
1914+
}
18971915
} else {
1898-
this[kPendingRequestCalls] = [onConnect];
1899-
this.once('connect', () => {
1900-
this[kPendingRequestCalls].forEach((f) => f());
1901-
this[kPendingRequestCalls] = null;
1902-
});
1916+
onConnect();
19031917
}
1904-
} else {
1905-
onConnect();
19061918
}
19071919

19081920
if (onClientStreamCreatedChannel.hasSubscribers) {

test/parallel/test-http2-client-destroy.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,19 @@ const { getEventListeners } = require('events');
8181
assert.throws(() => client.ping(), sessionError);
8282
assert.throws(() => client.settings({}), sessionError);
8383
assert.throws(() => client.goaway(), sessionError);
84-
assert.throws(() => client.request(), sessionError);
84+
85+
const pendingReq = client.request();
86+
pendingReq.on('response', common.mustNotCall());
87+
pendingReq.on('error', common.expectsError(sessionError));
88+
pendingReq.on('close', common.mustCall());
89+
90+
client.on('close', common.mustCall(() => {
91+
const postCloseReq = client.request();
92+
postCloseReq.on('response', common.mustNotCall());
93+
postCloseReq.on('error', common.expectsError(sessionError));
94+
postCloseReq.on('close', common.mustCall());
95+
}));
96+
8597
client.close(); // Should be a non-op at this point
8698

8799
// Wait for setImmediate call from destroy() to complete
@@ -92,7 +104,6 @@ const { getEventListeners } = require('events');
92104
assert.throws(() => client.ping(), sessionError);
93105
assert.throws(() => client.settings({}), sessionError);
94106
assert.throws(() => client.goaway(), sessionError);
95-
assert.throws(() => client.request(), sessionError);
96107
client.close(); // Should be a non-op at this point
97108
});
98109

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
if (!common.hasCrypto)
5+
common.skip('missing crypto');
6+
7+
const assert = require('assert');
8+
const http2 = require('http2');
9+
10+
const server = http2.createServer();
11+
let serverSocket;
12+
13+
server.on('connection', common.mustCall((socket) => {
14+
serverSocket = socket;
15+
socket.on('error', () => {});
16+
}));
17+
18+
server.on('sessionError', () => {});
19+
server.on('stream', common.mustCall((stream, headers) => {
20+
if (headers[':path'] === '/close') {
21+
stream.respond({ ':status': 200 });
22+
stream.write('partial', common.mustCall(() => {
23+
setImmediate(() => serverSocket.destroy());
24+
}));
25+
return;
26+
}
27+
28+
stream.respond({ ':status': 200 });
29+
stream.end('ok');
30+
}));
31+
32+
server.listen(0, common.mustCall(() => {
33+
const session = http2.connect(`http://localhost:${server.address().port}`);
34+
let cachedSession = session;
35+
36+
session.on('error', () => {});
37+
session.on('close', common.mustCall(() => {
38+
cachedSession = undefined;
39+
server.close();
40+
}));
41+
42+
const req = session.request({ ':path': '/close' });
43+
req.on('response', common.mustCall());
44+
req.on('error', () => {});
45+
req.on('close', common.mustCall(() => {
46+
// This must not throw synchronously even though the session is no longer
47+
// usable. Depending on teardown timing, the returned stream may report a
48+
// closed session before the destroy state is fully observable here.
49+
const req2 = session.request({ ':path': '/again' });
50+
51+
req2.on('error', common.mustCall((err) => {
52+
assert.ok(
53+
err.code === 'ERR_HTTP2_INVALID_SESSION' ||
54+
err.code === 'ERR_HTTP2_GOAWAY_SESSION');
55+
assert.strictEqual(cachedSession, undefined);
56+
}));
57+
}));
58+
req.resume();
59+
}));

0 commit comments

Comments
 (0)