socket: replace us_socket_context_t with embedded groups + kind dispatch
Summary
Replaces us_socket_context_t — the heap-allocated, refcounted, callback-carrying object that every Bun socket belonged to — with three orthogonal pieces:
| Piece | What it is | Where it lives |
|---|---|---|
us_socket_group_t | Intrusive list-head + sweep state | Embedded in its owner (Listener, VM, uWS App, HTTPThread) |
kind (1 byte on us_socket_t) | Static dispatch tag | Set at create/adopt; switched on in dispatch.zig |
SSL* (direct, on us_socket_t) | Per-socket TLS state | SSL_new()'d from a shared SSL_CTX* |
SSL_CTX* is owned by a native SecureContext JS class and shared across every connection that names it. tls.createSecureContext() is SHA-256-memoised so the common one-config-many-connections pattern allocates one SSL_CTX per process, not one per tls.connect().
Net diff: 75 files, +4801 / −6383.
What problem this solves
1. Per-connection SSL_CTX_new() (#12117 #24118 #29887)
tls.connect() / Bun.connect({tls}) / socket.upgradeTLS() each called us_create_bun_ssl_socket_context(), which allocated a fresh us_internal_ssl_socket_context_t and called SSL_CTX_new() (~50 KB of BoringSSL state + cert/key/CA parse) per connection. The context was held until socket finalize. Under connection churn (pg pools, redis, fetch keepalive expiry) this was a steady leak proportional to throughput.
After: SSL_CTX_new() happens once per distinct config. test/js/node/tls/tls-connect-socket-churn.test.ts does 200 connects and asserts sslCtxLiveCount Δ ≤ 2 and RSS flat.
2. Eleven mutable function pointers per connection
us_socket_context_t carried on_open/on_data/on_writable/on_close/on_timeout/on_long_timeout/on_end/on_connect_error/on_socket_connect_error/on_fd/is_low_prio. us_internal_ssl_socket_context_t carried a second copy. Zig's NewSocketHandler.configure/unsafeConfigure/wrapTLS regenerated the same trampolines at runtime per call to install them.
After: one static const us_socket_vtable_t per SocketKind (~24 total, all .rodata). loop.c calls us_dispatch_data(s, …) → Zig switches on s->kind → typed handler. No per-connection callback storage, no runtime setter calls.
3. int ssl first param everywhere
Every public uSockets function took int ssl to decide whether to cast through the SSL wrapper types. After: s->ssl != NULL is the test; the param is gone.
4. upgradeTLS allocated a context + cloned Handlers + built a WrappedSocket twin pair
After: us_socket_adopt_tls(s, group, kind, SSL_CTX*, sni) — same socket, new kind, SSL* attached. The raw-bytes twin is a thin TLSSocket with flags.bypass_tls that routes writes to us_socket_raw_write. No Handlers.clone(), no second context.
5. Miscellaneous leaks fixed in passing
DuplexUpgradeContextneverdeinit'd (full leak pertls.connect({socket: duplex}))UpgradedDuplex.onEndCallbackwas wired toonReceivedDataSSLWrapper.initleaked the strdup'd passphrase on its error pathTLSSocket.memoryCostdidn't report off-heap SSL state
Architecture & lifecycle
us_socket_group_t
struct us_socket_group_t {
struct us_loop_t *loop;
const struct us_socket_vtable_t *vtable; /* NULL for Zig kinds */
void *ext; /* owner pointer */
struct us_socket_t *head_sockets;
struct us_connecting_socket_t *head_connecting_sockets;
struct us_listen_socket_t *head_listen_sockets;
struct us_socket_t *iterator;
struct us_socket_group_t *prev, *next; /* loop's group list */
uint32_t global_tick;
uint16_t low_prio_count; /* sockets parked in loop.low_prio_head */
uint8_t timestamp, long_timestamp, linked;
};
Embedded in its owner:
Listener.group— acceptedBun.listen()socketsRareData.{bun_connect,postgres,mysql,valkey,ws_upgrade,ws_client,spawn_ipc}_group[_tls]— per-kind client socketsHttpContext<SSL>::group/WebSocketContext<SSL>::group— uWS server socketsNewHTTPContext(ssl).group— fetch client
Lazy loop-link: link_socket links the group into loop->data.head on first socket; unlink_socket unlinks on last. Unused kinds cost zero loop overhead.
Invariant: owner may free the embedding storage iff head_sockets == head_connecting_sockets == head_listen_sockets == NULL && low_prio_count == 0. us_socket_group_close_all() makes this true (closes sockets, aborts in-flight connects, closes listeners, drains low-prio); us_socket_group_deinit() asserts it in debug.
Dispatch
loop.c no longer reads s->context->on_data. It calls:
extern struct us_socket_t *us_dispatch_data(struct us_socket_t *s, char *data, int len);
…defined in src/deps/uws/dispatch.zig:
export fn us_dispatch_data(s: *us_socket_t, data: [*c]u8, len: c_int) ?*us_socket_t {
return if (vt(s).on_data) |f| f(s, data, len) else s;
}
inline fn vt(s: *us_socket_t) *const VTable {
return switch (s.kind()) {
.invalid => @panic("kind=invalid"),
.dynamic, .uws_http, .uws_http_tls, .uws_ws, .uws_ws_tls => s.rawGroup().vtable.?,
else => tables.get(s.kind()).?, // comptime-generated .rodata vtable per kind
};
}
tables is a comptime EnumArray(SocketKind, *const VTable) populated via vtable.make(HandlerType), which generates callconv(.c) trampolines that read the typed ext and call T.onData(this, socket, data). Adding a SocketKind is a compile error until every event has an arm.
uWS C++ kinds use the per-group vtable pointer because the handler closure differs per App instance; Zig kinds never read it.
TLS
s->sslis a directSSL*(was a heapus_internal_ssl_socket_twrapper).- 6 SSL state bits (
handshake_state:2,wants_read:1,wants_write:1,fatal:1,is_server:1) live inus_socket_tpadding — free. SSL_CTX*is the only refcount. Passphrase is freed eagerly post-key-load so plainSSL_CTX_freeis correct on every path.reject_unauthorized/request_certare encoded inSSL_CTX_get_verify_mode(); reneg limits are one packedSSL_CTXex-data slot; per-socket reneg counter and SNI userdata are lazySSL_set_ex_data(server only, ≈never under TLS 1.3).- SNI:
sni_cbreads theus_listen_socket_t*from per-SSL ex-data (set at accept), not fromSSL_CTX— so a sharedSSL_CTXacross multiple listeners works and the listener pointer dies with the socket.
SecureContext
Native JS class (src/bun.js/api/bun/SecureContext.zig) wrapping one SSL_CTX*. createSecureContext() is SHA-256-keyed WeakRef-memoised over {key, cert, ca, passphrase, ciphers, secureOptions, dhparam, minVersion, maxVersion, ecdhCurve, sigalgs, ALPNProtocols, rejectUnauthorized, requestCert}. memoryCost() reports the off-heap SSL_CTX floor + cert/key bytes.
Consumers migrated
Bun.connect/Bun.listen, postgres, mysql, valkey, fetch (HTTPContext), new WebSocket(), Bun.serve (uWS HttpContext/WebSocketContext/App), Bun.spawn IPC, test-runner parallel IPC, devtools ChromeBackend/WebKitBackend, bake DevServer.
Supersedes #29694 (per-connection SSL_CTX reuse) and #29870 (DuplexUpgradeContext leak).
Open questions / footguns
Embedded-group lifetime is on the owner. A us_socket_group_t is just bytes in its owner; there's no refcount. Every owner-deinit must call group.closeAll() then group.deinit() before freeing itself, and must not do so from inside a dispatch callback on its own group (deinit asserts iterator == NULL). The old design's deferred-free via closed_context_head is gone — there's nothing to defer.
kind must be stamped. A calloc'd socket has kind = 0 = .invalid and dispatch panics. Every path that allocates a us_socket_t (listen accept, connect, from_fd, adopt, pair) sets it, but a new path that forgets will crash on first event rather than silently mis-dispatch. This is intentional but loud.
dispatch.zig is a link-time dependency of loop.c. bun-usockets no longer links standalone — us_dispatch_* are extern symbols defined in Zig. Fine for Bun (one binary), but anything that compiled the C library on its own won't.
Per-VM groups in RareData are address-stable for the VM's lifetime, but a *SocketGroup taken before RareData is initialised would be wrong. All accessors go through vm.rareData().xxxGroup(vm) which lazy-inits.
upgradeTLS raw twin. The returned [raw, tls] pair are two TLSSocket JS objects wrapping the same us_socket_t*. raw has flags.bypass_tls so writes go to us_socket_raw_write. onClose on tls retires raw. This matches Node's [cleartext, encrypted] shape but both halves share one fd — closing either closes both.
createSecureContext memoisation is by config-value. Mutating a Buffer after passing it to createSecureContext won't re-key the cache (the SSL_CTX already parsed it). Passing a secureContext explicitly bypasses the cache.
SocketKind ordinals are ABI. packages/bun-uws/src/SocketKinds.h mirrors the Zig enum positions for the four C++ kinds. Reordering SocketKind.zig without updating that header silently mis-routes uWS sockets.
Tests
test/js/node/tls/tls-connect-socket-churn.test.ts— 200 connects,sslCtxLiveCountΔ ≤ 2, RSS bounded- Existing
socket.test.ts/ tls / serve / fetch suites — stabilisation in progress; draft until green