Fix chunked write() and beginWrite() calls - #1940
Merged
uNetworkingAB merged 1 commit intoSep 3, 2026
Merged
Conversation
Fix chunked write() and beginWrite() calls
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes uNetworking/uWebSockets#1939: HTTP/1.1 chunked
write()/beginWrite()/end()did not emit complete chunks as defined by RFC 9112 §7.1.write()prefixed aCRLFand omitted the chunk-data trailer. That leadingCRLFwas reused as (1) the header/body separator on the first call and (2) the previous chunk’s trailer on later calls.beginWrite()already wrote the header terminator, so the firstwrite()added a secondCRLF. A spec-compliant parser then treated0x0Das a chunk-size digit (chunk hex-length char not a hex digit: 0xd).On a long-lived stream that never calls
end()(SSE), the lastwrite()had no following operation to supply the deferred trailer, so the last event was not a complete chunk and was not delivered.This PR makes each
write()emit a completechunk, makesbeginWrite()only terminate headers, and makesend()emit onlylast-chunk+ emptytrailer-section+ finalCRLF.Spec (why the old bytes were illegal)
RFC 9112 §2.1 — headers end with one empty line:
RFC 9112 §7.1 — the body, when
Transfer-Encoding: chunked, is:A data chunk is therefore
HEXDIG+ CRLF chunk-data CRLF. The chunk-dataCRLFis part of this chunk, not a prefix of the next write.last-chunkwith an empty trailer is exactly0 CRLF+ (no field-lines) +CRLF→0\r\n\r\n.Older grammar, same bytes: RFC 7230 §4.1 (
chunk-data CRLF,last-chunk,trailer-part CRLF).What was on the wire
Before (deferred trailer / “CRLF before”):
A finished
write(); write(); end()happened to look legal because the next call’s leadingCRLFclosed the previous chunk. The current chunk was never closed until the nextwrite()orend().beginWrite(); write("foo"); end()(issue 1939):That extra
CRLFis a chunk-size line whose first byte isCR(0x0D). Curl is correct to reject it.Never-ending / SSE:
beginWrite(); write(event N);and wait. Event N issize CRLF datawith no chunk-dataCRLF. A compliant HTTP/1.1 decoder must not deliver those bytes until the trailer arrives (RFC 9112 §7.1.1:chunk-datalength ischunk-size, thenCRLF). The SSE parser never sees a finished frame.After:
write(); end()withoutbeginWrite()is byte-identical to the old successful path.beginWrite(); write(); end()now matches that path (13 bytes of body for"foo", not 15).Additional Context
Why the trailer is unconditional (backpressure)
AsyncSocket::write’sfailedflag means backpressure, not “bytes were dropped”. Non-optional writes always accept the buffer: leftover bytes are appended and the call returns{length, true}(src/AsyncSocket.h, already-buffered path and shortus_socket_write).If
writeChunk()skipped the trailer whenfailedwas true, a 64 KiBwrite()that filled the socket would queue10000 CRLF <data>with noCRLF, then the nextwrite("foo")/end()would start a new chunk. A decoder reading size0x10000would then see3instead ofCRLF.The trailer
Super::write("\r\n", 2)in that state also appends to the same per-socket buffer.write()still returnsfalseso callers back off.What about
tests/ChunkedEncoding.cpp?This file does not change
src/ChunkedEncoding.h. It only fixes the encoder used by the existing parser brute-force test.The fixture listed two concatenated chunked bodies, with
""meaning last-chunk. It encoded each empty element as:That is last-chunk
0\r\n+ empty trailerCRLF+ a surplusCRLF.RFC 9112 §7.1 last-chunk + empty
trailer-section+ finalCRLFis0\r\n\r\n. The parser (already, on master) finishes the firstchunked-bodythere and correctly leaves the surplusCRLF.Byte count on the old fixture (why CI printed
remaining chunk:76):22 CRLF+ 34 +CRLFf CRLF+ 15 +CRLF0\r\n\r\n\r\nParser consumes last-chunk + trailer = 5 bytes of the
0\r\n\r\n\r\n, not 7. Consumed = 40 + 20 + 5 = 65. Remaining = 141 − 65 = 76 = surplusCRLF+ 74-byte second body.The test then required leftover
== 0 || == 74. That 74 assumed the parser would swallow the illegal extraCRLF. It must not: thatCRLFis not part of the firstchunked-body. After the first body, leftover 76 is the spec-correct remainder for the old fixture.This PR encodes empty elements as
0\r\n\r\nonly. First body becomes 65 bytes, second 72, leftover after the first parse is exactly the second body.consumeChunkEncodingnow stops onstate == 0(onechunked-bodyfinished) instead of a magic leftover length that encoded the surplusCRLF.testWithoutTrailer()already emitted0\r\n\r\nvia the genericsize CRLF data CRLFpath for empty data; it is unchanged.