Skip to content

Commit 5fc94f7

Browse files
committed
http2: make request() invalid-state errors async
PR-URL: #63414 Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 0984bca commit 5fc94f7

4 files changed

Lines changed: 62 additions & 55 deletions

File tree

doc/api/http2.md

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

11151115
When a `ClientHttp2Session` is first created, the socket may not yet be
1116-
connected. if `clienthttp2session.request()` is called during this time, the
1116+
connected. If `clienthttp2session.request()` is called during this time, the
11171117
actual request will be deferred until the socket is ready to go.
1118-
If the `session` is closed before the actual request be executed, an
1119-
`ERR_HTTP2_GOAWAY_SESSION` is thrown.
1118+
1119+
If the session becomes unavailable before the request can be created, the
1120+
returned stream will emit `ERR_HTTP2_GOAWAY_SESSION` or
1121+
`ERR_HTTP2_INVALID_SESSION` asynchronously.
11201122

11211123
This method is only available if `http2session.type` is equal to
11221124
`http2.constants.NGHTTP2_SESSION_CLIENT`.

lib/internal/http2/core.js

Lines changed: 31 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -343,8 +343,6 @@ const SESSION_FLAGS_PENDING = 0x0;
343343
const SESSION_FLAGS_READY = 0x1;
344344
const SESSION_FLAGS_CLOSED = 0x2;
345345
const SESSION_FLAGS_DESTROYED = 0x4;
346-
const SESSION_FLAGS_CLOSE_QUEUED = 0x8;
347-
const SESSION_FLAGS_CLOSE_EMITTED = 0x10;
348346

349347
// Top level to avoid creating a closure
350348
function emit(self, ...args) {
@@ -840,6 +838,10 @@ function requestOnConnect(headersList, options) {
840838
}
841839
}
842840

841+
function requestOnError(error) {
842+
this.destroy(error);
843+
}
844+
843845
// Validates that priority options are correct, specifically:
844846
// 1. options.weight must be a number
845847
// 2. options.parent must be a positive number
@@ -1158,26 +1160,11 @@ function setupHandle(socket, type, options) {
11581160
// Emits an error event followed by a close event if err is truthy. Used
11591161
// by Http2Session.prototype.destroy()
11601162
function emitClose(self, error) {
1161-
const state = self[kState];
1162-
if (state.flags & SESSION_FLAGS_CLOSE_EMITTED)
1163-
return;
1164-
1165-
state.flags |= SESSION_FLAGS_CLOSE_EMITTED;
1166-
11671163
if (error)
11681164
self.emit('error', error);
11691165
self.emit('close');
11701166
}
11711167

1172-
function emitCloseNextTick(self, error) {
1173-
const state = self[kState];
1174-
if (state.flags & (SESSION_FLAGS_CLOSE_QUEUED | SESSION_FLAGS_CLOSE_EMITTED))
1175-
return;
1176-
1177-
state.flags |= SESSION_FLAGS_CLOSE_QUEUED;
1178-
process.nextTick(emitClose, self, error);
1179-
}
1180-
11811168
function cleanupSession(session) {
11821169
const socket = session[kSocket];
11831170
const handle = session[kHandle];
@@ -1226,7 +1213,7 @@ function finishSessionClose(session, error) {
12261213
}
12271214
});
12281215
} else {
1229-
emitCloseNextTick(session, error);
1216+
process.nextTick(emitClose, session, error);
12301217
}
12311218
}
12321219

@@ -1243,13 +1230,6 @@ function closeSession(session, code, error) {
12431230

12441231
const socket = session[kSocket];
12451232
const handle = session[kHandle];
1246-
const socketDestroyed = socket?.destroyed === true;
1247-
1248-
// If the transport has already closed, queue the session close event before
1249-
// stream callbacks are scheduled so clients can invalidate cached sessions
1250-
// before associated streams finish closing.
1251-
if (socketDestroyed)
1252-
emitCloseNextTick(session, error);
12531233

12541234
// Destroy any pending and open streams
12551235
if (state.pendingStreams.size > 0 || state.streams.size > 0) {
@@ -1261,7 +1241,7 @@ function closeSession(session, code, error) {
12611241
// Destroy the handle if it exists at this point.
12621242
if (handle !== undefined) {
12631243
handle.ondone = finishSessionClose.bind(null, session, error);
1264-
handle.destroy(code, socketDestroyed);
1244+
handle.destroy(code, socket.destroyed);
12651245
} else {
12661246
finishSessionClose(session, error);
12671247
}
@@ -1832,11 +1812,15 @@ class ClientHttp2Session extends Http2Session {
18321812
request(headersParam, options) {
18331813
debugSessionObj(this, 'initiating request');
18341814

1835-
if (this.destroyed)
1836-
throw new ERR_HTTP2_INVALID_SESSION();
1837-
1838-
if (this.closed)
1839-
throw new ERR_HTTP2_GOAWAY_SESSION();
1815+
// Keep argument validation synchronous, but defer session-state failures
1816+
// to the returned stream so request retries from stream callbacks do not
1817+
// throw before session lifecycle handlers run.
1818+
let requestError;
1819+
if (this.destroyed) {
1820+
requestError = new ERR_HTTP2_INVALID_SESSION();
1821+
} else if (this.closed) {
1822+
requestError = new ERR_HTTP2_GOAWAY_SESSION();
1823+
}
18401824

18411825
this[kUpdateTimer]();
18421826

@@ -1922,19 +1906,24 @@ class ClientHttp2Session extends Http2Session {
19221906
}
19231907
}
19241908

1925-
const onConnect = reqAsync.bind(requestOnConnect.bind(stream, headersList, options));
1926-
if (this.connecting) {
1927-
if (this[kPendingRequestCalls] !== null) {
1928-
this[kPendingRequestCalls].push(onConnect);
1909+
if (requestError) {
1910+
process.nextTick(reqAsync.bind(requestOnError.bind(stream, requestError)));
1911+
} else {
1912+
const onConnect = reqAsync.bind(
1913+
requestOnConnect.bind(stream, headersList, options));
1914+
if (this.connecting) {
1915+
if (this[kPendingRequestCalls] !== null) {
1916+
this[kPendingRequestCalls].push(onConnect);
1917+
} else {
1918+
this[kPendingRequestCalls] = [onConnect];
1919+
this.once('connect', () => {
1920+
this[kPendingRequestCalls].forEach((f) => f());
1921+
this[kPendingRequestCalls] = null;
1922+
});
1923+
}
19291924
} else {
1930-
this[kPendingRequestCalls] = [onConnect];
1931-
this.once('connect', () => {
1932-
this[kPendingRequestCalls].forEach((f) => f());
1933-
this[kPendingRequestCalls] = null;
1934-
});
1925+
onConnect();
19351926
}
1936-
} else {
1937-
onConnect();
19381927
}
19391928

19401929
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 { listenerCount } = 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 { listenerCount } = 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

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

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,23 +32,28 @@ server.on('stream', common.mustCall((stream, headers) => {
3232
server.listen(0, common.mustCall(() => {
3333
const session = http2.connect(`http://localhost:${server.address().port}`);
3434
let cachedSession = session;
35-
const events = [];
3635

37-
session.on('error', common.mustNotCall());
36+
session.on('error', () => {});
3837
session.on('close', common.mustCall(() => {
39-
events.push('session-close');
4038
cachedSession = undefined;
4139
server.close();
4240
}));
4341

4442
const req = session.request({ ':path': '/close' });
4543
req.on('response', common.mustCall());
44+
req.on('error', () => {});
4645
req.on('close', common.mustCall(() => {
47-
events.push('stream-close');
48-
assert.strictEqual(session.closed, true);
49-
assert.strictEqual(session.destroyed, true);
50-
assert.strictEqual(cachedSession, undefined);
51-
assert.deepStrictEqual(events, ['session-close', 'stream-close']);
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+
}));
5257
}));
5358
req.resume();
5459
}));

0 commit comments

Comments
 (0)