http: propagate highWaterMark to ClientRequest OutgoingMessage - #64653
Conversation
|
Review requested:
|
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.10% -0.02%
==========================================
Files 741 741
Lines 242091 242121 +30
Branches 45563 45594 +31
==========================================
- Hits 218180 218165 -15
- Misses 15430 15435 +5
- Partials 8481 8521 +40
π 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
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: Naman Trivedi <trivenay@amazon.com>
Fixes: nodejs#64645
Refs: nodejs#62936
a008632 to
5a5b3ce
Compare
|
Force-pushed to fix a mistake in the Signed-off-by line. No code changes. Could reviewers please re-approve? Thanks! |
This comment was marked as outdated.
This comment was marked as outdated.
|
Landed in fe5037b |
`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
#62936).
Signed-off-by: Naman Trivedi <trivenay@amazon.com>
Fixes: #64645
Refs: #62936
PR-URL: #64653
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Reviewed-By: Tim Perry <pimterry@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Luigi Pinca <luigipinca@gmail.com>
When OutgoingMessage transitions from pre-socket buffering (Path B) to socket-connected writing (Path A), the backpressure domain changes β subsequent writes go directly to the socket, which enforces its own backpressure via socket.write() return values. The OM should emit drain at this transition point to signal that its buffer is clear and the caller can resume writing under the socket backpressure regime. Previously, _flush() gated drain emission on writableLength === 0 which included socket.writableLength. This conflated two independent backpressure domains: the OM pre-socket buffer and the socket kernel write queue. When the socket had a higher writableHighWaterMark than the OM (e.g. agent-reused socket from a prior request), the socket was never backpressured and never emitted drain, causing a permanent deadlock. Additionally, avoid reusing a pooled socket in http.Agent when its writableHighWaterMark differs from the request highWaterMark, so that the user backpressure threshold is respected for the common case of the built-in Agent. Signed-off-by: Naman Trivedi <trivenay@amazon.com> Fixes: nodejs#64680 Refs: nodejs#64653 Refs: nodejs#62936
`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
#62936).
Signed-off-by: Naman Trivedi <trivenay@amazon.com>
Fixes: #64645
Refs: #62936
PR-URL: #64653
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Reviewed-By: Tim Perry <pimterry@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Luigi Pinca <luigipinca@gmail.com>
When OutgoingMessage transitions from pre-socket buffering (Path B) to socket-connected writing (Path A), the backpressure domain changes β subsequent writes go directly to the socket, which enforces its own backpressure via socket.write() return values. The OM should emit drain at this transition point to signal that its buffer is clear and the caller can resume writing under the socket backpressure regime. Previously, _flush() gated drain emission on writableLength === 0 which included socket.writableLength. This conflated two independent backpressure domains: the OM pre-socket buffer and the socket kernel write queue. When the socket had a higher writableHighWaterMark than the OM (e.g. agent-reused socket from a prior request), the socket was never backpressured and never emitted drain, causing a permanent deadlock. Additionally, avoid reusing a pooled socket in http.Agent when its writableHighWaterMark differs from the request highWaterMark, so that the user backpressure threshold is respected for the common case of the built-in Agent. Signed-off-by: Naman Trivedi <trivenay@amazon.com> Fixes: nodejs#64680 Refs: nodejs#64653 Refs: nodejs#62936
When OutgoingMessage transitions from pre-socket buffering (Path B) to socket-connected writing (Path A), the backpressure domain changes β subsequent writes go directly to the socket, which enforces its own backpressure via socket.write() return values. The OM should emit drain at this transition point to signal that its buffer is clear and the caller can resume writing under the socket backpressure regime. Previously, _flush() gated drain emission on writableLength === 0 which included socket.writableLength. This conflated two independent backpressure domains: the OM pre-socket buffer and the socket kernel write queue. When the socket had a higher writableHighWaterMark than the OM (e.g. agent-reused socket from a prior request), the socket was never backpressured and never emitted drain, causing a permanent deadlock. Additionally, avoid reusing a pooled socket in http.Agent when its writableHighWaterMark differs from the request highWaterMark, so that the user backpressure threshold is respected for the common case of the built-in Agent. Signed-off-by: Naman Trivedi <trivenay@amazon.com> Fixes: nodejs#64680 Refs: nodejs#64653 Refs: nodejs#62936
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