Skip to content

fix(h1): complete paused parser on socket end instead of crashing#2

Merged
ronag merged 6 commits into
masterfrom
fix/finish-paused-parser-on-socket-end
Jun 6, 2026
Merged

fix(h1): complete paused parser on socket end instead of crashing#2
ronag merged 6 commits into
masterfrom
fix/finish-paused-parser-on-socket-end

Conversation

@ronag

@ronag ronag commented Jun 6, 2026

Copy link
Copy Markdown
Member

Summary

Fixes an uncatchable process crash in the HTTP/1 client when a response body applies backpressure and the peer then closes the socket.

When the body stream pauses (backpressure), the llhttp parser is left paused. If the peer sends FIN, onHttpSocketEnd calls parser.finish(), which asserted !this.paused — throwing an uncatchable AssertionError straight from the socket 'end' handler:

AssertionError [ERR_ASSERTION]: assert(!this.paused)
    at Parser.finish (lib/dispatcher/client-h1.js)
    at Socket.onHttpSocketEnd (lib/dispatcher/client-h1.js)

This is upstream issue nodejs/undici#5360.

Fix

finish() now resumes a paused parser and drives it forward via execute() so on_message_complete fires and the response completes normally.

This goes one step further than the upstream PR nodejs/undici#5364: just resuming and calling llhttp_finish (without executing) stops the crash, but llhttp never fires on_message_complete, so the body stream hangs forever when the consumer reads it after FIN. I verified this against nodejs#5364's exact patch — the crash becomes a hang.

Verification

  • Confirmed the crash reproduces on this fork before the fix.
  • With the fix, the full body is delivered + end fires (verified 64 KiB round-trip).
  • Added a regression test that keeps the body unconsumed until after FIN, so it crashes without the fix and passes with it.
  • ⚠️ Note: fix: finish paused HTTP/1 parser on socket end nodejs/undici#5364's own regression test passes without the fix — attaching a 'data' listener immediately puts the stream in flowing mode, so the parser never pauses. (GitHub's Copilot reviewer flagged the same on that PR.) The test here avoids that by only attaching 'end' initially and deferring consumption.
  • Lint clean on changed files.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Fixes an uncatchable crash in the HTTP/1 client when the response body parser is paused due to backpressure and the peer closes the socket (FIN). The change ensures the paused llhttp parser is resumed and advanced so the response can complete normally instead of asserting/crashing.

Changes:

  • Update Parser.finish() to handle the paused-parser state by resuming llhttp and executing buffered data to reach on_message_complete.
  • Add a regression test that keeps the response body unconsumed until after FIN to reproduce the crash and verify correct completion.

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 Adjusts parser finalization on socket end to safely handle a paused llhttp parser.
test/client.js Adds regression coverage for FIN arriving while the response parser is paused by backpressure.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/dispatcher/client-h1.js
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>
@ronag
ronag force-pushed the fix/finish-paused-parser-on-socket-end branch from 49c0756 to 2aa5739 Compare June 6, 2026 11:24
ronag and others added 5 commits June 6, 2026 17:57
The `https-pem` dev dependency generates a 1024-bit RSA key, which Node 25's
OpenSSL rejects with `ERR_SSL_EE_KEY_TOO_SMALL`, failing the https tests.
Switch to `@metcoder95/https-pem` (2048-bit, same API), matching upstream
undici (nodejs#4395).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A prior upstream sync applied the RFC 7230 connection-header parsing change
(nodejs#4775) to lib/core/request.js but not its test update. Sync the
test: `connection: 'asd'` is now valid (a single connection-option token), so
assert on `'invalid header with spaces'` instead, and add the content-length
validation cases (plan 10 -> 13).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When `autoSelectFamily` is enabled and every attempted address fails with
ETIMEDOUT, Node emits an `AggregateError` on the socket 'error' event, which
was forwarded raw to the caller. Wrap it in a `ConnectTimeoutError` (preserving
the original on `.cause`) so callers see a consistent error regardless of which
timer wins the race. Downstreamed from nodejs#5329, with its
test/connect-timeout.js regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Downstreamed from nodejs#5332.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`writeIterable` iterates the request body with `for await`. For a synchronous
iterable whose writes never trigger backpressure (the kernel keeps draining the
socket), the loop spins the microtask queue and never yields to the event
loop's I/O phase, so socket 'error'/'close' events are never observed and an
aborted request loops forever. This starved the event loop and made
test/client.js's "regular iterator error from server closes early" hang
intermittently (taking unrelated tests down with it).

Yield to the macrotask phase every 1024 consecutive backpressure-free writes so
those events can fire and the existing socket[kError] check terminates the loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ronag
ronag merged commit 8ed6c2b into master Jun 6, 2026
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.

2 participants