fix: finish paused HTTP/1 parser on socket end#5364
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a crash in the HTTP/1 client path where parser.finish() could be invoked from the socket 'end' handler while the llhttp parser was paused due to response-body backpressure, previously triggering an assert(!this.paused) and crashing the process (Fixes #5360).
Changes:
- Update
Parser.finish()to resume a paused llhttp parser before callingllhttp_finish(). - Add a regression test intended to cover the “socket end while body backpressures/pauses parsing” scenario.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| lib/dispatcher/client-h1.js | Makes Parser.finish() resilient to being called while parsing is paused by resuming llhttp first. |
| test/client.js | Adds a regression test for completing a response when the socket ends while the body causes backpressure. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let body = '' | ||
| data.body.setEncoding('utf8') | ||
| data.body.on('data', (chunk) => { | ||
| body += chunk | ||
| }) | ||
| data.body.on('end', () => { | ||
| t.strictEqual(body, payload.toString()) | ||
| }) | ||
|
|
||
| setImmediate(() => { | ||
| data.body.resume() | ||
| }) |
ronag
left a comment
There was a problem hiding this comment.
I don' think the test actually test for the fix. Please confirm that the test fails without the fix.
if (this.paused) {
|
When a response body applies backpressure, the HTTP/1 llhttp parser is left paused. If the peer then closes the socket, onHttpSocketEnd calls parser.finish(), which asserted `!this.paused` and crashed the process with an uncatchable AssertionError from the socket 'end' handler. finish() now resumes a paused parser and drains the socket through it so the response completes correctly across all body framings: - Content-Length / chunked bodies reach on_message_complete during execute() (driving the parser past the body is required; calling llhttp_finish() alone leaves them hanging). - EOF-delimited bodies (no length) can't complete via execute() and re-pause, so we resume once more and let llhttp_finish() deliver the EOF completion. - Backpressure is advisory here (onData keeps buffering delivered bytes), so we resume across pauses and loop until the socket buffer is empty, rather than parsing only a single read(). Truncated responses still surface as errors, never a false completion: short Content-Length -> UND_ERR_RES_CONTENT_LENGTH_MISMATCH, unterminated chunked -> HTTPParserError. Fixes nodejs#5360. Supersedes nodejs#5364, whose fix hangs Content-Length bodies (resume without execute) and whose test passes without the fix (the 'data' listener switches the stream to flowing mode so the parser never pauses). The tests here keep the body unconsumed until after FIN and cover Content-Length/EOF completion plus Content-Length/chunked truncation; all four fail against the unpatched parser. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1335ea8 to
5132ed8
Compare
|
Updated in The regression now keeps the response body unconsumed until after the socket has ended, so the HTTP/1 parser actually remains paused when Red/green proof locally:
Also ran:
|
| llhttp.llhttp_resume(this.ptr) | ||
| this.paused = false | ||
| this.execute(this.socket.read() || EMPTY_BUF) | ||
| if (this.paused || this.ptr == null) { |
There was a problem hiding this comment.
Is this condition really required?
Signed-off-by: marko1olo <barsukdana@gmail.com>
5132ed8 to
5784712
Compare
|
Good point. I removed that extra early-return condition in Re-ran locally: |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #5364 +/- ##
==========================================
+ Coverage 93.25% 93.26% +0.01%
==========================================
Files 110 110
Lines 36738 36757 +19
==========================================
+ Hits 34259 34281 +22
+ Misses 2479 2476 -3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
|
||
| const { llhttp } = this | ||
|
|
||
| if (this.paused) { | ||
| llhttp.llhttp_resume(this.ptr) | ||
| this.paused = false | ||
| this.execute(this.socket.read() || EMPTY_BUF) | ||
| } |
There was a problem hiding this comment.
the approach here makes sense — resuming the parser before finishing handles the backpressure case cleanly without changing the existing finish path.
one thought: the this.execute(this.socket.read() || EMPTY_BUF) call after resuming will try to consume whatever is left in the socket buffer. if the socket has already ended (which it has in this scenario), socket.read() returns null so we fall through to EMPTY_BUF. should be fine since the FIN is what triggers finish() in the first place, but worth noting that this only works correctly because onHttpSocketEnd is called after the kernel has drained the read buffer.
|
Follow-up on the remaining red check here: the failing job is I split the HTTP/2 timeout test teardown fix into #5383 so this PR can stay scoped to parser finish behavior. |
Fixes #5360.
When a response body applies backpressure, the HTTP/1 parser can be left paused. If the peer then closes the socket,
onHttpSocketEndcallsparser.finish(), which previously asserted that the parser was not paused and could crash the process from the socketendhandler.This resumes the paused llhttp parser before finishing it, then lets the existing finish path handle EOF and completion normally.
Tests:
node --expose-gc --test --test-name-pattern "socket end completes response when body is paused by backpressure" test/client.jsnpm run lint -- --no-cache lib/dispatcher/client-h1.js test/client.jsgit diff --check