Skip to content

Commit cbb1789

Browse files
committed
http: count buffered outgoing data in bytes
`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: #57985 Refs: #46601 Refs: #46605 Signed-off-by: Ali Ahmed <ali.lah.aed456@gmail.com>
1 parent fa10566 commit cbb1789

2 files changed

Lines changed: 209 additions & 2 deletions

File tree

‎lib/_http_outgoing.js‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,13 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL
414414
if (typeof data === 'string' &&
415415
(encoding === 'utf8' || encoding === 'latin1' || !encoding)) {
416416
data = this._header + data;
417+
if (byteLength !== undefined) {
418+
// `data` now carries the header as well, so a byte length measured by
419+
// the caller for the body chunk alone no longer describes it. Header
420+
// values are restricted to the latin1 range, so no surrogate pair can
421+
// straddle the join and the two lengths are additive.
422+
byteLength += Buffer.byteLength(this._header, encoding);
423+
}
417424
} else {
418425
const header = this._header;
419426
this.outputData.unshift({
@@ -453,8 +460,15 @@ function _writeRaw(data, encoding, callback, size) {
453460
}
454461
// Buffer, as long as we're not destroyed.
455462
this.outputData.push({ data, encoding, callback });
456-
this.outputSize += data.length;
457-
this._onPendingData(data.length);
463+
// `outputSize` and the pending data counter track how many *bytes* are
464+
// queued, so string chunks have to be measured accordingly: `.length` is a
465+
// count of UTF-16 code units and undercounts any multi-byte character.
466+
// Callers that already computed the byte length hand it over as `size` so
467+
// that the string is not measured twice.
468+
const len = size ?? (typeof data === 'string' ?
469+
Buffer.byteLength(data, encoding) : data.length);
470+
this.outputSize += len;
471+
this._onPendingData(len);
458472
return this.outputSize < this[kHighWaterMark];
459473
}
460474

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
'use strict';
2+
const common = require('../common');
3+
const assert = require('assert');
4+
const http = require('http');
5+
const { OutgoingMessage } = http;
6+
7+
// `outputSize`, and the per-connection counter fed by `_onPendingData()`,
8+
// track how many *bytes* are buffered on an outgoing message. They are used
9+
// to decide when to apply backpressure, so measuring a string by its number
10+
// of UTF-16 code units instead of its byte length made Node under-account any
11+
// multi-byte body and apply backpressure too late.
12+
// Refs: https://github.com/nodejs/node/issues/57985
13+
14+
// An OutgoingMessage without a socket buffers everything it is handed, which
15+
// is the path that does the accounting.
16+
function createMessage(options) {
17+
const msg = new OutgoingMessage(options);
18+
msg._implicitHeader = function() {};
19+
return msg;
20+
}
21+
22+
// A two byte character is counted as two bytes, not as one character.
23+
{
24+
const msg = createMessage();
25+
assert.strictEqual(msg.write('é'.repeat(100)), true);
26+
assert.strictEqual(msg.outputSize, 200);
27+
}
28+
29+
// Characters outside the BMP are a surrogate pair in UTF-16 (two code units)
30+
// and four bytes in UTF-8.
31+
{
32+
const msg = createMessage();
33+
msg.write('😀'.repeat(10));
34+
assert.strictEqual(msg.outputSize, 40);
35+
}
36+
37+
// Plain ASCII is unaffected: one code unit is one byte.
38+
{
39+
const msg = createMessage();
40+
msg.write('a'.repeat(100));
41+
assert.strictEqual(msg.outputSize, 100);
42+
}
43+
44+
// Buffers and other views already report a byte length.
45+
{
46+
const msg = createMessage();
47+
const chunk = Buffer.from('é'.repeat(100), 'utf8');
48+
assert.strictEqual(chunk.length, 200);
49+
msg.write(chunk);
50+
assert.strictEqual(msg.outputSize, 200);
51+
}
52+
53+
// The declared encoding decides the byte length: the same string is 100 bytes
54+
// as latin1 and 200 bytes as utf8.
55+
{
56+
const msg = createMessage();
57+
msg.write('é'.repeat(100), 'latin1');
58+
assert.strictEqual(msg.outputSize, 100);
59+
}
60+
61+
// Encodings that decode to fewer bytes than the string has characters are
62+
// counted by what they decode to, not by the length of the source string.
63+
{
64+
const msg = createMessage();
65+
msg.write('deadbeef', 'hex');
66+
assert.strictEqual(msg.outputSize, 4);
67+
}
68+
69+
{
70+
const msg = createMessage();
71+
msg.write('AAAAAA==', 'base64');
72+
assert.strictEqual(msg.outputSize, Buffer.byteLength('AAAAAA==', 'base64'));
73+
}
74+
75+
// The per-connection counter is fed the same byte length.
76+
{
77+
const msg = createMessage();
78+
const deltas = [];
79+
msg._onPendingData = (delta) => deltas.push(delta);
80+
msg.write('é'.repeat(100));
81+
assert.deepStrictEqual(deltas, [200]);
82+
}
83+
84+
// The header is prepended to the first string chunk, and has to be accounted
85+
// for along with it.
86+
{
87+
const header = 'HTTP/1.1 200 OK\r\n\r\n';
88+
const msg = createMessage();
89+
msg._implicitHeader = function() { this._header = header; };
90+
msg.write('é'.repeat(100));
91+
assert.strictEqual(msg.outputSize, Buffer.byteLength(header) + 200);
92+
}
93+
94+
// The header is also accounted for when the caller already knows the byte
95+
// length of the body, as is the case for chunked encoding. The bytes it
96+
// contributes must not be dropped in favour of the body length alone.
97+
{
98+
const header = 'HTTP/1.1 200 OK\r\n\r\n';
99+
const msg = createMessage();
100+
msg._implicitHeader = function() { this._header = header; };
101+
msg.chunkedEncoding = true;
102+
msg.write('é'.repeat(100));
103+
// Header + "c8" + CRLF + 200 bytes of body + CRLF.
104+
assert.strictEqual(msg.outputSize,
105+
Buffer.byteLength(header) + 2 + 2 + 200 + 2);
106+
}
107+
108+
// Chunked encoding without a header: the byte length handed to `_send()` is
109+
// the one that gets used, rather than the body being measured again.
110+
{
111+
const msg = createMessage();
112+
msg.chunkedEncoding = true;
113+
msg.write('é'.repeat(100));
114+
assert.strictEqual(msg.outputSize, 2 + 2 + 200 + 2);
115+
}
116+
117+
// Backpressure kicks in once the buffered *bytes* reach the high water mark.
118+
// 50 two-byte characters are exactly 100 bytes, so the message is full.
119+
{
120+
const msg = createMessage({ highWaterMark: 100 });
121+
assert.strictEqual(msg.writableHighWaterMark, 100);
122+
const ret = msg.write('é'.repeat(50));
123+
assert.strictEqual(msg.outputSize, 100);
124+
assert.strictEqual(ret, false);
125+
assert.strictEqual(msg.writableNeedDrain, true);
126+
}
127+
128+
// The same number of single-byte characters is only half as much data, so it
129+
// still fits.
130+
{
131+
const msg = createMessage({ highWaterMark: 100 });
132+
const ret = msg.write('a'.repeat(50));
133+
assert.strictEqual(msg.outputSize, 50);
134+
assert.strictEqual(ret, true);
135+
assert.strictEqual(msg.writableNeedDrain, false);
136+
}
137+
138+
// `writableLength` reports the buffered byte count.
139+
{
140+
const msg = createMessage();
141+
msg.write('é'.repeat(100));
142+
assert.strictEqual(msg.writableLength, 200);
143+
}
144+
145+
// Flushing hands back exactly what was accounted for, leaving the counters at
146+
// zero rather than drifting.
147+
{
148+
const msg = createMessage();
149+
let pending = 0;
150+
msg._onPendingData = (delta) => { pending += delta; };
151+
msg.write('é'.repeat(100));
152+
msg.write('😀'.repeat(10));
153+
assert.strictEqual(pending, 240);
154+
assert.strictEqual(msg.outputSize, 240);
155+
156+
const written = [];
157+
msg._flushOutput({
158+
cork() {},
159+
uncork() {},
160+
write(data, encoding) { written.push([data, encoding]); },
161+
});
162+
assert.strictEqual(msg.outputSize, 0);
163+
assert.strictEqual(pending, 0);
164+
assert.strictEqual(written.length, 2);
165+
}
166+
167+
// End to end: correcting the accounting must not change what is put on the
168+
// wire. A chunked multi-byte body goes through the corked path, where the
169+
// chunk is handed to `_send()` without a precomputed length, so this also
170+
// exercises measuring the string inside `_writeRaw()`.
171+
{
172+
const body = '😀é漢字'.repeat(2000);
173+
const server = http.createServer(common.mustCall((req, res) => {
174+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
175+
// Without a content-length the response is chunked.
176+
res.write(body);
177+
res.end();
178+
}));
179+
180+
server.listen(0, common.mustCall(() => {
181+
http.get({ port: server.address().port }, common.mustCall((res) => {
182+
assert.strictEqual(res.headers['transfer-encoding'], 'chunked');
183+
const chunks = [];
184+
res.on('data', (chunk) => chunks.push(chunk));
185+
res.on('end', common.mustCall(() => {
186+
const received = Buffer.concat(chunks);
187+
assert.strictEqual(received.length, Buffer.byteLength(body));
188+
assert.strictEqual(received.toString('utf8'), body);
189+
server.close();
190+
}));
191+
}));
192+
}));
193+
}

0 commit comments

Comments
 (0)