http: emit drain on socket takeover and avoid stale HWM reuse
When OutgoingMessage transitions from pre-socket buffering (Path B) to socket-connected writing (Path A), the backpressure domain changes. The OM should emit drain at this transition to signal that its buffer is clear and the caller can resume writing under the socket's own backpressure.
Previously, _flush() gated drain emission on writableLength === 0 (which includes socket.writableLength). This conflated the OM's buffer state with the socket's kernel write queue. When the socket had a higher writableHighWaterMark than the OM (e.g., agent reuses a socket from a prior request with a different HWM), the socket was never backpressured, never emitted drain — permanent deadlock.
Approach
This PR makes two changes to address the problem:
1. Drain fix in _flush() (the must-have): Once _flushOutput() completes and all buffered data has been handed to the socket, emit drain unconditionally. From this point, the socket enforces its own backpressure via socket.write() return values. We don't wait for socket.writableLength to reach zero because that's the socket's backpressure domain — not the OM's. If the socket is full, the very next write() through Path A will return false and the user stops writing again naturally.
2. Agent HWM mismatch check (defense in depth): Don't reuse a pooled socket in http.Agent if its writableHighWaterMark differs from the request's highWaterMark. This ensures the user's backpressure threshold is respected for users of the built-in http.Agent. We chose to include this because highWaterMark on a connected TCP socket cannot be changed after creation (the underlying kernel buffer is not exposed via Node's TCP handle, and _writableState.highWaterMark is cosmetic since state.length stays 0 for connected sockets). Since there's no way to make a reused socket respect a different HWM, the most resilient approach is to not reuse it. For requests to the same host:port it's rare that different highWaterMark values are used, so socket reuse still happens for the vast majority of connections.
The drain fix alone prevents the deadlock universally (including custom agents and createConnection). The agent check additionally ensures correct backpressure behavior — not just absence of deadlock — for the common case.
Deadlock reproduction (requires reduced TCP send buffer)
const http = require('http');
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 HWM, < 10MB socket HWM, > 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');
});
sysctl -w net.ipv4.tcp_wmem="4096 16384 65536"
node repro.js # DEADLOCK without fix, drain fires with fix
Fixes: https://github.com/nodejs/node/issues/64680
Refs: https://github.com/nodejs/node/pull/64653
Refs: https://github.com/nodejs/node/pull/62936