http: propagate highWaterMark to ClientRequest OutgoingMessage#64653
http: propagate highWaterMark to ClientRequest OutgoingMessage#64653trivenay wants to merge 1 commit into
Conversation
|
Review requested:
|
`http.request({ highWaterMark })` passes the value to the TCP socket
via createConnection() but does not set it on the OutgoingMessage
internal kHighWaterMark. OutgoingMessage._writeRaw() has two mutually
exclusive write paths:
Path A (socket connected): conn.write() — uses socket HWM ✓
Path B (no socket yet): outputSize < this[kHighWaterMark] — uses
OutgoingMessage own default (64 KB) ✗
Because the OutgoingMessage constructor already accepts
options.highWaterMark, the fix is to set kHighWaterMark from the
user options after they are parsed in the ClientRequest constructor.
This resolves two symptoms:
1. write() returning the wrong boolean for pre-socket writes (the
user highWaterMark was silently ignored on all Node versions).
2. A deadlock on Node >= 24.16.0 where the incorrect false return
sets kNeedDrain, but drain never fires because the socket was
never backpressured (introduced by the stricter drain gate in
nodejs#62936).
Signed-off-by: Trivikram Narayanan Kamasamudram <trivenay@amazon.com>
Fixes: nodejs#64645
Refs: nodejs#62936
b1249d0 to
a008632
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #64653 +/- ##
=======================================
Coverage 90.12% 90.13%
=======================================
Files 741 741
Lines 242091 242142 +51
Branches 45563 45563
=======================================
+ Hits 218180 218250 +70
+ Misses 15430 15394 -36
- Partials 8481 8498 +17
🚀 New features to boost your workflow:
|
This comment was marked as outdated.
This comment was marked as outdated.
pimterry
left a comment
There was a problem hiding this comment.
I think there's a missing case to watch for here with agents or createConnection. In those cases, the request often doesn't directly create or own the socket - an agent might create it independently, it might be a reuse of an existing socket from another request, or it could be a custom non-socket stream from anywhere, and in each case it may have its own independent highWaterMark option already set.
I'm not sure but I strongly suspect this won't properly handle that, since it's assuming that the request owns the socket and controls the HWM setting. I think if you reproduce the HWM mismatch in one of those contexts there's still a route to a deadlock with no drain in some cases.
I won't block this - it is still a clear improvement on the current state in the meantime, but if you have time to investigate and potentially fix that flow en route that would be fantastic.
|
@pimterry Thanks for the callout — you were right. I investigated the agent socket reuse case and confirmed both a deadlock and a functional bug. 1. Deadlock with reused socketFor the deadlock to occur, three conditions must align:
When all three hold, drain is never emitted from either path and the request hangs permanently. Minimal reproduction: const http = require('http');
// Server that delays reading — TCP send buffer fills
const server = http.createServer((req, res) => {
setTimeout(() => { req.resume(); req.on('end', () => res.end('ok')); }, 30000);
}).listen(0, () => {
const port = server.address().port;
const agent = new http.Agent({ keepAlive: true });
// Request A: creates socket with HWM=10MB
http.request({ port, method: 'POST', agent, highWaterMark: 10 * 1024 * 1024 }, (res) => {
res.resume();
res.on('end', () => {
setTimeout(() => {
// Request B: default HWM (64KB), reuses socket (HWM=10MB)
const req = http.request({ port, method: 'POST', agent });
// Write 2MB: > 64KB OM, < 10MB socket, > kernel TCP buffer
const r = req.write(Buffer.alloc(2 * 1024 * 1024));
if (!r) {
setTimeout(() => { console.error('DEADLOCK'); process.exit(1); }, 15000);
req.on('drain', () => req.end());
} else {
req.end();
}
}, 100);
});
}).end('x');
});# Requires reduced TCP send buffer:
sysctl -w net.ipv4.tcp_wmem="4096 16384 65536"
node repro.js # → DEADLOCK2. highWaterMark silently not respected (no deadlock required)Even without the deadlock, the user's const http = require('http');
const server = http.createServer((req, res) => {
req.resume();
req.on('end', () => res.end('ok'));
}).listen(0, () => {
const port = server.address().port;
const agent = new http.Agent({ keepAlive: true });
// Request A: creates socket with HWM=1MB
http.request({ port, method: 'POST', agent, highWaterMark: 1024 * 1024 }, (res) => {
res.resume();
res.on('end', () => {
setTimeout(() => {
// Request B: HWM=10KB, gets reused socket (HWM=1MB)
const req = http.request({ port, method: 'POST', agent, highWaterMark: 10 * 1024 });
req.on('socket', () => {
setTimeout(() => {
console.log('Requested HWM: 10KB');
console.log('Socket HWM:', req.socket.writableHighWaterMark); // 1MB!
const r = req.write(Buffer.alloc(50 * 1024));
console.log('write(50KB):', r, '— expected false, got true');
req.end();
req.on('response', (r2) => { r2.resume(); r2.on('end', () => server.close()); });
}, 10);
});
}, 100);
});
}).end('x');
});User requests 10KB backpressure but gets 1MB from the reused socket. 3. Fix options
Changing a pre-existing socket's HWM is not feasible — I'd recommend option 2: don't reuse if HWM differs. For requests to the same host:port, it's rare that different If we agree on the approach, I'll raise a follow-up PR for this. |
Thanks for looking into this @trivenay, yes a follow up PR would be great. Unfortunately, option 2 isn't possible: we can change the default agent, but users can provide any agent which might implement their own pooling in any unknown way. Similarly I think that's OK though! Looking for more closely at the docs: this isn't actually defined explicitly as an option for In reality, many socket options will already be ignored if the socket is not managed by the request. I think we should:
Ah that said, I think mirroring the socket HWM isn't sufficient: if write() return false before the socket arrives based on the initial HWM option, we need to drain correctly even if a socket arrives with a different HWM later. We will need a separate drain fix somewhere to handle that. |
http.request({ highWaterMark })passes the value to the TCP socket viacreateConnection()but does not set it on theOutgoingMessage's internalkHighWaterMark.OutgoingMessage._writeRaw()has two mutually exclusive write paths:conn.write()— uses socket's HWM (correct)outputSize < this[kHighWaterMark]— uses OutgoingMessage's own default (64 KB) (incorrect)Because the
OutgoingMessageconstructor already acceptsoptions.highWaterMark, the fix is to setkHighWaterMarkfrom the user's options after they are parsed in theClientRequestconstructor.This resolves two symptoms:
write()returning the wrong boolean for pre-socket writes (the user'shighWaterMarkwas silently ignored on all Node versions).falsereturn setskNeedDrain, butdrainnever fires because the socket was never backpressured (introduced by the stricter drain gate in http: emit 'drain' on OutgoingMessage only after buffers drain #62936).Test results (compiled from source)
Minimal reproduction
node -e "require('http').createServer((req,res)=>{req.resume();req.on('end',()=>res.end('ok'))}).listen(9001)"write()returnsfalse(wrong)false(wrong)true(correct)Fixes: #64645
Refs: #62936