http: count buffered outgoing data in bytes - #66039
QuantumBreakz wants to merge 2 commits into
Conversation
`OutgoingMessage#outputSize`, and the per-connection counter updated through `_onPendingData()`, decide when the socket is paused to apply backpressure, so both are meant to hold a number of bytes. When a write is buffered instead of being handed straight to the socket, they were increased by `data.length`, which for a string is a count of UTF-16 code units rather than its size on the wire. Multi-byte bodies were therefore under-accounted. A UTF-8 response built from two byte characters was counted at half its real size, so `write()` kept reporting that there was room and the socket was paused later than it should have been. `_writeRaw()` already received the byte length its callers had computed, as `size`, but never read it. Use it, and fall back to measuring the string when it is not supplied. `_send()` prepends the header to the first string chunk, so add the header's byte length to the value handed on, otherwise the bytes it contributes are dropped from the count. Fixes: nodejs#57985 Refs: nodejs#46601 Refs: nodejs#46605 Signed-off-by: Ali Ahmed <ali.lah.aed456@gmail.com>
|
Review requested:
|
|
Welcome to Node.js, and thank you for your first contribution! Before review, please take a moment to read:
Please make sure every commit is signed off. For a first pull request, GitHub Actions require collaborator approval and Jenkins CI must be started by a collaborator or triager, so an initial wait is normal. |
ronag
left a comment
There was a problem hiding this comment.
This makes thing slower without solving a practical problem.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #66039 +/- ##
=======================================
Coverage 90.23% 90.23%
=======================================
Files 789 789
Lines 270476 270490 +14
Branches 51751 51746 -5
=======================================
+ Hits 244052 244074 +22
+ Misses 16907 16896 -11
- Partials 9517 9520 +3
🚀 New features to boost your workflow:
|
Single byte encodings have a byte length equal to the string length, so measuring them with `Buffer.byteLength()` is wasted work. Every write this module makes internally — the header block, the chunk size lines and the trailer — is latin1, so they were all paying for a scan that could only return `data.length`. Check for those encodings before measuring. This takes the overhead of the previous commit on a chunked write from ~13% to ~5%, and leaves utf8 bodies, which do have to be measured, unaffected. Refs: nodejs#57985 Signed-off-by: Ali Ahmed <ali.lah.aed456@gmail.com>
|
@ronag You're right about the cost, and I measured it rather than guessing. A I've pushed a second commit that skips the measurement for single byte On the practical side, two things I'd like your read on.
More concretely, a queued response overshoots its high water mark, because
So a response serving CJK holds 3x the memory it was configured for. So, directly: is that overshoot acceptable to you? If it is, I'll close this |
OutgoingMessage#outputSize, and the per-connection counter updated through_onPendingData(), exist to decide when the socket should be paused to applybackpressure. The comment on
outputSizesays as much:When a write is buffered rather than passed straight to the socket, both were
increased by
data.length. For a string that is a count of UTF-16 code units,not the number of bytes that will go on the wire, so multi-byte bodies were
under-accounted — 2x for two byte characters, 3x for most CJK text.
The visible consequence is that
write()keeps reporting there is room whenthe buffer is already past the high water mark, and
updateOutgoingData()inlib/_http_server.jspauses the socket later than it should:The
sizeargument_writeRaw()has accepted the byte length its callers already computed, assize, since #46601 — but never read it, which is what #57985 reports:This uses that value and falls back to measuring the string when it is not
supplied, since the non-chunked path computes
lenlazily and often leaves itundefined.The history suggests the dead parameter was an oversight rather than a
deliberate no-op: #46601 added
byteLengthto_send()andsizeto_writeRaw()while reading neither, and #46605 — which would have used it, asthis.outputSize += size ?? data.byteLength ?? data.length— was openedagainst an earlier tree and closed after a review comment about
TypedArray.prototype.length, which addressed its stated justification ratherthan the accounting. The plumbing landed; the consumer did not.
The prepended header
One detail worth review.
_send()prepends the header to the first stringchunk:
After that, a
byteLengthmeasured by the caller describes only the body, soadding it to
outputSizeunchanged would drop the header's bytes from thecount entirely. The header's byte length is therefore added to it. Header
values are restricted to
\x00-\xffbycheckInvalidHeaderChar(), so nosurrogate pair can straddle the join and the two lengths are additive.
The other branch of that
if, which queues the header as its own entry, wasalready correct — it is written as
latin1, where.lengthis the bytelength — and is untouched.
Verification
Added
test/parallel/test-http-outgoing-buffer-bytelength.js. Everyassertion was checked against an unpatched build of this same tree: the
fix-dependent cases each fail with exactly the code-unit value (
119vs219for the header case,
206/225for chunked,8vs4for hex and base64),and the ASCII,
Bufferandlatin1cases pass unchanged both before andafter, which is what pins down that those paths do not move.
All 768
test-http*/test-https*parallel tests pass.Performance
The added
Buffer.byteLength()call only runs on the buffered path, and onlywhen a caller did not already supply the length. Instrumenting a keep-alive
server shows 83.3% of
_writeRaw()calls go straight to the socket and neverreach the accounting at all.
End to end throughput with a multi-byte body on every response is unchanged
(3 runs each, 16 connections, keep-alive):
A micro-benchmark that forces every write to buffer does show the extra
measurement (~25% on that operation alone), but that is the path that only
runs once the message is already queuing.
Notes
write()can now returnfalsewhere it previously returnedtruefor amulti-byte body, which is the point of the fix. I do not think that warrants
semver-major, but flagging it for reviewers.
AI disclosure
Per the project's AI use policy:
this patch and its test were written with the assistance of an AI coding agent
(Claude Code). What I did to verify the output myself, rather than trusting it:
anything, and confirmed against the source that
sizeis still unused onmain.mainlocally and confirmed the new test fails there without the patch(
100 !== 200) and passes with it, so the test is known to catch the defectrather than merely describe the implementation.
fix-dependent cases fail with exactly the UTF-16 code-unit value (
119vs219for the header case,206/225for chunked,8vs4for hex andbase64), while the ASCII,
Bufferandlatin1cases pass both before andafter — which is what pins down that those paths do not move.
parallelandsequentialsuites: 5038 pass, 4 fail. Those 4fail identically on an unpatched binary built from the same tree, so they are
pre-existing and specific to my build configuration (no Rust/FFI, OpenSSL CA
env), not to this change.
buffered-versus-direct ratio, and A/B'd throughput between the two binaries.
checkInvalidHeaderChar()restricts header values to
\x00-\xff, which is what makes the two bytelengths additive.
I can explain and defend both the change and the test during review.
Fixes: #57985
Refs: #46601
Refs: #46605