net: fail the write when the kernel rejects the bytes outright

Closed
RO
2mo ago
+112-27

net.Server#maxConnections looked like it wasn't capping: a refused connection kept its socket, its fd, and went on writing into the server forever. The cap is actually fine (the server closes the accepted fd), but the peer never found out it was dropped.

Repro

import net from "node:net";
const srv = net.createServer(c => c.write("hi")); // holds the one allowed socket open
srv.maxConnections = 1;
srv.listen(0, "127.0.0.1", async () => {
  const port = srv.address().port;
  const c1 = net.connect(port, "127.0.0.1");
  c1.on("error", () => {});
  await new Promise(r => c1.once("data", r)); // the quota is full

  const dropped = net.connect({ port, host: "127.0.0.1", allowHalfOpen: true });
  let errors = 0;
  dropped.on("error", () => errors++);
  await new Promise(r => dropped.once("end", r)); // the server closed its side

  const buf = Buffer.alloc(65536, 0x41);
  for (let k = 0; k < 8; k++) dropped.write(buf);
  await new Promise(r => setTimeout(r, 900));
  console.log({ errors, writable: dropped.writable, bytesWritten: dropped.bytesWritten });
  process.exit(0);
});
node v26.3.0: { errors: 1, writable: false, bytesWritten: 131072 }
bun 1.4.0:    { errors: 0, writable: true,  bytesWritten: 65536  }

Half a megabyte was handed to write() and silently discarded, the socket stayed writable forever, and its fd was never released. A dropped peer, a blockList rejection, or any peer that closes while we still have a half-open socket to write to hits this.

Cause

us_socket_write_check_error already separates a fatal send() error (EPIPE/ECONNRESET, the peer is gone) from would-block, and write_maybe_corked / internal_flush act on it by dropping the undeliverable bytes. But nothing reported the failure:

  • writeBuffered ($write) returned false, which node:net reads as "would block" and parks the stream callback for a drain.
  • on_writable dispatches the drain callback regardless of the flush result, so the parked callback completed with success, and the next chunk repeated the cycle.
  • Nothing closed the socket. The comment in write_maybe_corked assumed "the read side surfaces the reset for anyone who is only waiting", but a half-open socket has no read side left: uws switches the poll to writable-only after the peer's FIN, and the send() that failed already consumed sk_err, so EPOLLERR never arrives either.

Fix

Record the fatal send error on the socket (Flags::FATAL_WRITE_ERROR) where it is detected, and report it from writeBuffered as -1. node:net's _write and its two drain handlers turn that into errnoException(UV_EPIPE, 'write') on the write callback, which is what makes the Writable destroy the socket -- the same shape Node's afterWrite produces for a failed uv_write. The drain dispatch itself is unchanged (skipping it on fatal is what broke FIN-terminated responses on Windows before); the drain is simply how the failure now reaches JS.

The flag is cleared when the wrapper is reused for a fresh connect().

The other two $write callers are deliberately left alone: internal/debugger.ts discards the return value, and _http2_upgrade.ts's handle is an InternalSocket::UpgradedDuplex, whose write_check_error arm cannot report fatal (there is no fd to send() on -- the ciphertext goes back to a JS Duplex), so -1 is unreachable there. That invariant is now stated on the flag itself.

Separately, both onconnection drop branches now close() the refused handle instead of end()-ing it, matching Node. end() only tears the socket down if the flush path's flags happen to line up; close() releases it whatever the peer does with the FIN.

Verification

The repro now prints byte-identical output to node, as does the reporter's longer 20-connection version ({"drops":20,"clientErrors":20,"stillWritableAfterDrop":0,"serverFdDelta":0}).

New test in test/js/node/net/node-net-server.test.ts asserts a dropped peer's writes fail with write EPIPE and the socket is destroyed. It times out on main (no error is ever emitted) and passes with the fix.

Suites run against the debug build
  • test/js/node/test/parallel/test-net-*.js: 141/141 pass
  • test/js/node/test/parallel/test-tls-*.js, test-http2-*.js: no new failures
  • test/js/node/net/, test/js/node/tls/, test/js/node/http/, test/js/bun/net/: no new failures (the remaining ones fail identically on a stock bun in this container: localhost resolution and DNS)