socket, timer, sql, redis: replace `unsafe` with typed ownership

JA
1mo ago
+6557-7016

Includes the commits of #40187 (timers) and #40273 (SQL, Redis).

Problem

  • The socket, timer, SQL and Redis code pairs ref_() and deref() on raw pointers by hand. A missed pair is a leak or a use after free.
  • On main, src/runtime/timer, src/sql_jsc, src/runtime/valkey_jsc, socket_body.rs and UpgradedDuplex.rs hold 394 lines with unsafe.

Fix

  • A named field, for example NewSocket::io_ref, holds each ref that an object keeps on itself. A second hold or release does nothing. A missed release still leaks.
  • A host function that can free its own object takes ThisPtr<Self>. Timers go through TimerRef and TimerHeap, which refuses a linked slot.
  • Those places now hold 20 lines with unsafe (src: 13,810 to 13,398). Verified: setInterval.test.js, setImmediate.test.js, node-net.test.ts, the suites in Notes.
  • Self-reviewed: 11 concerns raised, 11 addressed. #![forbid(unsafe_code)] now pins the 18 files that reached 0.

Background

  • RefPtr<T> owns one ref and releases it on drop. ThisPtr<T> is valid for the current call only. TimerRef is a handle to a timer slot.
  • Each task type implements Taskable (#43675), and this PR keeps that. Considered a safe helper for boxed tasks: it hides unsafe and removes none.

Downsides

  • Five behaviours change (list in Notes). Example: clearImmediate(id) with a numeric id no longer clears a timeout.
  • Allocator blocks grow (release, bytes): Redis client 512 to 640, Postgres connection 768 to 896, MySQL connection 512 to 768.
  • The release binary (linux x64) grows by 32,768 bytes (0.04%). Allocations per TLS accept and per failed Redis dial do not change.
Notes

What is in this PR

PartCommitsFirst described in
socket, TLS over a Duplex2this PR (original text below)
timers8#40187
SQL, Redis1 merge + 5#40273
merges of main6here
lint fixes, changes after the two reviews, tests21here

The 16 commits of the author are unchanged. Every later commit is on top of them.

Behaviour changes

Four are in the timers. For the first and the fourth, Node 26.3.0 prints what the test expects. The second and the third are about memory that bun holds.

  • clearImmediate(+t) and clearImmediate(String(+t)), where t is a timeout or an interval, do nothing. On main they clear t. Test: setImmediate.test.js.
  • An interval that stops itself in its callback (this._repeat = null or this._idleTimeout = -1) releases its JS object. On main the object, its callback and its arguments live until the process ends. Test: setInterval.test.js.
  • refresh() on an interval that stopped itself with _idleTimeout = -1 does nothing, as before, and no longer makes the JS object a strong reference. Test: setInterval.test.js. On Windows aarch64 a build with only this change reverted prints 200 200 leaked 201.
  • An interval whose callback calls refresh() and then stops itself runs once more, and keeps the event loop alive until that run. On main it runs once more only when something else keeps the event loop alive. Test: setInterval.test.js ({"repeat":2,"idleTimeout":2}).

One is in node:net, on Windows only:

  • socket.connect() on a socket that is connected closes the connection that the socket had, also when that connection or the new one is a named pipe. For TCP and unix sockets main does this already. With a named pipe on one side, main keeps the first connection open and the process does not exit. A debug build of main stops at an assertion that the socket is detached. Node closes the first connection in each case. Test: node-net.test.ts, connect() on a socket that is connected closes that connection (a path then TCP, TCP then a path, a path then a path). The first case fails on a release build of main on Windows x64 (time limit). All three pass on the Windows x64 and Windows aarch64 lanes of CI.

One difference from Node stays, on main and here: refresh() on an interval that stopped with _repeat = null starts it again in Node and does nothing in bun. No test states what bun does there.

Other differences from main

Each one was read on both sides, and none was measured. "No path" means that no JavaScript was found that reaches it.

DifferenceReach from JavaScriptTest
handle_connect_error releases the ref of the socket also when the socket is detached. Main leaks one NewSocket when terminate() runs on a socket that still connects, and when a TLS socket over a Duplex fails before it opens.yes by the code (destroy() of a socket that still connects). No script was run to show it.none
get_native_handle() is None for a socket that still connects. Main gives the uSockets value (void*)-1.no path: every TLS method of a socket goes through ssl(), which is None for that state on main toonone
The ALPN select thunk answers NOACK for an empty offered list, and accepts a selection only when it is one entry of that list.no path: BoringSSL checks the list before it calls the callback. Malformed list, no overlap and a callback that names a protocol the client did not offer give a fatal alert on both.node-tls-server.test.ts (ALPNCallback), 2 unit tests of the thunk (run under miri)
createSocketPair() (test helper in bun:internal-for-testing) makes its descriptors close-on-exec, and on macOS sets SO_NOSIGPIPE.test helper onlynone for the flags
The auto flusher of a Redis or SQL connection holds a ref on the connection while it is registered. Main keeps a pointer and a flag.lifetime onlynone
Redis: a flush that closes the socket no longer leaves the flusher marked as registered. On main later commands of that client would not flush.not establishednone
Postgres do_run looks the named statement up again after the write. Main keeps a pointer into the statement map across the write.no path: no JavaScript runs in that window (a statement with no parameters)postgres-bind-wire.test.ts runs the path
MySQL on_close releases the ref of the socket only when on_open took one.no path: uSockets reports a socket that never opened as a connect errorsql-connect-error-reporting.test.ts runs the balanced path
Postgres and Redis Drop clear the pointer to themselves first, so a ref taken during Drop is a panic. On main it is a second destroy.no path: a connected client has pending activity, and teardown closes sockets firstnone
create_structure_for asserts in release builds that the owner is empty or a cell. Main asserts in debug builds that it is a cell.no pathsql-empty-column-name.test.ts runs the function
SQL TLS upgrade and upgradeTLS take the socket groups from the context that the connection recorded. Main reads them from the socket.same groups while the context is livesql-tls-ip-literal-host.test.ts, module-graph-io.test.ts

The description of #40273 names four repairs in the SQL part. Compared with main of today: main already has the repair of the string ref leak in CachedStructure::build_from_columns (#40238). Main never had the leak in the re-entrant fill of a named statement. The other two are the Postgres do_run and MySQL on_close rows above.

How far the change reaches

This PR is not a mechanical change. It rewrites by hand how these objects are owned: 92 files under src, 6,350 lines added, 7,006 removed. Three measurements bound what can have changed. None of them proves that nothing else changed.

1. Which machine code changed. Release builds on linux x64 of main (a7c73fdd89) and of this branch (720d151dd9) were compared function by function. Before two functions are compared, the parts that depend on the link address are removed: absolute addresses, RIP-relative displacements and padding. A call is compared by the name of its target.

functionsinstructions
main67,70713,920,811
the same in both builds66,805 (98.67%)13,662,847 (98.15%)
changed809
only in main93
only in this branch119

Where the 1,021 functions that differ are:

  • 585 are in a module that this PR edits.
  • 346 are generated bindings (RedisClientPrototype__*, TLSSocketPrototype__*, H2FrameParserPrototype__*) and C callbacks of uSockets. The linker inlines the edited Rust code into them.
  • 89 are elsewhere. 49 are in the HTTP client and the WebSocket client, which call the socket API that this PR edits. About 20 differ in one constant (a field offset, an object size, the version string). 16 inline timer or event loop code. 4 have a different inlining and no change in their source (two password jobs, one hash map insert, the loader of .npmrc).

A second release build of this branch, made in another directory, gives the same table. What this cannot show: a constant that changed in data, and what a function that differs now does.

2. The same scripts on both builds. 501 small scripts. Each one records, for each socket, timer or client, every callback in order with its arguments, the return values of methods, getters, error codes, and the exit code of a child process, and prints one line. Each script ran 5 times on each release build and 2 times on each debug build with ASAN.

Areascriptssame line on both buildsdifferent
Bun.connect, Bun.listen over TCP and unix sockets1071070
TLS: Bun.listen({ tls }), upgradeTLS, node:tls, TLS over a Duplex62620
node:net, node:http295950
timers1431367
Bun.SQL (Postgres, MariaDB), Bun.RedisClient94940

The 7 that differ show two of the behaviour changes listed above: 4 scripts for clearImmediate(id), and 3 for an interval that calls refresh() and then stops itself (it runs once more when nothing else keeps the process alive). No script shows a difference that is not in that list. The debug builds with ASAN give the same result and no ASAN report. On them 2 timer scripts do not print the same line in every run, on main too (a timer of 0 ms or 60 ms against the end of a slow process).

One race is on both builds and is not in the table: after maxLifetime closes a Postgres connection, a query sent from the continuation of onclose sometimes rejects with ERR_POSTGRES_LIFETIME_TIMEOUT. It happened in 1 of 393 runs on main and in 3 of 393 on this branch.

3. The test suites: section Tests, and CI on every lane.

Not covered by 1 and 2: Windows and macOS (only CI's own suites ran there), memory use, and timing.

Open issues checked

97 open issues about timers, sockets and TLS, Bun.SQL and the Redis client were read. 58 of them have a script that runs on linux without a download: each ran on a release build of main and of this branch (timers 10, sockets and TLS 17, SQL 25, Redis 6). None behaves differently on this branch, so this PR names no issue that it closes. The bugs that this PR repairs (section Behaviour changes) have no open issue.

A crash report with a stack in the code that this PR rewrites, and no script, cannot be checked. One example is #30418 (macOS: tickImmediateTasks, runImmediateTask, TimerObjectInternals.run).

The merges with main

Six merges took 397 commits of main. 38 files conflicted (27 in the first merge, 7 in the last one). The rule of the resolution: the ownership model of the branch stays, and each behaviour change of main is written again inside it.

  • Tasks (#43675): main removed ManagedTask. A task is a pointer and a tag, and each type implements Taskable. The branch uses that layer as it is: ConcurrentTask.rs is equal to main, the tag count is 83 on both, and git grep 'Taskable for' gives 70 lines on both.
  • Timers (#42590, #42728): init_with, TimerObject::schedule and TimerObjectInternals::new take &JsThread. A timer records its context. A Bun.ModuleGraph context tracks the event_loop_timer slot of each of its timers. When the context stops, cancel_timers makes a TimerRef from the slot and calls dispatch::cancel_js_timer.
  • Sockets (#43863, #43694, #42293, #42295, #42590): latest_session is an OwnedSslSession on the socket. server_identity exists for NewSocket, DuplexUpgradeContext and UpgradedDuplex. on_close makes CloseTeardown first and holds one event loop scope over the close of the twin and its own. A listener and a duplex upgrade arm an AbortHandle.
  • Redis (#43675, #43863): ValkeyDeferredClose and DeferredFailure implement Taskable as on main. The name check runs inside the handshake.
  • MySQL (#36842): the Err arm of MySQLRequestQueue::advance is on_error then continue. test/js/sql/sql-mysql-queued-query-failure.test.ts passes (18 of 18).
  • Postgres (#43892, #34732, #43187): the Bind call sites go through encode_request, atomically discards a partial message, only the query whose row does not decode is rejected.
  • SQL TLS upgrade: main reads the socket group set from the socket (SocketGroups::of, unsafe). Here each connection records the ContextId of the script that made it and asks the VM for the groups of that context.
  • SSLConfig::sni and the bracket strip of #42766 use the safe server_name() -> Option<&CStr> of the branch.
  • Platform #[cfg] cleanup (#44086): each of the 7 conflicts is a place where main removed an allow attribute or a let _ = x; from code that the branch had rewritten. The branch side stays, without the attribute.

Changes after the review of the design

  • impl Taskable for DuplexUpgradeContext is beside the type in socket_body.rs, as on main. An earlier state of this branch had it in Listener.rs, and Bun__socketReadErrorFromCloseCode in uws_jsc.rs, so that the count of socket_body.rs read 0. That moved unsafe and removed none. Both are back in socket_body.rs, which has 8 lines with unsafe.
  • ALPN: the TLSSocket of an SSL is in the application data slot of the SSL (index 0), through ExDataSlot::app_data(), as on main. uSockets and lsquic register their indices, and BoringSSL numbers those from 1. No new ex-data index is registered.
  • SelfRef<T> (src/ptr/self_ref.rs) is one byte that says that an object holds a ref on itself. It has one unsafe block, a check that the field is inside its owner, and 6 unit tests that run under miri. The heap_ref of a timer is a SelfRef, so Timeout and Immediate keep the size they have on main. The type has a size assertion.
  • The Postgres and MySQL query objects no longer hold a pointer to themselves. Their methods take ThisPtr<Self>. Both keep the size they have on main, with a size assertion.
  • The two refresh() changes in Behaviour changes. Each answers a review comment.

Changes after the review of the code

11 concerns, all closed:

  1. The generated thunks had 871 new unsafe blocks (one for each prototype method of a sharedThis class). The thunk now takes ThisPtr<T> in its signature. generated_classes.rs has 3122 lines with unsafe and 94 blocks, as on main.

  2. PostgresSQLConnection::fail_with_js_value released its guard before its last use of self. The guard lives to the end of the function again.

  3. A second connect() with a Windows named pipe on one side: see Behaviour changes. On this branch the close of the first connection released the ref of the second one.

  4. The buffer for the text of a BoringSSL error in the SQL client was one byte shorter than on main.

  5. Maps::set_immediate was written and never read. It is removed.

  6. globals.d.ts and the doc of to_primitive said that clearImmediate accepts a numeric id.

  7. The docs of ContextTimer::Object named the wrong object.

  8. The doc of SSL::set_ex_data stated a contract that its one user does not meet and does not need. The doc of ExDataSlot::app_data said that lsquic uses index 0. The declaration of SSL_get_ex_new_index had no caller.

  9. The two unit tests of the ALPN select thunk ran in no CI job. bun_boringssl_sys is now in the list of the cargo miri test job.

  10. This description: what a slot does and does not prevent, the differences from main outside the timers, the four SQL repairs compared with main of today.

  11. Nothing stopped a later change from adding unsafe to the 18 files that now have none. A maintainer asked for the guard: #![forbid(unsafe_code)] is at the top of each of them. It compiles for the 12 targets of CI, and in the release profile for linux x64 and Windows x64.

Checks that were red on the branch

  • Source lints, dead-code escapes: WriteonlySocketRef in h2_frame_parser.rs holds its ref in a field named _socket. The two #[allow(dead_code)] are gone.
  • Source lints, vm-thread-door: the inventory entry for unsafe impl Send for SSLConfig is removed, because the impl is gone.
  • Source lints, host-export-callers: five exports have their caller outside src/. The lint now also scans packages/bun-usockets and packages/bun-uws (loop.c calls Bun__internal_ensureDateHeaderTimerIsEnabled). The four WTFTimer__* exports are called by Source/WTF/wtf/bun/RunLoopBun.cpp of the WebKit fork (nm -A libWTF.a shows the references). They are in a list in the lint, and a test fails when a listed symbol is no longer exported or gets a caller in this tree.
  • mordant, unused_pub: seven pub items that nothing uses are removed.
  • mordant, generic_body_not_generic: the parts of six TimerObject default methods and of MySQLConnection::handle_handshake_decode_public_key that do not use the type parameter are now non-generic functions.
  • clippy needless_pass_by_value: EncodeRequest is Copy. After the merge every field of it is a shared reference or a JSValue.
  • setInterval.test.js on Windows aarch64: the first form of the refresh() test kept the 200 intervals in an array and set the variable to undefined before the collection. On Windows aarch64 the array lived through both collections (200 leaked 202), also on a build of main. A heap snapshot taken at that point shows the array with no incoming edge and no root entry. The test now holds no timer in JS: each interval calls this.refresh() from an immediate that it queues in its callback.

Lines with unsafe (git grep -c '\bunsafe\b', comments included)

main is a7c73fdd89, the commit of the last merge.

Pathmainthis PR
src/runtime/timer1953
src/sql_jsc1023
src/runtime/valkey_jsc266
src/runtime/socket/socket_body.rs528
src/runtime/socket/UpgradedDuplex.rs190
src/runtime/socket/Listener.rs4342
src/runtime/socket (all files)350286
src/runtime/dispatch.rs114102
src/runtime/hw_exports.rs6137
src/event_loop/EventLoopTimer.rs715
src/io/heap.rs1021
src/boringssl_sys/boringssl.rs5166
src/jsc/host_fn.rs2832
src/ptr/self_ref.rs05
the 89 .rs files that this PR changes32622850
src (all files)1381013398
generated_classes.rs (generated by the build, not in the repository)31223122

Where the remaining lines are: the 8 in socket_body.rs are the Taskable impl (4), the AbortHandle owner and its arm (2), the #[unsafe(no_mangle)] export and the ALPN slot. The 6 in valkey_jsc are the two Taskable impls. The 3 in the timers and the 3 in sql_jsc are unsafe extern blocks. The five files that grow hold the primitives that the other files now use (TimerRef and TimerHeap, the intrusive heap, ExDataSlot and the owned BoringSSL types, the ThisPtr receiver of a host function, SelfRef).

18 of the files had unsafe on main and have none now. Each has #![forbid(unsafe_code)]: UpgradedDuplex.rs, Timer.rs, ImmediateObject.rs, DateHeaderTimer.rs, EventLoopDelayMonitor.rs, js_valkey_functions.rs, and in src/sql_jsc the files JSMySQLConnection.rs, JSMySQLQuery.rs, MySQLConnection.rs, MySQLContext.rs, MySQLQuery.rs, MySQLRequestQueue.rs, MySQLValue.rs, PostgresSQLConnection.rs, PostgresSQLContext.rs, PostgresSQLQuery.rs, SASL.rs, CachedStructure.rs.

Size of the objects (bytes, release build, linux x64)

The block is what the allocator gives for that size (mi_usable_size of an allocation of that size).

Typemainthis PRblock, mainblock, this PR
TimeoutObject, ImmediateObject96969696
PostgresSQLQuery64646464
JSMySQLQuery80808080
NewSocket<SSL>232248256256
JSValkeyClient480528512640
PostgresSQLConnection664872768896
JSMySQLConnection496704512768

EventLoopTimer is 48 bytes and Task is 16 bytes on both. MySQLConnection, a field of JSMySQLConnection, goes from 344 to 528. NewSocket gets io_ref and named_pipe_ref. In the SQL connections the field tls_config goes from a pointer to a boxed config to the config itself, which is one allocation less for each connection that uses TLS.

Binary size, task dispatch, allocations

Release builds on linux x64 of main at a7c73fdd89 and of this branch at 720d151dd9, same machine and toolchain.

mainthis PRdelta
bun (stripped), bytes80,827,97680,860,744+32,768
text (size), bytes80,660,49280,695,052+34,560
run_task (nm -S), bytes113411340
run_task_cold197319730
__bun_release_task_unrun8658650
__bun_tick_queue_with_count108710870

The allocation counts below are from an earlier pair of builds (main at 1313ca61d4, this branch at 95961f95d7).

Allocations: calls of the 11 mimalloc entry points that allocate (mi_malloc, mi_zalloc, mi_calloc, mi_realloc, the aligned and the heap forms, mi_expand), counted with gdb breakpoints on bun-profile over a whole run. The count for one operation is the count at N = 2000 minus the count at N = 1000, divided by 1000.

Operationmainthis PR
a Redis dial that fails before a socket exists (a unix socket path that does not exist)21.0721.06
a TLS connection that Bun.listen accepts, with ALPN, until it is closed224.64224.34

Four runs at N = 1000 for each build give, for the whole process: Redis 27,878 to 27,892 on main and 27,879 to 27,888 on this branch, TLS 251,187 to 251,412 on main and 251,297 to 251,360 on this branch. The difference between the builds is smaller than the difference between two runs of one build.

Speed: no instruction counter is available in the container that ran this (perf and valgrind are missing). CPU time of interleaved runs (12 rounds for each build, at 689552ded9) shows no difference and proves none: the median of this branch is between 17% lower and 4% higher than main, and the spread of main alone is 36% to 73% on that machine.

Tests

linux x64, debug build with ASAN. Each suite ran on this branch and on a build of main at the commit that the merge took. The result is the same on both for:

  • node's own tests test-tls-*, test-timers-*, test-net-*, test-https-*, test-http2-*: 751 files.
  • test/js/node/tls (17 files), test/js/bun/net (7), test/js/node/net (8), test/js/node/http2 (4), websocket client and proxy, test/js/web/abort.
  • test/js/sql: 69 files against a local Postgres and MariaDB.
  • test/js/web/timers, test/js/node/timers, test/js/bun/test/fake-timers, test/js/bun/cron, test/js/bun/module-graph.
  • test/js/valkey: the part that runs without Docker.

Those runs are from before the last two merges. After the last merge 15 files ran again (timers, Bun.connect and Bun.listen, node:net, node:tls, TLS over a Duplex, the MySQL queue test, Redis connection failures, spawn.test.ts), with the same result as main. The timer files, the fake timers and node's 58 test-timers-* files ran again after the timer changes.

After the changes of the code review (every prototype method of a sharedThis class goes through the changed thunk) these ran on the last commit: the timer files, socket.test.ts, tcp-server.test.ts, node-net.test.ts, four node:tls files, node-http2.test.js, serve.test.ts, fetch.test.ts, sqlite.test.js, streams.test.js, module-graph-isolation.test.ts, three Postgres files and the Redis connection failures. No test fails that passes on main.

Tests that fail on both builds on this machine: the four RSS leak tests in setTimeout.test.js and setInterval.test.js, the tests that need the public internet, IPv6 or a user that is not root, 11 tests in node-net.test.ts, the MySQL tests (the local MariaDB refuses the login in the container of the last run), and tests with a time limit of 5 seconds or less that time out under load.

On the last commit: cargo check --workspace for the 12 targets of CI, cargo clippy, cargo fmt --check, test/internal/source-lints (167 pass), cargo miri test -p bun_boringssl_sys. mordant ran on the last merge (exit 0) and not on the last commit.

Windows x64, debug build at 3d9fcd91b8: 16 test files (named pipes, Bun.connect and Bun.listen, node:net, node:tls, http2, timers, the SQL socket test, Redis connection failures). 15 pass. In setInterval.test.js the leak test reaches its 90 second limit on a debug build.

Windows aarch64, debug build of the sixth merge: 21 test files pass (named pipes, Bun.connect and Bun.listen, node:net, node:tls, TLS over a Duplex, timers, spawn.test.ts, Redis connection failures, the SQL socket test). setInterval.test.js passes on a release build.

Not run: tests on macOS, and the tests that need Docker.

Original description of the socket part

The counts in this text are from the time it was written. The table above has the counts of today.

What

Same treatment as #40055 / #40136 for Bun.connect/Bun.listen sockets and the TLS-over-duplex path: src/runtime/socket/socket_body.rs 57 → 0 and UpgradedDuplex.rs 15 → 0 (plus Listener 44→37, tls_socket_functions 38→36, SSLContextCache 16→14, SecureContext 10→7, js_valkey 36→34, WindowsNamedPipe 38→36), by fixing the layers below:

  • ThisPtr<Self> host-fn receivers. generate-classes.ts passes the wrapper's root m_ctx pointer to a receiver-generic helper (host_fn::host_fn_this_ptr over HostReceiver = &T | ThisPtr<T>), and #[host_fn] accepts this: ThisPtr<Self>; methods that may release the socket's refs (end, close, terminate, upgrade_tls, do_connect, …) take ThisPtr<Self> instead of minting one from &self. JSValue::as_class_this_ptr for the downcast sites.
  • Typed ref ownership. The ref held on behalf of the uSockets ext slot / in-flight connect / accept / duplex owner is NewSocket::io_ref: Cell<Option<RefPtr<Self>>> (hold_io_ref / adopt_io_ref / release_io_ref at exactly the old ref_()/deref() points; Windows named pipes get named_pipe_ref, the h2 write-only attachment H2FrameParser::writeonly_socket_ref). The hand-written RefCounted impl + deinit_and_destroy become #[derive(RefCounted)] + Drop; finalize → finalize_js_box. DuplexUpgradeContext is single-owner via a new bun_ptr::OwnedThis<T> (Box-backed, lends ThisPtr), built by value, no MaybeUninit/ptr::write, no fake refcount.
  • UpgradedDuplex holds BackRef<DuplexUpgradeContext, Mut> and calls on_* directly (the 10-entry fn-pointer table is gone); its SSLWrapper ctx is a BackRef<UpgradedDuplex>; the JS callbacks are ContextHostFn impls via host_fn::new_function_with_context.
  • BoringSSL / SSL_CTX ownership. create_ssl_context, SecureContext::borrow, SSLContextCache::get_or_create* return OwnedSslCtx; Listener.secure_ctx, SecureContext.ctx, valkey _secure hold OwnedSslCtx; SSLWrapper::init_with_owned_ctx; every cache site goes through jsc_hooks::with_ssl_ctx_cache. ALPN selection is a typed SSL_CTX::set_alpn_select_callback::<H: AlpnSelectCallback>() with select_next_proto as a pure two-iterator find over validated lists; SSL::{servername, set_tlsext_host_name, set_alpn_protos, set_ex_data_ref, ex_data_ref}, SSL_CTX::{verify_mode, up_ref}.
  • uws_sys: NewSocketHandler::write_ext, get_native_handle() returns None while Connecting (it used to return uSockets' (void*)-1 sentinel, which every ssl() caller would have handed to BoringSSL; not JS-reachable pre-open today, so no test), us_fault_set/hit as safe fn; bun_sys::setsockopt_int; the internal createSocketPair test helper uses bun_sys::socketpair (now CLOEXEC too).
  • RefPtr debug tracking only computes return_address() under debug_assertions.

Testing

Debug+ASAN: node-tls-{upgrade,connect,server,duplex-close-throw-uaf,duplex-write-throw-error-value,connect-hostname-verification,context}, renegotiation, bun/net {socket,socket-retention,tcp-server,socket-syscall-fault}, ssl-ctx-cache, tls-syscall-fault, bun-connect-x509, valkey, websocket-client/proxy all pass; node-http2 362 pass; node parallel test-tls-{inception,js-stream,socket-close,destroy-stream,alpn-server-client} exit 0; node-net 85/85 apart from the timing-bound "reused handle" leak case on a loaded debug box (passes standalone, delta 0). Hand-driven Bun.connect/Bun.listen TLS echo, socket.upgradeTLS(), and a new tls.TLSSocket(duplex) pair produce the same event order as the release binary. rust-check-all windows-x64 / mac-arm64 ok; clippy clean.