Bun.connect: make end()/shutdown() a write-side half-close; fix timeout() docs

Closed
RO
2mo ago
+110-46

What

Socket.end() and Socket.shutdown() on Bun.connect/Bun.listen sockets did not match their documented contract:

CallDocumentedActual (before)Actual (after)
end()SHUT_WR, stay readableclose(2), nothing receivedSHUT_WR, stay readable
shutdown()SHUT_RDWRshutdown(SHUT_WR)shutdown(SHUT_WR)
shutdown(false)SHUT_RDWRshutdown(SHUT_WR)shutdown(SHUT_WR)
shutdown(true)SHUT_WR, stay readableshutdown(SHUT_RD), nothing receivedshutdown(SHUT_WR)

So an app that follows the docs for a request-then-read-reply protocol loses the peer's entire reply with end() or shutdown(true).

Cause

  • Socket.prototype.shutdown mapped arg.to_boolean() to shutdown_read() (SHUT_RD) vs shutdown() (SHUT_WR) (socket_body.rs).
  • Socket.prototype.end set END_AFTER_FLUSH, and internal_flush() turned that into mark_inactive() which calls close_and_detach(Normal) (close(2)), not a half-close.

Fix

  • shutdown() ignores its argument and always issues SHUT_WR. SHUT_RD has no safe user-facing use (the peer cannot observe it and uSockets fabricates a local end).
  • internal_flush() replaces mark_inactive() with socket.shutdown() for the end-after-flush arm. The socket stays readable; when the peer's FIN arrives uSockets' existing eof && is_shut_down branch in loop.c closes it and on_close runs the same teardown that used to run here.
  • node:net's _final already called shutdown() directly, so its half-close behavior is unchanged. The server onconnection drop path (blockList / maxConnections) switches from clientHandle.end() to clientHandle.close() since it wants an immediate close, matching Node's clientHandle.close().
  • bun.d.ts: rewrite the shutdown() and timeout() JSDoc to describe the actual behavior; delete the self-contradictory end(): void "Close the socket immediately" overload. timeout() is notify-only (the handler must close the socket itself) and rounds up to the 4-second LIBUS_TIMEOUT_GRANULARITY sweep.

Verification

Fail-before (released bun):

(fail) end() sends FIN and keeps the read side open      { received: 0,     readyState: -1, writeAfter: -1 }
(fail) shutdown(true) sends FIN and keeps the read side  { received: 0,     readyState:  1, writeAfter:  1 }
(pass) shutdown() sends FIN and keeps the read side open
(pass) shutdown(false) sends FIN and keeps the read side open

Pass-after (bun bd test):

(pass) end() sends FIN and keeps the read side open
(pass) shutdown() sends FIN and keeps the read side open
(pass) shutdown(false) sends FIN and keeps the read side open
(pass) shutdown(true) sends FIN and keeps the read side open

test/js/bun/net/, test/js/node/net/, test/js/node/tls/, test/js/node/http/, test/js/node/http2/ all match main (pre-existing local failures only). test-net-server-drop-connections.js, test-net-server-max-connections.js, test-net-server-blocklist.js, test-net-server-max-connections-close-makes-more-available.js pass. bun-types integration test passes.

Supersedes #35932 (docs-only approach).


no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/net/socket.test.ts