http: emit drain on socket takeover and avoid stale HWM reuse - #64991
http: emit drain on socket takeover and avoid stale HWM reuse#64991trivenay wants to merge 1 commit into
Conversation
|
Review requested:
|
afb656c to
b307aa7
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #64991 +/- ##
==========================================
+ Coverage 90.27% 90.29% +0.01%
==========================================
Files 762 759 -3
Lines 247534 247624 +90
Branches 46694 46689 -5
==========================================
+ Hits 223457 223587 +130
+ Misses 15529 15512 -17
+ Partials 8548 8525 -23
🚀 New features to boost your workflow:
|
| debug('skip reuse, HWM mismatch (socket=%d, request=%d)', | ||
| socket.writableHighWaterMark, options.highWaterMark); | ||
| socket.destroy(); | ||
| socket = null; |
There was a problem hiding this comment.
Why not just update the socket hwm?
There was a problem hiding this comment.
@ronag Thanks for the suggestion. My initial thought was that this would be fragile since HWM on a connected socket operates at the TCP/libuv layer and changing it after creation might not reliably affect backpressure behavior. But after testing, it does work — state.length accumulates through the HTTP write path and the HWM check is effective:
# With reduced kernel buffer + HWM synced on reused socket:
sysctl -w net.ipv4.tcp_wmem="4096 4096 16384"
Socket HWM before sync: 10485760
Socket HWM after sync: 65536
write(2MB): false
drain fired — HWM sync works
# Also verified on Path A (socket connected, write after delay):
Before write: state.length= 0 HWM= 10240
After write: state.length= 51302 returned: false
drain fired
Will update the PR to sync the socket HWM instead of destroying it. The drain fix in _flush() is still needed separately for custom agents and createConnection where we can't control the socket's HWM.
b307aa7 to
ce69fc9
Compare
| } else if (this[kNeedDrain]) { | ||
| // _flushOutput() handed all buffered data to the socket; the OM's | ||
| // backpressure concern is resolved. Subsequent writes go directly | ||
| // to the socket where socket-level backpressure takes over. |
There was a problem hiding this comment.
I'm a little skeptical but I don't think it's super harmful. The thing here is this.writableLength === 0 basically means that the socket doesn't want more data, and ignoring that will cause us to always over buffer. I'm not sure I understand what this is fixing. Semantically it's wrong...
There was a problem hiding this comment.
This fixes the deadlock from #64680 — the previous fix (#64653) didn't cover cases where the socket comes from agent reuse or custom createConnection with a different HWM that we can't control (detailed repro and analysis here). At the changeover, _flushOutput() moves all bytes from the OM buffer to the socket. The old check requires writableLength === 0 before emitting drain — but after flush, writableLength is just socket.writableLength (OM buffer is empty). If the socket has a higher HWM than the OM, it accepted all the data without backpressure (socket.write() returned true) — so it will never emit drain on its own. And the kernel may not have consumed the bytes yet, leaving socket.writableLength > 0. No one triggers drain → deadlock. We reproduced this with reduced TCP buffer settings and confirmed this change resolves it.
The logic: once Path A (socket) takes over, it should also own backpressure. So we emit drain once all bytes move from OM to socket — the next write goes directly to the socket which will return false if it's full. No over-buffering, just one write attempt that the socket correctly handles.
this.writableLength < hwm wouldn't fix it because after flush, socket.writableLength can exceed hwm (data queued in libuv while kernel is slow), so the condition still fails and drain never fires.
There was a problem hiding this comment.
Happy to discuss further — if you think this.writableLength < hwm is more appropriate for other reasons, we can explore that. My concern is that after flush, socket.writableLength can be larger than hwm while the kernel drains, which would prevent drain from firing and leave us in the same deadlock. But if there's a scenario where unconditional drain causes issues beyond the one-extra-write that Path A immediately backpressures, I would love to understand it.
Also I have addressed your earlier feedback on the agent side — replaced the socket destroy with a HWM. Please have a look when possible.
There was a problem hiding this comment.
sounds like there is a bigger problem:
socket.writableLength can be larger than hwm while the kernel drains, which would prevent drain from firing and leave us in the same deadlock.
This sounds wrong, and if this is the case then that's what we should fix. writableLength is allowed to be larger than hwm and drain should fire.
There was a problem hiding this comment.
Thinking about this more — the only remaining case is when a custom agent or createConnection provides a socket with a different HWM that we can't control. With #64653 we synced the OM's kHighWaterMark to the user's value, and in this PR we sync the reused socket's HWM to match. So the only gap is externally-provided sockets.
For that case: _flushOutput() already returns the last socket.write() return value. If it returns false, the socket is backpressured and will emit drain — which ondrain() catches and emits on the request for the user's listener. So we don't need to do anything. If it returns true, the socket won't emit drain on its own — so we need to emit it ourselves.
const ret = this._flushOutput(socket);
if (this.finished) {
this._finish();
} else if (this[kNeedDrain] && ret !== false) {
// Socket accepted all data without backpressure — it won't emit
// drain, so we emit it since the OM buffer is now clear.
this[kNeedDrain] = false;
this.emit(drain);
}This way we only emit drain when the socket genuinely won't do it itself. When the socket IS backpressured, the normal ondrain() path handles it and we don't over-buffer.
If this approach looks right, I'll update the PR with this change.
There was a problem hiding this comment.
Thanks — pushed the updated approach. _flush() now captures the return value from _flushOutput(): if ret !== false (socket accepted without backpressure, won't emit drain on its own), we emit drain; otherwise we leave it to ondrain() which handles the backpressured case normally.
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
ce69fc9 to
94a47d6
Compare
When
OutgoingMessagetransitions from pre-socket buffering (Path B) to socket-connected writing (Path A), the backpressure domain changes. The OM should emitdrainat 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 onwritableLength === 0(which includessocket.writableLength). This conflated the OM's buffer state with the socket's kernel write queue. When the socket had a higherwritableHighWaterMarkthan 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 viasocket.write()return values. We don't wait forsocket.writableLengthto reach zero because that's the socket's backpressure domain — not the OM's. If the socket is full, the very nextwrite()through Path A will returnfalseand the user stops writing again naturally.2. Agent HWM mismatch check (defense in depth): Don't reuse a pooled socket in
http.Agentif itswritableHighWaterMarkdiffers from the request'shighWaterMark. This ensures the user's backpressure threshold is respected for users of the built-inhttp.Agent. We chose to include this becausehighWaterMarkon a connected TCP socket cannot be changed after creation (the underlying kernel buffer is not exposed via Node's TCP handle, and_writableState.highWaterMarkis cosmetic sincestate.lengthstays 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 differenthighWaterMarkvalues 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)
Fixes: #64680
Refs: #64653
Refs: #62936