Repository navigation
http2: ServerHttp2Session#close does not allow "any existing streams to complete on their own" #42713
Description
Activity
- addedhttp2Issues and PRs related to the http2 subsystem.Issues and PRs related to the http2 subsystem.
on Apr 13, 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?The documentation for
Http2Session#closesaysGracefully closes the
Http2Session, allowing any existing streams to complete on their own and preventing newHttp2Streaminstances from being created.I don't understand how that could possibly be compatible with forcibly ending an existing stream when
closeis called.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.
@artificial-aidan Are you getting the unexpected behavior in v17? I'm on linux, and it's working fine.
@artificial-aidan Are you getting the unexpected behavior in v17? I'm on linux, and it's working fine.
I will check later today, not at a computer right now. But that's what I recall.
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 occurs@lpinca The libuv deps upgrade was also merged in v17.x right?
Yes.
$ ./node Welcome to Node.js v17.0.0. Type ".help" for more information. > process.versions { node: '17.0.0', v8: '9.5.172.21-node.12', uv: '1.42.0', zlib: '1.2.11', brotli: '1.0.9', ares: '1.17.2', modules: '102', nghttp2: '1.45.1', napi: '8', llhttp: '6.0.4', openssl: '3.0.0+quic', cldr: '39.0', icu: '69.1', tz: '2021a', unicode: '13.0', ngtcp2: '0.1.0-DEV', nghttp3: '0.1.0-DEV' } >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:
const http2 = require('http2'); const { HTTP2_HEADER_PATH, HTTP2_HEADER_STATUS, HTTP2_HEADER_TE, HTTP2_HEADER_METHOD, HTTP2_HEADER_AUTHORITY } = http2.constants; const server = 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, () => { const port = server.address().port; const client = http2.connect(`http://localhost:${port}`); client.socket.on('close', () => { console.log('Client socket closed'); }) const startTime = new Date(); const req = 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', () => { const endTime = new Date(); 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 closedOn 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 2016msNote the lack of the
received trailersline in the second log. gRPC waits for trailers to consider a request complete.Reacted by Aidan Jensen@murgatroid99 Thanks for the update. I'll try to look at it pretty soon (traveling atm)
@murgatroid99 just tried to reproduce the issue and I'm always getting the
received trailersrafaelgss@rafaelgss:~/repos/os/node$ node -v v17.9.0 rafaelgss@rafaelgss:~/repos/os/node$ node issue.js sending trailers 200 received data received trailers received end Stream closed with code 0 after 2020ms session close completed server close completed Client socket closed
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
const http2 = require('http2'); const { HTTP2_HEADER_PATH, HTTP2_HEADER_STATUS, HTTP2_HEADER_TE, HTTP2_HEADER_METHOD, HTTP2_HEADER_AUTHORITY } = http2.constants; const server = 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, () => { const port = server.address().port; const client = http2.connect(`http://localhost:${port}`); client.socket.on('close', () => { console.log('Client socket closed'); }) const startTime = new Date(); const req = 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', () => { const endTime = new Date(); console.log(`Stream closed with code ${req.rstCode} after ${endTime - startTime}ms`); }); });
I think #45153 may fix this issue
9 remaining items
- added a commit that references this issue
on Dec 30, 2022 - added 2 commits that reference this issue
on Jan 3, 2023 - added a commit that references this issue
on Jan 3, 2023 This seems to have been re-broken in #46721. Can we reopen this issue?
Reacted by alexeych0 and Jack KavanaghPing on this again. It seems that there has not been any activity since it was re-broken.
I have been debugging this, here is what causes this problem, as far as I can tell:
- The server session closes
- The server stream tries to send trailers, which eventually calls
Http2Session::SendPendingData - This calls
nghttp2_session_mem_sendto serialize the outgoing frame data - After serializing the frame data, nghttp2 sees that the stream has finished reading and writing, and calls
Http2Session::OnStreamClose - That in turn calls the JS function
stream.onStreamClose. - That calls
stream.destroy, which callsstream._destroy - That calls
session[kMaybeDestroy] - Because the session is closed and the last open stream was just closed, that calls
session.destroy - That calls
closeSession - That calls
Http2Session::Destroy - That calls
Http2Session::Close - Before closing the session, that function tries to flush the remaining outgoing data by calling
Http2Session::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.- 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
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.- added a commit that references this issue
on Oct 20, 2023 - added a commit that references this issue
on Oct 23, 2023 - added a commit that references this issue
on Nov 11, 2023 - added a commit that references this issue
on May 5, 2024
Version
v16.7.0 (and later)
Platform
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?
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:
What do you see instead?
Additional information
As noted above, this works as expected in Node versions 16.6 and earlier.
This only happens if the
stream.respondcall is delayed in thestreamhandler. If that is not delayed butstream.endis delayed, this failure does not occur.Increasing the delay before responding in the
streamevent handler increases the time before thecloseevent 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.