socket: replace us_socket_context_t with embedded groups + kind dispatch

Merged
JA
6mo ago
+6096-6512

Summary

Replaces us_socket_context_t — the heap-allocated, refcounted, callback-carrying object that every Bun socket belonged to — with three orthogonal pieces:

PieceWhat it isWhere it lives
us_socket_group_tIntrusive list-head + sweep stateEmbedded in its owner (Listener, VM, uWS App, HTTPThread)
kind (1 byte on us_socket_t)Static dispatch tagSet at create/adopt; switched on in dispatch.zig
SSL* (direct, on us_socket_t)Per-socket TLS stateSSL_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

  • DuplexUpgradeContext never deinit'd (full leak per tls.connect({socket: duplex}))
  • UpgradedDuplex.onEndCallback was wired to onReceivedData
  • SSLWrapper.init leaked the strdup'd passphrase on its error path
  • TLSSocket.memoryCost didn'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 — accepted Bun.listen() sockets
  • RareData.{bun_connect,postgres,mysql,valkey,ws_upgrade,ws_client,spawn_ipc}_group[_tls] — per-kind client sockets
  • HttpContext<SSL>::group / WebSocketContext<SSL>::group — uWS server sockets
  • NewHTTPContext(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->ssl is a direct SSL* (was a heap us_internal_ssl_socket_t wrapper).
  • 6 SSL state bits (handshake_state:2, wants_read:1, wants_write:1, fatal:1, is_server:1) live in us_socket_t padding — free.
  • SSL_CTX* is the only refcount. Passphrase is freed eagerly post-key-load so plain SSL_CTX_free is correct on every path.
  • reject_unauthorized/request_cert are encoded in SSL_CTX_get_verify_mode(); reneg limits are one packed SSL_CTX ex-data slot; per-socket reneg counter and SNI userdata are lazy SSL_set_ex_data (server only, ≈never under TLS 1.3).
  • SNI: sni_cb reads the us_listen_socket_t* from per-SSL ex-data (set at accept), not from SSL_CTX — so a shared SSL_CTX across 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