net: fail the write when the kernel rejects the bytes outright
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) returnedfalse, whichnode:netreads as "would block" and parks the stream callback for a drain.on_writabledispatches 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_corkedassumed "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 thesend()that failed already consumedsk_err, soEPOLLERRnever 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 passtest/js/node/test/parallel/test-tls-*.js,test-http2-*.js: no new failurestest/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:localhostresolution and DNS)