Skip to content

http: emit drain on socket takeover and avoid stale HWM reuse - #64991

Open
trivenay wants to merge 1 commit into
nodejs:mainfrom
trivenay:http-agent-hwm-no-reuse
Open

http: emit drain on socket takeover and avoid stale HWM reuse#64991
trivenay wants to merge 1 commit into
nodejs:mainfrom
trivenay:http-agent-hwm-no-reuse

Conversation

@trivenay

@trivenay trivenay commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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: #64680
Refs: #64653
Refs: #62936

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/http
  • @nodejs/net

@nodejs-github-bot nodejs-github-bot added http Issues or PRs related to the http subsystem. needs-ci PRs that need a full CI run. labels Aug 3, 2026
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
@trivenay
trivenay force-pushed the http-agent-hwm-no-reuse branch from afb656c to b307aa7 Compare August 3, 2026 22:29
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.25%. Comparing base (f00fb75) to head (b307aa7).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #64991      +/-   ##
==========================================
- Coverage   90.27%   90.25%   -0.02%     
==========================================
  Files         762      762              
  Lines      247534   247548      +14     
  Branches    46694    46689       -5     
==========================================
- Hits       223457   223424      -33     
- Misses      15529    15541      +12     
- Partials     8548     8583      +35     
Files with missing lines Coverage Δ
lib/_http_agent.js 96.19% <100.00%> (+0.06%) ⬆️
lib/_http_outgoing.js 97.64% <100.00%> (+<0.01%) ⬆️

... and 34 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

http Issues or PRs related to the http subsystem. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

http: highWaterMark not respected when agent reuses socket with different HWM

2 participants