socket, timer, sql, redis: replace `unsafe` with typed ownership
Includes the commits of #40187 (timers) and #40273 (SQL, Redis).
Problem
- The socket, timer, SQL and Redis code pairs
ref_()andderef()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.rsandUpgradedDuplex.rshold 394 lines withunsafe.
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 throughTimerRefandTimerHeap, 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.TimerRefis 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 hidesunsafeand 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
| Part | Commits | First described in |
|---|---|---|
| socket, TLS over a Duplex | 2 | this PR (original text below) |
| timers | 8 | #40187 |
| SQL, Redis | 1 merge + 5 | #40273 |
| merges of main | 6 | here |
| lint fixes, changes after the two reviews, tests | 21 | here |
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)andclearImmediate(String(+t)), wheretis a timeout or an interval, do nothing. On main they cleart. Test:setImmediate.test.js.- An interval that stops itself in its callback (
this._repeat = nullorthis._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 = -1does 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 prints200 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.
| Difference | Reach from JavaScript | Test |
|---|---|---|
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 too | none |
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 only | none 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 only | none |
| 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 established | none |
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 error | sql-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 first | none |
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 path | sql-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 live | sql-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.
| functions | instructions | |
|---|---|---|
| main | 67,707 | 13,920,811 |
| the same in both builds | 66,805 (98.67%) | 13,662,847 (98.15%) |
| changed | 809 | |
| only in main | 93 | |
| only in this branch | 119 |
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.
| Area | scripts | same line on both builds | different |
|---|---|---|---|
Bun.connect, Bun.listen over TCP and unix sockets | 107 | 107 | 0 |
TLS: Bun.listen({ tls }), upgradeTLS, node:tls, TLS over a Duplex | 62 | 62 | 0 |
node:net, node:http2 | 95 | 95 | 0 |
| timers | 143 | 136 | 7 |
Bun.SQL (Postgres, MariaDB), Bun.RedisClient | 94 | 94 | 0 |
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 implementsTaskable. The branch uses that layer as it is:ConcurrentTask.rsis equal to main, the tag count is 83 on both, andgit grep 'Taskable for'gives 70 lines on both. - Timers (#42590, #42728):
init_with,TimerObject::scheduleandTimerObjectInternals::newtake&JsThread. A timer records its context. ABun.ModuleGraphcontext tracks theevent_loop_timerslot of each of its timers. When the context stops,cancel_timersmakes aTimerReffrom the slot and callsdispatch::cancel_js_timer. - Sockets (#43863, #43694, #42293, #42295, #42590):
latest_sessionis anOwnedSslSessionon the socket.server_identityexists forNewSocket,DuplexUpgradeContextandUpgradedDuplex.on_closemakesCloseTeardownfirst and holds one event loop scope over the close of the twin and its own. A listener and a duplex upgrade arm anAbortHandle. - Redis (#43675, #43863):
ValkeyDeferredCloseandDeferredFailureimplementTaskableas on main. The name check runs inside the handshake. - MySQL (#36842): the
Errarm ofMySQLRequestQueue::advanceison_errorthencontinue.test/js/sql/sql-mysql-queued-query-failure.test.tspasses (18 of 18). - Postgres (#43892, #34732, #43187): the Bind call sites go through
encode_request,atomicallydiscards 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 theContextIdof the script that made it and asks the VM for the groups of that context. SSLConfig::sniand the bracket strip of #42766 use the safeserver_name() -> Option<&CStr>of the branch.- Platform
#[cfg]cleanup (#44086): each of the 7 conflicts is a place where main removed anallowattribute or alet _ = 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 DuplexUpgradeContextis beside the type insocket_body.rs, as on main. An earlier state of this branch had it inListener.rs, andBun__socketReadErrorFromCloseCodeinuws_jsc.rs, so that the count ofsocket_body.rsread 0. That movedunsafeand removed none. Both are back insocket_body.rs, which has 8 lines withunsafe.- ALPN: the
TLSSocketof anSSLis in the application data slot of theSSL(index 0), throughExDataSlot::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 oneunsafeblock, a check that the field is inside its owner, and 6 unit tests that run under miri. Theheap_refof a timer is aSelfRef, soTimeoutandImmediatekeep 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:
-
The generated thunks had 871 new
unsafeblocks (one for each prototype method of asharedThisclass). The thunk now takesThisPtr<T>in its signature.generated_classes.rshas 3122 lines withunsafeand 94 blocks, as on main. -
PostgresSQLConnection::fail_with_js_valuereleased its guard before its last use ofself. The guard lives to the end of the function again. -
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. -
The buffer for the text of a BoringSSL error in the SQL client was one byte shorter than on main.
-
Maps::set_immediatewas written and never read. It is removed. -
globals.d.tsand the doc ofto_primitivesaid thatclearImmediateaccepts a numeric id. -
The docs of
ContextTimer::Objectnamed the wrong object. -
The doc of
SSL::set_ex_datastated a contract that its one user does not meet and does not need. The doc ofExDataSlot::app_datasaid that lsquic uses index 0. The declaration ofSSL_get_ex_new_indexhad no caller. -
The two unit tests of the ALPN select thunk ran in no CI job.
bun_boringssl_sysis now in the list of thecargo miri testjob. -
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.
-
Nothing stopped a later change from adding
unsafeto 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:WriteonlySocketRefinh2_frame_parser.rsholds its ref in a field named_socket. The two#[allow(dead_code)]are gone.Source lints, vm-thread-door: the inventory entry forunsafe impl Send for SSLConfigis removed, because the impl is gone.Source lints, host-export-callers: five exports have their caller outsidesrc/. The lint now also scanspackages/bun-usocketsandpackages/bun-uws(loop.ccallsBun__internal_ensureDateHeaderTimerIsEnabled). The fourWTFTimer__*exports are called bySource/WTF/wtf/bun/RunLoopBun.cppof the WebKit fork (nm -A libWTF.ashows 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: sevenpubitems that nothing uses are removed.mordant,generic_body_not_generic: the parts of sixTimerObjectdefault methods and ofMySQLConnection::handle_handshake_decode_public_keythat do not use the type parameter are now non-generic functions.- clippy
needless_pass_by_value:EncodeRequestisCopy. After the merge every field of it is a shared reference or aJSValue. setInterval.test.json Windows aarch64: the first form of therefresh()test kept the 200 intervals in an array and set the variable toundefinedbefore 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 callsthis.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.
| Path | main | this PR |
|---|---|---|
src/runtime/timer | 195 | 3 |
src/sql_jsc | 102 | 3 |
src/runtime/valkey_jsc | 26 | 6 |
src/runtime/socket/socket_body.rs | 52 | 8 |
src/runtime/socket/UpgradedDuplex.rs | 19 | 0 |
src/runtime/socket/Listener.rs | 43 | 42 |
src/runtime/socket (all files) | 350 | 286 |
src/runtime/dispatch.rs | 114 | 102 |
src/runtime/hw_exports.rs | 61 | 37 |
src/event_loop/EventLoopTimer.rs | 7 | 15 |
src/io/heap.rs | 10 | 21 |
src/boringssl_sys/boringssl.rs | 51 | 66 |
src/jsc/host_fn.rs | 28 | 32 |
src/ptr/self_ref.rs | 0 | 5 |
the 89 .rs files that this PR changes | 3262 | 2850 |
src (all files) | 13810 | 13398 |
generated_classes.rs (generated by the build, not in the repository) | 3122 | 3122 |
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).
| Type | main | this PR | block, main | block, this PR |
|---|---|---|---|---|
TimeoutObject, ImmediateObject | 96 | 96 | 96 | 96 |
PostgresSQLQuery | 64 | 64 | 64 | 64 |
JSMySQLQuery | 80 | 80 | 80 | 80 |
NewSocket<SSL> | 232 | 248 | 256 | 256 |
JSValkeyClient | 480 | 528 | 512 | 640 |
PostgresSQLConnection | 664 | 872 | 768 | 896 |
JSMySQLConnection | 496 | 704 | 512 | 768 |
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.
| main | this PR | delta | |
|---|---|---|---|
bun (stripped), bytes | 80,827,976 | 80,860,744 | +32,768 |
text (size), bytes | 80,660,492 | 80,695,052 | +34,560 |
run_task (nm -S), bytes | 1134 | 1134 | 0 |
run_task_cold | 1973 | 1973 | 0 |
__bun_release_task_unrun | 865 | 865 | 0 |
__bun_tick_queue_with_count | 1087 | 1087 | 0 |
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.
| Operation | main | this PR |
|---|---|---|
| a Redis dial that fails before a socket exists (a unix socket path that does not exist) | 21.07 | 21.06 |
a TLS connection that Bun.listen accepts, with ALPN, until it is closed | 224.64 | 224.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.tspasses the wrapper's rootm_ctxpointer to a receiver-generic helper (host_fn::host_fn_this_ptroverHostReceiver=&T|ThisPtr<T>), and#[host_fn]acceptsthis: ThisPtr<Self>; methods that may release the socket's refs (end,close,terminate,upgrade_tls,do_connect, …) takeThisPtr<Self>instead of minting one from&self.JSValue::as_class_this_ptrfor 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_refat exactly the oldref_()/deref()points; Windows named pipes getnamed_pipe_ref, the h2 write-only attachmentH2FrameParser::writeonly_socket_ref). The hand-writtenRefCountedimpl +deinit_and_destroybecome#[derive(RefCounted)]+Drop;finalize→finalize_js_box.DuplexUpgradeContextis single-owner via a newbun_ptr::OwnedThis<T>(Box-backed, lendsThisPtr), built by value, noMaybeUninit/ptr::write, no fake refcount. - UpgradedDuplex holds
BackRef<DuplexUpgradeContext, Mut>and callson_*directly (the 10-entry fn-pointer table is gone); itsSSLWrapperctx is aBackRef<UpgradedDuplex>; the JS callbacks areContextHostFnimpls viahost_fn::new_function_with_context. - BoringSSL / SSL_CTX ownership.
create_ssl_context,SecureContext::borrow,SSLContextCache::get_or_create*returnOwnedSslCtx;Listener.secure_ctx,SecureContext.ctx, valkey_secureholdOwnedSslCtx;SSLWrapper::init_with_owned_ctx; every cache site goes throughjsc_hooks::with_ssl_ctx_cache. ALPN selection is a typedSSL_CTX::set_alpn_select_callback::<H: AlpnSelectCallback>()withselect_next_protoas 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()returnsNonewhileConnecting(it used to return uSockets'(void*)-1sentinel, which everyssl()caller would have handed to BoringSSL; not JS-reachable pre-open today, so no test),us_fault_set/hitassafe fn;bun_sys::setsockopt_int; the internalcreateSocketPairtest helper usesbun_sys::socketpair(now CLOEXEC too). RefPtrdebug tracking only computesreturn_address()underdebug_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.