You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
http2: ServerHttp2Session#close does not allow "any existing streams to complete on their own" #42713
Linux mlumish.svl.corp.google.com 5.15.15-1rodete2-amd64 #1 SMP Debian 5.15.15-1rodete2 (2022-02-23) x86_64 GNU/Linux
Subsystem
http2
What steps will reproduce the bug?
consthttp2=require('http2');const{HTTP2_HEADER_PATH,HTTP2_HEADER_STATUS,HTTP2_HEADER_TE,HTTP2_HEADER_METHOD,HTTP2_HEADER_AUTHORITY}=http2.constants;constserver=http2.createServer();server.on('stream',(stream,headers)=>{setTimeout(()=>{stream.respond({[HTTP2_HEADER_STATUS]: 200});stream.end('some data');},2000);});server.on('session',session=>{setTimeout(()=>{server.close();session.close();},1000);});server.listen(0,()=>{constport=server.address().port;constclient=http2.connect(`http://localhost:${port}`);conststartTime=newDate();constreq=client.request({[HTTP2_HEADER_PATH]: '/'});req.end();req.on('response',(headers)=>{console.log(headers[HTTP2_HEADER_STATUS]);});req.on('data',(chunk)=>{console.log('received data')});req.on('end',()=>{console.log('received end')});req.on('close',()=>{constendTime=newDate();console.log(`Stream closed with code ${req.rstCode} after ${endTime-startTime}ms`);});});
How often does it reproduce? Is there a required condition?
100%
What is the expected behavior?
The correct output, as seen on Node 16.6 and earlier:
200
received data
received end
Stream closed with code 0 after 2018ms
What do you see instead?
received end
Stream closed with code 8 after 2012ms
Additional information
As noted above, this works as expected in Node versions 16.6 and earlier.
This only happens if the stream.respond call is delayed in the stream handler. If that is not delayed but stream.end is delayed, this failure does not occur.
Increasing the delay before responding in the stream event handler increases the time before the close event is logged, even though the result of the call is fully determined once the server session is closed, making this bug worse for long-running workloads.
It seems "fixed" in v17.x.x. However, I do feel that the stream should be closed without receiving any data (as v16.7.0+ does), once you are closing the session before respond. I'm missing something?
Gracefully closes the Http2Session, allowing any existing streams to complete on their own and preventing new Http2Stream instances from being created.
I don't understand how that could possibly be compatible with forcibly ending an existing stream when close is called.
I did a git bisect and this was introduced in a libuv update (v16.7.0): #39525, but, that's weird because the libuv version is the same as used in v17.0.0, which works fine. @lpinca The libuv deps upgrade was also merged in v17.x right?
I will double check, but I believe I was also seeing this issue on 17.x.x. When I did the original diagnosis in another library that led to @murgatroid99 finding the issue with node I was developing locally on OSX with node 17.
So this is more confusing. @murgatroid99 using the example you provided I don't see the issue on v17.8.0 on OSX, but I'm 99% sure I was using that same version when I ran into issues with grpc-js. I didn't have nodenv installed until you mentioned it working on an older version, and I had not upgraded node. So I'm not sure how I could have had another version.
Edit: I just confirmed this is the case. On v17.8.0 the above HTTP example works fine, but the linked issue in the grpc-js library still occurs
This is not 100% fixed. The following code sample demonstrates a remaining behavior difference between Node 16.6 and 17.9 that breaks grpc-js:
consthttp2=require('http2');const{HTTP2_HEADER_PATH,HTTP2_HEADER_STATUS,HTTP2_HEADER_TE,HTTP2_HEADER_METHOD,HTTP2_HEADER_AUTHORITY}=http2.constants;constserver=http2.createServer();server.on('stream',(stream,headers)=>{stream.on('wantTrailers',()=>{console.log('sending trailers');stream.sendTrailers({xyz: 'abc'});});setTimeout(()=>{stream.respond({[HTTP2_HEADER_STATUS]: 200},{waitForTrailers: true});stream.write('some data');stream.end();},2000);});server.on('session',session=>{setTimeout(()=>{server.close(()=>console.log('server close completed'));session.close(()=>console.log('session close completed'));},1000);});server.listen(0,()=>{constport=server.address().port;constclient=http2.connect(`http://localhost:${port}`);client.socket.on('close',()=>{console.log('Client socket closed');})conststartTime=newDate();constreq=client.request({[HTTP2_HEADER_PATH]: '/'});req.end();req.on('response',(headers)=>{console.log(headers[HTTP2_HEADER_STATUS]);});req.on('data',(chunk)=>{console.log('received data')});req.on('end',()=>{console.log('received end')});req.on('trailers',trailers=>{console.log('received trailers')});req.on('close',()=>{constendTime=newDate();console.log(`Stream closed with code ${req.rstCode} after ${endTime-startTime}ms`);});});
On Node 16.6.2, this outputs
sending trailers
200
received data
session close completed
server close completed
received trailers
received end
Stream closed with code 0 after 2014ms
Client socket closed
On Node 17.9.0, it outputs
sending trailers
200
received data
session close completed
server close completed
Client socket closed
received end
Stream closed with code 8 after 2016ms
Note the lack of the received trailers line in the second log. gRPC waits for trailers to consider a request complete.
I screwed something up with synchronizing changes I made to my test code and my comment. I also do not see the issue with the test code I put into that comment, but I do see the issue if I include [HTTP2_HEADER_METHOD]: 'POST' in the client request headers. So the corrected code is:
Test code
consthttp2=require('http2');const{HTTP2_HEADER_PATH,HTTP2_HEADER_STATUS,HTTP2_HEADER_TE,HTTP2_HEADER_METHOD,HTTP2_HEADER_AUTHORITY}=http2.constants;constserver=http2.createServer();server.on('stream',(stream,headers)=>{stream.on('wantTrailers',()=>{console.log('sending trailers');stream.sendTrailers({xyz: 'abc'});});setTimeout(()=>{stream.respond({[HTTP2_HEADER_STATUS]: 200},{waitForTrailers: true});stream.write('some data');stream.end();},2000);});server.on('session',session=>{setTimeout(()=>{server.close(()=>console.log('server close completed'));session.close(()=>console.log('session close completed'));},1000);});server.listen(0,()=>{constport=server.address().port;constclient=http2.connect(`http://localhost:${port}`);client.socket.on('close',()=>{console.log('Client socket closed');})conststartTime=newDate();constreq=client.request({[HTTP2_HEADER_PATH]: '/',[HTTP2_HEADER_METHOD]: 'POST'});req.end();req.on('response',(headers)=>{console.log(headers[HTTP2_HEADER_STATUS]);});req.on('data',(chunk)=>{console.log('received data')});req.on('end',()=>{console.log('received end')});req.on('trailers',trailers=>{console.log('received trailers')});req.on('close',()=>{constendTime=newDate();console.log(`Stream closed with code ${req.rstCode} after ${endTime-startTime}ms`);});});
Before closing the session, that function tries to flush the remaining outgoing data by calling Http2Session::SendPendingData. However, Http2::SendPendingData is non-reentrant. The call fails, so the data is not actually flushed, but this call site does not check the return value, so it continues on destroying the session and closing the underlying socket.
One option I see to fix that is to handle the failure of Http2::SendPendingData in Http2Session::Close, but the comment there indicates that the purpose is to send a GOAWAY and that sending is best-effort, so ignoring the error code might be intentional. Alternatively, adding an asynchronous delay somewhere in that call stack should resolve the problem by allowing the original Http2Session::SendPendingData call to finish before closing the session.
Update: I have tried adding an async delay in various parts of this call stack, and it always causes a few other tests to fail. It is not yet clear if those failures are legitimate problems, or if those tests have unreasonably narrow expectations.
Update 2: I have been looking in to those test failures, here is what I have found so far:
test-http2-client-jsstream-destroy.js fails on this assert, apparently because the final http2 session data flush can happen after the socket has finished closing. I think the JSStreamSocket class just needs to be able to handle receiving writes after close, and do nothing instead of erroring.
test-http2-close-while-writing.js times out. I don't know exactly why this is happening, but it seems to be some kind of race related to the fact that data is still buffered to be written as connections are shutting down.
A couple of other tests fail because of an unhandled error event. I think the client receives more data than it previously did because the server successfully flushes data that it otherwise discarded. So, the client should see that error, and the test should be changed to expect it.
The rest of the tests fail because the client request object gets an unexpected end event when expecting an error. This seems to be related to how the client handles the new data at the end of the stream/session, but I haven't yet investigated what exactly is happening there.
Update 3: The timeout and unexpected end events appear to be caused by delaying the callback in addition to the call to session[kMaybeDestroy] when adding an asynchronous delay in stream.destroy, and there is no need to delay the callback.
Activity
RafaelGSS commentedon Apr 23, 2022
It seems "fixed" in
v17.x.x. However, I do feel that thestreamshould be closed without receiving any data (as v16.7.0+ does), once you are closing the session beforerespond. I'm missing something?murgatroid99 commentedon Apr 23, 2022
The documentation for
Http2Session#closesaysI don't understand how that could possibly be compatible with forcibly ending an existing stream when
closeis called.RafaelGSS commentedon Apr 24, 2022
I did a
git bisectand this was introduced in alibuvupdate (v16.7.0): #39525, but, that's weird because the libuv version is the same as used inv17.0.0, which works fine. @lpinca The libuv deps upgrade was also merged in v17.x right?aidandj commentedon Apr 24, 2022
I will double check, but I believe I was also seeing this issue on 17.x.x. When I did the original diagnosis in another library that led to @murgatroid99 finding the issue with node I was developing locally on OSX with node 17.
RafaelGSS commentedon Apr 24, 2022
@artificial-aidan Are you getting the unexpected behavior in v17? I'm on linux, and it's working fine.
aidandj commentedon Apr 24, 2022
I will check later today, not at a computer right now. But that's what I recall.
aidandj commentedon Apr 24, 2022
So this is more confusing. @murgatroid99 using the example you provided I don't see the issue on
v17.8.0on OSX, but I'm 99% sure I was using that same version when I ran into issues withgrpc-js. I didn't havenodenvinstalled until you mentioned it working on an older version, and I had not upgraded node. So I'm not sure how I could have had another version.Edit: I just confirmed this is the case. On
v17.8.0the above HTTP example works fine, but the linked issue in the grpc-js library still occurslpinca commentedon Apr 24, 2022
Yes.
murgatroid99 commentedon Apr 28, 2022
This is not 100% fixed. The following code sample demonstrates a remaining behavior difference between Node 16.6 and 17.9 that breaks grpc-js:
On Node 16.6.2, this outputs
On Node 17.9.0, it outputs
Note the lack of the
received trailersline in the second log. gRPC waits for trailers to consider a request complete.RafaelGSS commentedon Apr 29, 2022
@murgatroid99 Thanks for the update. I'll try to look at it pretty soon (traveling atm)
RafaelGSS commentedon May 12, 2022
@murgatroid99 just tried to reproduce the issue and I'm always getting the
received trailersmurgatroid99 commentedon May 12, 2022
I screwed something up with synchronizing changes I made to my test code and my comment. I also do not see the issue with the test code I put into that comment, but I do see the issue if I include
[HTTP2_HEADER_METHOD]: 'POST'in the client request headers. So the corrected code is:Test code
santigimeno commentedon Oct 24, 2022
I think #45153 may fix this issue
9 remaining items
src: let http2 streams end after session close
src: let http2 streams end after session close
src: let http2 streams end after session close
src: let http2 streams end after session close
murgatroid99 commentedon Mar 22, 2023
This seems to have been re-broken in #46721. Can we reopen this issue?
murgatroid99 commentedon Sep 13, 2023
Ping on this again. It seems that there has not been any activity since it was re-broken.
murgatroid99 commentedon Oct 10, 2023
I have been debugging this, here is what causes this problem, as far as I can tell:
Http2Session::SendPendingDatanghttp2_session_mem_sendto serialize the outgoing frame dataHttp2Session::OnStreamClosestream.onStreamClose.stream.destroy, which callsstream._destroysession[kMaybeDestroy]session.destroycloseSessionHttp2Session::DestroyHttp2Session::CloseHttp2Session::SendPendingData. However,Http2::SendPendingDatais non-reentrant. The call fails, so the data is not actually flushed, but this call site does not check the return value, so it continues on destroying the session and closing the underlying socket.One option I see to fix that is to handle the failure of
Http2::SendPendingDatainHttp2Session::Close, but the comment there indicates that the purpose is to send a GOAWAY and that sending is best-effort, so ignoring the error code might be intentional. Alternatively, adding an asynchronous delay somewhere in that call stack should resolve the problem by allowing the originalHttp2Session::SendPendingDatacall to finish before closing the session.Update: I have tried adding an async delay in various parts of this call stack, and it always causes a few other tests to fail. It is not yet clear if those failures are legitimate problems, or if those tests have unreasonably narrow expectations.
Update 2: I have been looking in to those test failures, here is what I have found so far:
test-http2-client-jsstream-destroy.jsfails on this assert, apparently because the final http2 session data flush can happen after the socket has finished closing. I think theJSStreamSocketclass just needs to be able to handle receiving writes after close, and do nothing instead of erroring.test-http2-close-while-writing.jstimes out. I don't know exactly why this is happening, but it seems to be some kind of race related to the fact that data is still buffered to be written as connections are shutting down.endevent when expecting an error. This seems to be related to how the client handles the new data at the end of the stream/session, but I haven't yet investigated what exactly is happening there.Update 3: The timeout and unexpected end events appear to be caused by delaying the callback in addition to the call to
session[kMaybeDestroy]when adding an asynchronous delay instream.destroy, and there is no need to delay the callback.http2: allow streams to complete gracefully after goaway
http2: allow streams to complete gracefully after goaway
http2: allow streams to complete gracefully after goaway
http2: allow streams to complete gracefully after goaway