valkey: report a TLS context that fails to build from the event loop - #38794
valkey: report a TLS context that fails to build from the event loop#38794robobun wants to merge 1 commit into
Conversation
connect()'s tls_ctx_failed branch ran on_valkey_close() inline, so the connect() promise was rejected and onclose was called from inside the connect() (or command) call that started the attempt. An onclose that dials again therefore re-entered the same branch on the same stack until the stack ran out, and an onclose that threw had its exception turned into a connect() error that rejected the already rejected promise. The branch now uses the deferred no-socket close that reconnect() already uses for a dial that fails outright; fail() still runs inline so commands issued meanwhile are rejected as before and no connection timer is armed. The deferred close holds a strong ref to the JS wrapper while it is queued: unlike a socket close (status Connecting), nothing in update_poll_ref() counts this pending close as activity, so a client that only the pending attempt still referenced was collected before the task ran and the attempt never settled.
|
Status: superseded, closed. The base branch picked up the same change in 218faf0 ( The test cases from here that 218faf0 does not already have pass unchanged on top of it: ali/valkey-fail-recovery...farm/eb4582f3/valkey-tls-ctx-extra-tests (a command starting the attempt, an Original statusReproduced on a debug build of the base branch ( |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not a duplicate of #34829. That PR keeps the |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes GC rooting (adds a jsc::Strong held across an event-loop task) and rebalances the socket keep-alive ref on the tls_ctx_failed path — both in the memory-safety category the repo treats as most-blocked — a human look would still be worthwhile, especially since it's stacked on #37993.
Checked: the socket ref that on_valkey_close() used to consume is now released by socket_ref's Drop on the early return, and the deferred task's own ref_() is what on_valkey_close/on_valkey_reconnect adopts — balanced on both run and release_unrun.
Checked: DeferredClose losing Copy is fine — match self.what/match task.what have no by-value bindings, so the Strong stays in the boxed task and drops with it after on_close() has read this_value.
Checked: reset_connection_timeout() after the now-Ok(()) return is a no-op (failed → get_timeout_interval()==0 → arm early-returns), and fail() inside the deferred on_close() early-returns on failed so no second rejection.
Extended reasoning...
Overview
The PR moves the TLS-context-build-failure branch in JSValkeyClient::connect() from calling on_valkey_close() inline to enqueuing the existing DeferredClose::WithoutSocket task, so onclose and the connect() promise settle from the event loop like every other failure mode. To keep the JS wrapper alive across the enqueue→run gap (after update_poll_ref() downgrades it on the way out of do_connect/send), the task variant now carries an Option<jsc::Strong> on the wrapper. DeferredClose accordingly loses Clone, Copy. Five new tests cover: async report from both connect() and a command, redialing onclose not recursing, throwing onclose surfacing as uncaughtException, and survival across Bun.gc(true).
Security risks
None. The path is a client-side TLS config validation failure; no untrusted input parsing, auth, or protocol handling is touched.
Level of scrutiny
High. This is native code touching intrusive refcounts and GC rooting — REVIEW.md's most-blocked category. The change is small and reuses #37993's task, but adding a Strong whose lifetime spans an event-loop task boundary (and must release correctly in both run and release_unrun) is exactly the kind of thing a maintainer should sign off on. The repo guidance is explicit that new Strong refs need justification; the PR justifies it well (0/100 vs 100/100 probe), but confirming that holding the Strong in the task rather than teaching update_poll_ref() about the pending close is the preferred shape is a maintainer call.
Other factors
- Refcount balance verified by inspection: the old path forgot
socket_refand leton_valkey_closeadopt it; the new path letssocket_refdrop naturally, and the task's ownthis.ref_()supplies the refon_valkey_close/on_valkey_reconnectadopts.enqueue_deferred_close's ref is adopted in bothrunandrelease_unrun. - The
Strongdrops with the boxed task:match self.what { WithoutSocket { .. } => ... }has no by-value binding, so_wrapperremains owned byself/taskand is released after the arm body (i.e., afteron_close()has readthis_value), and inrelease_unrunafter the poll ref is disabled. get_timeout_interval()returns 0 whenfailed, so the callers'reset_connection_timeout()after the now-Ok(())return arms nothing — matches the PR's claim.- Stacked on #37993's branch; whoever reviews that will likely want to see this alongside it.
- Tests follow harness conventions (
bunExe/bunEnv,await using, combined{stdout,stderr,exitCode}assertion, no network) and each is stated to fail on the base branch.
|
On the one open point in the review above, the task holding the |
|
Updated 9:05 PM PT - Aug 14th, 2026
❌ @robobun, your commit 6d26f04 has some failures in 🧪 To try this PR locally: bunx bun-pr 38794That installs a local version of the PR into your bun-38794 --bun |
Follow-up to #37993, opened against its branch because it reuses the deferred no-socket close added there. Turned up while testing that branch.
Problem
new Bun.RedisClient("rediss://...", { tls: { ca: "not a certificate" } })constructs fine; the attempt fails whenconnect()cannot build theSSL_CTX. That branch (tls_ctx_failedinJSValkeyClient::connect, src/runtime/valkey_jsc/js_valkey.rs) calledon_valkey_close()inline, so theconnect()promise was rejected and the user'sonclosewas called from inside theconnect()(or command) call that started the attempt. Every other way an attempt can fail reports from the event loop.onclosethat dials again therefore re-entered the same branch on the same stack:connect()->onclose->connect()-> ... until JSC threwMaximum call stack size exceeded, which on a debug build ends inASSERTION FAILED: Unexpected exception observed/releaseAssertNoException(abort). The sameoncloseagainst a refused port just loops through the event loop.onclosethat simply threw was also mishandled on this path: the exception came back out ofconnect()as acrate::Error, whichdo_connecttakes to mean "dial failed" and answers by rejecting the promise thaton_valkey_close()had already rejected (ASSERTION FAILED: Promise is already resolved or rejected,JSC__JSPromise__reject, abort on debug builds).Fix
enable_auto_reconnect = falseand the inlinefail(), and replaces the inlineon_valkey_close()(plus the socket ref it consumed) withclose_without_socket_next_tick(), the taskreconnect()already uses when a dial fails before a socket exists. The task marks the client disconnected and runson_close(), which takes the manual-close pathfail()set up and reacheson_valkey_close()on a fresh stack: a redialingonclosenow loops through the event loop like it does against a refused port, and anonclosethat throws is reported through the task's fold like any other callback.fail()stays inline on purpose: commands issued before the task runs are rejected as before, and becausefailedis set, the callers'reset_connection_timeout()arms nothing for an attempt that is already over. Only the JS-visible report (promise settlement andonclose) moves.Strongon the JS wrapper while queued. A socket that is still going to report its close keeps the wrapper strong throughupdate_poll_ref()(statusConnecting); this queued close is invisible toupdate_poll_ref(), and afterfail()nothing else it counts as activity is left, so theupdate_poll_ref()the callers run on the way out downgraded the wrapper before the task ran. With just the deferral, a client that only its pending attempt still referenced was collected in between andconnect()never settled (0 of 100 settled in aBun.gc(true)loop; 100 of 100 with theStrong).reconnect()'s existing use of the task was not affected (is_reconnectingkeeps the wrapper strong there; 30 of 30 settled without theStrong), andduplicate()of a connected client is a case where nothing in JS references the new wrapper until its promise settles.connect()and a command; anonclosethat redials three times never runs inside aconnect()call; anonclosethat throws is reported as an uncaught exception and the process exits 0; and clients referenced by nothing but their pending attempt still get their rejection andoncloseacross aBun.gc(true). The first four fail against the base branch (the first three on their assertions, the throwing one with the abort above); the GC one fails by timing out against the deferral without theStrong.Background
JSValkeyClientis the native object behindBun.RedisClient; it is intrusively refcounted, and separately holdsthis_value, a reference to its JS wrapper thatupdate_poll_ref()flips between strong and weak depending on whether anything is still going to need the wrapper (an open or connecting socket, queued commands, a scheduled reconnect). Theconnect()promise lives in a slot on the wrapper andoncloseis read from it, so a close reported after the wrapper is collected reports to nobody.fail()marks the client failed, rejects everything queued, and closes the socket if there is one;on_close()is what runs when a connection ends and decides between retrying and reporting the close (on_valkey_close()rejects the cachedconnect()promise and callsonclose). valkey: close the socket on every fail() and mark the client disconnected before onclose runs #37993 addedValkeyDeferredClose::WithoutSocket, an event-loop task that runson_close()for an attempt that failed without ever having a socket, precisely so thatonclosedoes not run inside the call that failed.dispatch::foldis how callbacks dispatched from the event loop report an exception their JS left pending: as an uncaught exception, rather than returning it to a caller that has no idea what to do with it.Probes (debug build of the base branch)
Synchronous report, bad
cavs a real dial:Unbounded redial from
onclose:onclosethat throws:100 clients referenced by nothing but their pending
connect(),Bun.gc(true)after each: