node:net: emit 'error' once when a buffered write on an adopted fd fails

RO
20d ago
+74-20

Problem

  • A net.Socket that adopts an fd (socket.connect({ fd })) emits 'error' two times when the flush of a buffered write fails with a fatal send() errno. Every connection that a node:cluster worker accepts is such a socket. Node emits 'error' one time. Observed: ["write:EPIPE","error:EPIPE","error:EPIPE","close:true"].
  • The cause is SocketHandlers.error in src/js/node/net.ts:538. It fails the parked write callback with the error. The Writable then destroys the stream and queues 'error'. After that, the handler calls self.emit("error", error) a second time.

Fix

  • SocketHandlers.error now calls self.destroy(error) when the socket is not destroyed yet, instead of self.emit("error", error). This is the shape that ServerHandlers.error already uses for plain TCP.
  • destroy(error) owns the single 'error' emission through the stream's errorEmitted guard. When callback(error) already destroyed the stream, the destroyed check makes the second step a no-op. The socket now also closes with hadError true, as Node does.
  • Verified: test/js/node/net/net-syscall-fault.test.ts (new cluster worker case, fails on main with two error:EPIPE events). Also test/js/node/net/node-net.test.ts (same result set as main) and all 88 test-cluster-*.js files in the Node suite.

Background

  • node:net binds one of three native handler tables to a socket. SocketHandlers is bound by socket.connect({ fd }), which onClusterConnection uses for every accepted connection in a worker. SocketHandlers2 is bound by a normal net.connect(). ServerHandlers is bound by accepted sockets of a net.createServer in the same process. The other two tables already route the error through destroy(error).
  • The native caller is the fatal-flush path of on_writable in src/runtime/socket/socket_body.rs. When a flush of buffered_data_for_node_net fails with a fatal errno, it calls the error handler with a write errno, then closes the socket. The test uses the in-tree socket fault injection to make the first send() short and the second one fail with EPIPE.

Fixes #43030

Notes
  • The same handler also receives an exception that a JS callback throws (drain, end, open). Those now destroy the socket with the exception instead of leaving it open after an 'error' emit. A thrown 'data' listener does not reach this handler: pushDataToSocket reports it as an uncaught exception.
  • node-net.test.ts has 11 failures in this container both with and without the change. They are ECONNREFUSED on fixed ports and a 60 s timeout, unrelated to the handler.
  • #43026 adds the tail of on_open as a second caller of the same handler. It does not change the handler. Both PRs add tests to net-syscall-fault.test.ts.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/net/net-syscall-fault.test.ts