Skip to content

fix: finish paused HTTP/1 parser on socket end#5364

Open
marko1olo wants to merge 1 commit into
nodejs:mainfrom
marko1olo:fix-h1-backpressure-assertion
Open

fix: finish paused HTTP/1 parser on socket end#5364
marko1olo wants to merge 1 commit into
nodejs:mainfrom
marko1olo:fix-h1-backpressure-assertion

Conversation

@marko1olo
Copy link
Copy Markdown

Fixes #5360.

When a response body applies backpressure, the HTTP/1 parser can be left paused. If the peer then closes the socket, onHttpSocketEnd calls parser.finish(), which previously asserted that the parser was not paused and could crash the process from the socket end handler.

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.js
  • npm run lint -- --no-cache lib/dispatcher/client-h1.js test/client.js
  • git diff --check

Signed-off-by: marko1olo <barsukdana@gmail.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 calling llhttp_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.

Comment thread test/client.js
Comment on lines +1222 to +1233
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()
})
Copy link
Copy Markdown
Member

@ronag ronag left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don' think the test actually test for the fix. Please confirm that the test fails without the fix.

@ronag
Copy link
Copy Markdown
Member

ronag commented Jun 6, 2026

  1. Resuming without executing leaves the response incomplete (crash → hang). llhttp_resume() + llhttp_finish() stops the assertion crash, but when the parser was paused inside on_body, llhttp_finish does not fire on_message_complete, so request.onComplete is never called and the body stream hangs forever once the consumer reads it. Driving the parser forward the way resume() does fixes it:

if (this.paused) {
llhttp.llhttp_resume(this.ptr)
this.paused = false
this.execute(this.socket.read() || EMPTY_BUF) // drive parser to on_message_complete
}

  1. The regression test passes without the fix. As Copilot noted, attaching a 'data' listener immediately switches the stream to flowing mode, so the parser never pauses. A reliable repro keeps the body unconsumed until after FIN (attach only 'end' initially, defer 'data').

ronag added a commit to nxtedition/undici that referenced this pull request Jun 6, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Uncatchable AssertionError: assert(!this.paused) on socket end

3 participants