Do not swallow calls to res.end()#188
Conversation
| if (onFinished.isFinished(this)) { | ||
| return endOnce.call(this) | ||
| } |
There was a problem hiding this comment.
This addition takes care of situations where a route handler calls res.end() after the response is considered "finished". This is often due to the client having moved on before the response is ready for them.
| onFinished(res, function onFinished () { | ||
| if (ended) { | ||
| endOnce.call(res) | ||
| } | ||
| }) |
There was a problem hiding this comment.
This addition takes care of situations where a response becomes "finished" soon after res.end() gets called by a route handler. Without this code, if the events occur close enough to one another, the stream may (at least in principle) get stuck in a "paused" state, never emitting the end event that would normally invoke the original res.end function.
| setTimeout(function () { | ||
| server.close(function () { | ||
| if (originalResEndCalledTimes === 1) { | ||
| done() | ||
| } else { | ||
| done(new Error('The original res.end() was called ' + originalResEndCalledTimes + ' times')) | ||
| } | ||
| }) | ||
| }, 5) |
There was a problem hiding this comment.
Waiting for the condition to assert could be extracted into a generic helper, and it could also poll a couple of times if that is desired. I left this code in its naked form for now, and it is also duplicated in the other added test cases. I'm happy to improve this further as needed.
bjohansebas
left a comment
There was a problem hiding this comment.
Can we do this without on-finished? Ideally, this would go into the v2 branch, so support for older Node versions doesn’t matter—we’ll support Node 18+. Sorry for the delay.
I suppose we can. Is there an alternative way to detect client disconnect that you can recommend? I will look into putting this on top of the
No worries. 👍 |
maybe |
aef3b53 to
0ea9e82
Compare
| // mark ended | ||
| ended = true | ||
|
|
||
| if (this.writableFinished || !this.socket.writable) { |
There was a problem hiding this comment.
I believe that the essential condition here is !this.socket.writable but checking this.writableFinished seems reasonable as well.
Co-authored-by: Sebastian Beltran <[email protected]>
|
Unfortunately, the changes here seem to lead to an issue with sometimes calling |
|
I'll attempt to deploy this alternative patch on our end. We only got 2 of those uncaught exceptions over the last month, so only time will tell whether the alternative fix helps resolve them. |
Could you add a test for that case, please? |
… reasonable to test in addition to the other ones
Getting the actual uncaught error is not something I was able to reproduce on my laptop so far. All I really know is that it originates from this line of code (Node version When I experiment with things locally and attach a callback to I added a specific test around Then I generalised the test a bit further in 175a50d - but that has then the implication of not even attempting to write into the response after it appears to be finished, and all the stream does is ignore all the compressed data. Not sure whether this is the way to go but I'm happy to revert this part or adjust things in another way. |
There was a problem hiding this comment.
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
Comments suppressed due to low confidence (2)
index.js:318
- Consider verifying that res.socket exists before accessing its properties to prevent potential runtime errors in environments where res.socket may be undefined.
return res.writableFinished || !res.socket.writable
test/compression.js:973
- [nitpick] The use of fixed setTimeout delays could lead to intermittent test failures on slower machines; consider using event-driven triggers or increasing the timeout duration for more reliable synchronization.
setTimeout(function () {
| finished(res, function onResponseFinished () { | ||
| stream.resume() | ||
| }) |
There was a problem hiding this comment.
I still don't quite understand at what point this becomes necessary, given that finished runs after ('error', 'end', 'finish', and 'close') have already been emitted. Why should we call stream.resume()?
There was a problem hiding this comment.
Okay, after doing more research, I think resume() is necessary — but it's already being called in the data event, so doing it again would be redundant.
I'm going to leave this here in case anyone has comments about it
There was a problem hiding this comment.
For completeness: There is no test that fails if this piece of code gets removed.
I kept it in place in case the sequence of events goes something like this:
- At first, response socket is all well and good but the connection is slow, causing its buffer to fill up. This leads to calling
stream.pause(), which is all completely fine. - Before the response buffer gets drained, the socket connection dies. As a result, the
drainevent on the response does not get emitted. - The stream remains paused and never emits the
endevent, and the originalres.end()never gets called.
I can attempt to write a test that captures this scenario but it might end up being somewhat artificial (i.e. relying on mocking res.write() so it returns false without the response appearing as finished). What do you think?
There was a problem hiding this comment.
it's better to add a comment explaining why it's necessary
… the response finishes
bjohansebas
left a comment
There was a problem hiding this comment.
Thanks for this great work
|
i've gone back to using on-finished in order to use isFinished, since it's easier to make changes there and just update the dependency here. I've also added several debug messages. |
* Add tests demonstrating scenarios under which the original res.end does not get called * Ensure stream gets closed and response gets ended when it is finished * Only call the original res.end() a single time * Avoid using the on-finished package * Use finished() helper from node:stream * Import finished() helper directly from node:stream Co-authored-by: Sebastian Beltran <[email protected]> * Use finished() helper directly instead of nodeStream.finished() * Adjust stream pause / resume logic instead of messing with res.end() calls directly * Revert addition of endOnce() wrapper * Add unit test around one more alternate sequence of events that seems reasonable to test in addition to the other ones * Add test around avoiding 'write after end' errors * Avoid writing into the response after it is finished * Add a comment explaining why the compression stream gets resumed when the response finishes * chore: use isFinished of on-finished --------- Co-authored-by: Sebastian Beltran <[email protected]>
* Add tests demonstrating scenarios under which the original res.end does not get called * Ensure stream gets closed and response gets ended when it is finished * Only call the original res.end() a single time * Avoid using the on-finished package * Use finished() helper from node:stream * Import finished() helper directly from node:stream Co-authored-by: Sebastian Beltran <[email protected]> * Use finished() helper directly instead of nodeStream.finished() * Adjust stream pause / resume logic instead of messing with res.end() calls directly * Revert addition of endOnce() wrapper * Add unit test around one more alternate sequence of events that seems reasonable to test in addition to the other ones * Add test around avoiding 'write after end' errors * Avoid writing into the response after it is finished * Add a comment explaining why the compression stream gets resumed when the response finishes * chore: use isFinished of on-finished --------- Co-authored-by: Sebastian Beltran <[email protected]>
Background
In our application we tend to rely on
res.end()calls to perform cleanup. Specifically, we intercept those calls, similarly to how the same is done in thecompressionmiddleware, and take custom actions before calling the originalres.endfunction.Recently we found out that depending on middleware order, our cleanup code either gets called reliably or it does not. Additional investigation revealed that it is the
compressionmiddleware that sometimes "swallows" calls tores.end, which is to say that even though a route handler callsres.end, the middleware never calls the originalres.endfunction. In particular, this happens when the client cuts the connection before consuming the compressed response because the following happens:res.end, thecompressionmiddleware does not call the originalres.endfunction and instead callsstream.end.endevent does not get emitted and the handler attached to it never calls the originalres.endfunction.We considered using the
finishorprefinishevents instead of wrappingres.endcalls, but these do not get emitted either, likely becauseOutgoingMessage.endnever gets called either.Change
res.endfunction does not get called with the code onmasteron-finishedto detect client disconnect and call the originalres.endfunction - though this is only done if the replacementres.endwrapper had already been called, or if it is currently being calledres.endfunction, the changed code also ensures that we never call the function more than onceOverall, the change should ensure that
res.endcalls do not get swallowed by thecompressionmiddleware.Acknowledgement
Lots of the ideation around this change as well as the test code originate from @papandreou - many thanks for all the help! 🙇