Skip to content

Commit 39f61fb

Browse files
mcollinasxa
authored andcommitted
http2: emit session close before stream close
Signed-off-by: Matteo Collina <hello@matteocollina.com> PR-URL: #63414Fixes: #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 5a95a2b commit 39f61fb

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
@@ -1110,10 +1110,12 @@ creates and returns an `Http2Stream` instance that can be used to send an
11101110
HTTP/2 request to the connected server.
11111111

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

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

‎lib/internal/http2/core.js‎

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,10 @@ function requestOnConnect(headersList, options) {
845845
}
846846
}
847847

848+
functionrequestOnError(error){
849+
this.destroy(error);
850+
}
851+
848852
// Validates that priority options are correct, specifically:
849853
// 1. options.weight must be a number
850854
// 2. options.parent must be a positive number
@@ -1160,7 +1164,7 @@ function setupHandle(socket, type, options) {
11601164
process.nextTick(emit,this,'connect',this,socket);
11611165
}
11621166

1163-
// Emits a close event followed by an error event if err is truthy. Used
1167+
// Emits an error event followed by a close event if err is truthy. Used
11641168
// by Http2Session.prototype.destroy()
11651169
functionemitClose(self,error){
11661170
if(error)
@@ -1231,17 +1235,16 @@ function closeSession(session, code, error) {
12311235
session.setTimeout(0);
12321236
session.removeAllListeners('timeout');
12331237

1238+
constsocket=session[kSocket];
1239+
consthandle=session[kHandle];
1240+
12341241
// Destroy any pending and open streams
12351242
if(state.pendingStreams.size>0||state.streams.size>0){
12361243
constcancel=newERR_HTTP2_STREAM_CANCEL(error);
12371244
state.pendingStreams.forEach((stream)=>stream.destroy(cancel));
12381245
state.streams.forEach((stream)=>stream.destroy(error));
12391246
}
12401247

1241-
// Disassociate from the socket and server.
1242-
constsocket=session[kSocket];
1243-
consthandle=session[kHandle];
1244-
12451248
// Destroy the handle if it exists at this point.
12461249
if(handle!==undefined){
12471250
handle.ondone=finishSessionClose.bind(null,session,error);
@@ -1816,11 +1819,15 @@ class ClientHttp2Session extends Http2Session {
18161819
request(headersParam,options){
18171820
debugSessionObj(this,'initiating request');
18181821

1819-
if(this.destroyed)
1820-
thrownewERR_HTTP2_INVALID_SESSION();
1821-
1822-
if(this.closed)
1823-
thrownewERR_HTTP2_GOAWAY_SESSION();
1822+
// Keep argument validation synchronous, but defer session-state failures
1823+
// to the returned stream so request retries from stream callbacks do not
1824+
// throw before session lifecycle handlers run.
1825+
letrequestError;
1826+
if(this.destroyed){
1827+
requestError=newERR_HTTP2_INVALID_SESSION();
1828+
}elseif(this.closed){
1829+
requestError=newERR_HTTP2_GOAWAY_SESSION();
1830+
}
18241831

18251832
this[kUpdateTimer]();
18261833

@@ -1906,19 +1913,24 @@ class ClientHttp2Session extends Http2Session {
19061913
}
19071914
}
19081915

1909-
constonConnect=reqAsync.bind(requestOnConnect.bind(stream,headersList,options));
1910-
if(this.connecting){
1911-
if(this[kPendingRequestCalls]!==null){
1912-
this[kPendingRequestCalls].push(onConnect);
1916+
if(requestError){
1917+
process.nextTick(reqAsync.bind(requestOnError.bind(stream,requestError)));
1918+
}else{
1919+
constonConnect=reqAsync.bind(
1920+
requestOnConnect.bind(stream,headersList,options));
1921+
if(this.connecting){
1922+
if(this[kPendingRequestCalls]!==null){
1923+
this[kPendingRequestCalls].push(onConnect);
1924+
}else{
1925+
this[kPendingRequestCalls]=[onConnect];
1926+
this.once('connect',()=>{
1927+
this[kPendingRequestCalls].forEach((f)=>f());
1928+
this[kPendingRequestCalls]=null;
1929+
});
1930+
}
19131931
}else{
1914-
this[kPendingRequestCalls]=[onConnect];
1915-
this.once('connect',()=>{
1916-
this[kPendingRequestCalls].forEach((f)=>f());
1917-
this[kPendingRequestCalls]=null;
1918-
});
1932+
onConnect();
19191933
}
1920-
}else{
1921-
onConnect();
19221934
}
19231935

19241936
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+
constpendingReq=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+
constpostCloseReq=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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use strict';
2+
3+
constcommon=require('../common');
4+
if(!common.hasCrypto)
5+
common.skip('missing crypto');
6+
7+
constassert=require('assert');
8+
consthttp2=require('http2');
9+
10+
constserver=http2.createServer();
11+
letserverSocket;
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+
constsession=http2.connect(`http://localhost:${server.address().port}`);
34+
letcachedSession=session;
35+
36+
session.on('error',()=>{});
37+
session.on('close',common.mustCall(()=>{
38+
cachedSession=undefined;
39+
server.close();
40+
}));
41+
42+
constreq=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+
constreq2=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)