usockets: dispatch on_end before the poll change in the half-open EOF arm
RO
1mo ago+53+53+01234567890123456789-3-3-0123456789
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 dispatcheson_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.servemakes 1epoll_ctl(MOD), anode:netserver makes 2.
Fix
- Dispatch
on_endfirst. Then change the poll only when the socket is still open and no error close follows: no events for a socket thaton_endshut 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_openstays 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 ofnode:net,node:tlsandnode:http2, andBun.listen/Bun.connectwithallowHalfOpen: 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_pollgrows 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:netserver sends an 8 MiB reply to a client that sent its request and its FIN at once, forallowHalfOpentrue 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_PRELOADshim 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_ctlADD 1 and DEL 1,send1,recv2,TCP_CORK0. node:netdrops 2 poll changes, not 1: on main the loop arms writable, then theshutdown()thaton_endtriggers drops it again.us_internal_socket_follow_adoptedruns before the poll change becauseon_endcan 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 ninetest/js/bun/test/parallel/test-net-half-open-peer-reset-*.mjs, andtest/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.ts1 (should not call drain before handshake, needs the public internet),node-net.test.ts11 (10 fromlocalhostresolution in the container, 1 leak test that reaches its 60 s limit on debug builds),serve.test.ts2 (/bun:infoloopback check and the root-range port test, the container runs as root).