usockets: dispatch on_end before the poll change in the half-open EOF arm

RO
1mo ago
+53-3

Problem

  • In the half-open EOF arm of us_internal_dispatch_ready_poll (packages/bun-usockets/src/loop.c), the loop changes the poll to writable and then dispatches on_end. An owner that closes the socket there pays for a poll change it does not need.
  • Per connection that the peer closes first: Bun.serve makes 1 epoll_ctl(MOD), a node:net server makes 2.

Fix

  • Dispatch on_end first. Then change the poll only when the socket is still open and no error close follows: no events for a socket that on_end shut down, writable otherwise.
  • A socket that stays open ends with the same poll events as before.
  • Both counts go to 0.00 (release builds, main a7c73fdd89 against this PR).
  • Verified: three new cases in node-net-allowHalfOpen.test.js, and the existing suites in Notes.

Behaviour change: none

Background

  • A socket with allow_half_open stays open after the peer's FIN. The loop stops the readable poll, keeps the writable poll, and the owner decides when to close.
  • Owners on this arm: uWS HTTP (Bun.serve, node:http), the native handles of node:net, node:tls and node:http2, and Bun.listen / Bun.connect with allowHalfOpen: true.
  • This is groundwork for #41785, which moves default Bun sockets onto this arm. Considered keeping the order there: it costs one poll change per connection.

Downsides

  • Code size: us_internal_dispatch_ready_poll grows by 52 B and 15 instructions (clang -O3, no LTO). The release binary is 80827976 B before and after.
  • kqueue and libuv were not run locally. The macOS and Windows CI lanes passed on eaffc29e58.
Notes
  • Control: when the server closes first, epoll_ctl(MOD) is 0.00 per connection before and after.
  • The new cases: a node:net server sends an 8 MiB reply to a client that sent its request and its FIN at once, for allowHalfOpen true and false and for a reply from the 'data' or the 'end' listener. They pass on main too: the PR changes one syscall, not behaviour.
  • Counter: an LD_PRELOAD shim that counts libc calls (no strace on the machine). Per connection = (count at N=300 - count at N=100) / 200, two runs each, identical. The peer is a separate node process that reads the reply and then sends its FIN. Other calls do not change: epoll_ctl ADD 1 and DEL 1, send 1, recv 2, TCP_CORK 0.
  • node:net drops 2 poll changes, not 1: on main the loop arms writable, then the shutdown() that on_end triggers drops it again.
  • us_internal_socket_follow_adopted runs before the poll change because on_end can adopt the socket into another group.
  • Suites run on a debug build of this branch, Linux x64: test/js/bun/net/{socket,tcp-server,socket-syscall-fault}.test.ts, test/js/node/net/{node-net-allowHalfOpen.test.js,node-net.test.ts,node-net-server.test.ts}, test/js/node/tls/{node-tls-socket-allow-half-open-option,node-tls-raw-end,node-tls-server,node-tls-connect,tls-syscall-fault}.test.ts, test/js/node/http/{node-http-server-socket-end-drain,node-http-server-close-drain,node-http-backpressure,node-http-req-socket-pause,node-http}.test.ts, test/js/node/http2/node-http2.test.js, test/js/bun/http/{serve,node-http-halfclose-midupload}.test.ts, the nine test/js/bun/test/parallel/test-net-half-open-peer-reset-*.mjs, and test/js/node/test/parallel/{test-net-allow-half-open,test-net-socket-no-halfopen-enforcer,test-tls-socket-allow-half-open-option}.js.
  • Failures in those runs, all also present on a build of main on the same machine: socket.test.ts 1 (should not call drain before handshake, needs the public internet), node-net.test.ts 11 (10 from localhost resolution in the container, 1 leak test that reaches its 60 s limit on debug builds), serve.test.ts 2 (/bun:info loopback check and the root-range port test, the container runs as root).