Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4d21306
valkey: close the socket on every fail() and mark the client disconne…
alii Aug 13, 2026
3a0e904
Merge branch 'main' into ali/valkey-fail-recovery
alii Aug 13, 2026
21be6f4
valkey: fix fail_handshake/on_data clobbering a connect() issued from…
robobun Aug 13, 2026
2689715
test(valkey): assert the error code of the first connect() rejection
robobun Aug 13, 2026
5fb1742
Merge remote-tracking branch 'origin/main' into ali/valkey-fail-recovery
robobun Aug 13, 2026
dfcee7c
Merge remote-tracking branch 'origin/main' into ali/valkey-fail-recovery
robobun Aug 13, 2026
682b042
valkey: settle a reconnect whose dial fails outright, disarm the conn…
robobun Aug 13, 2026
134b3dd
valkey: report a dial that fails outright from the event loop
robobun Aug 13, 2026
2fcf109
valkey: fast-shutdown on close() so TLS closes synchronously too, typ…
robobun Aug 13, 2026
a603f2c
valkey: close outright from fail(), fast shutdown only for disconnect()
robobun Aug 14, 2026
1909276
Merge remote-tracking branch 'origin/main' into ali/valkey-fail-recovery
alii Aug 15, 2026
de72c16
Merge remote-tracking branch 'origin/main' into ali/valkey-fail-recovery
alii Aug 15, 2026
218faf0
valkey: defer the close for a TLS context that cannot be built
alii Aug 15, 2026
48e3ef5
valkey: stay Connecting until a deferred no-socket close runs, pin th…
robobun Aug 15, 2026
404fecd
valkey tests: cap the backpressure loop and close unix listeners in f…
alii Aug 15, 2026
38efbc1
valkey tests: arm the same-tick connect() from the PING rejection ins…
robobun Aug 15, 2026
890824d
ci: rerun build canceled by queue cleanup
alii Aug 15, 2026
4ddfb56
Merge remote-tracking branch 'origin/main' into ali/valkey-fail-recovery
robobun Aug 17, 2026
c793bab
valkey tests: make the stub's listen helpers reject when the listen f…
robobun Aug 17, 2026
204e8a9
ci: retrigger
robobun Aug 17, 2026
000413c
valkey: run a first dial that fails outright through the deferred clo…
robobun Aug 18, 2026
5573ec4
valkey: count idle time from connect and restart it on incoming data
alii Aug 13, 2026
97f835b
test(valkey): make the idle timer tests hang or fail outright without…
robobun Aug 14, 2026
9c3ff0b
test: describe how the idle timer is armed now that HELLO OK and data…
alii Aug 18, 2026
bc6884f
test: pin what a rejected SELECT after an accepted HELLO does to the …
alii Aug 18, 2026
a96dd90
valkey: keep auto-reconnect on a duplicate of a failed client
alii Aug 18, 2026
3bf03fa
test: run the deferred-close teardown tests on the ASAN lane and add …
alii Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 37 additions & 37 deletions src/runtime/valkey_jsc/js_valkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,20 @@
}
}

/// `ValkeyClient::on_close()` is otherwise only reached from a socket's
/// close or connect-error callback. Run it for a dial that failed before
/// there was a socket, so the connect() promise, `onclose`, the retry
/// policy and the poll ref are handled the same way. `on_close()` releases
/// the ref a socket would have held, so take one for it to release.
fn on_close_without_socket(&self) -> JsTerminatedResult<()> {
let _guard = self.ref_scope();
self.ref_();
self.client_mut().status = valkey::Status::Disconnected;
let result = narrow_terminated(self.client_mut().on_close());
self.update_poll_ref();
result
}

pub(crate) fn on_reconnect_timer(&self) {
debug!("Reconnect timer fired, attempting to reconnect");

Expand Down Expand Up @@ -1221,15 +1235,13 @@
});

if let Err(err) = self.connect() {
Comment thread
robobun marked this conversation as resolved.
self.fail_with_js_value(
self.global_object
.err(
jsc::ErrorCode::SOCKET_CLOSED_BEFORE_CONNECTION,
format_args!("{} reconnecting", err.name()),
)
.to_js(),
debug!(
"reconnect failed before a socket was opened: {}",
err.name()
);
self.poll_ref.with_mut(|r| r.disable());
// Same outcome as a dial that fails asynchronously: another retry,
// or fail() and a settled connect() promise once retries are used up.
let _ = self.on_close_without_socket();

Check warning on line 1244 in src/runtime/valkey_jsc/js_valkey.rs

View check run for this annotation

Claude / Claude Code Review

on_close_without_socket() enables unbounded stack recursion when onclose reconnects on a sync-failing address

The new sync-failure branch in `reconnect()` calls `on_close_without_socket()` inline, which (with `autoReconnect: false` or `maxRetries: 0`) reaches `on_valkey_close()` — that clears the cached connection promise and then synchronously calls the user's `onclose`. If `onclose` calls `client.connect()` unconditionally, `do_connect()` sees no cached promise, hits the `Disconnected` arm, calls `reconnect()` → same sync failure → unbounded stack recursion. Narrow reachability (unix path gone / fd ex
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
return;
}

Expand Down Expand Up @@ -1375,11 +1387,16 @@
// Callback for when Valkey client needs to reconnect
pub(crate) fn on_valkey_reconnect(&self) {
// SAFETY: adopts connect()'s socket keep-alive ref for the just-closed
// socket. Reached only from `ValkeyClient::on_close()`'s reconnect
// branch, which never calls `on_valkey_close()`, so this scope is the
// sole releaser. The caller holds its own scoped ref, so count > 0.
// socket (or the one `on_close_without_socket()` took in its place).
// Reached only from `ValkeyClient::on_close()`'s reconnect branch,
// which never calls `on_valkey_close()`, so this scope is the sole
// releaser. The caller holds its own scoped ref, so count > 0.
let _socket_ref = unsafe { ScopedRef::adopt(self.as_ctx_ptr()) };

// This timer was bounding the attempt that just ended; left armed it
// fires during the retry delay, and `fail()` then has no socket to
// close and nothing settles connect(). `reconnect()` arms a new one.
self.timer.disarm(self);
self.reconnect_timer
.arm(self, self.client.get().get_reconnect_delay());
}
Expand All @@ -1388,8 +1405,9 @@
pub(crate) fn on_valkey_close(&self) -> JsTerminatedResult<()> {
let global_object = self.global_object;

// SAFETY: adopts connect()'s socket keep-alive ref; the caller holds
// its own scoped ref so count stays > 0 until this drops.
// SAFETY: adopts connect()'s socket keep-alive ref (or the one
// `on_close_without_socket()` took in its place); the caller holds its
// own scoped ref so count stays > 0 until this drops.
let _socket_ref = unsafe { ScopedRef::adopt(self.as_ctx_ptr()) };
let _defer = scopeguard::guard(BackRef::new(self), |p| p.update_poll_ref());

Expand Down Expand Up @@ -1436,19 +1454,6 @@
narrow_terminated(self.client_mut().fail(message, err))
}

pub(crate) fn fail_with_js_value(&self, value: JSValue) {
let Some(this_value) = self.this_value.get().try_get() else {
return;
};
let global_object = self.global_object;
if let Some(on_close) = Js::onclose_get_cached(this_value) {
let _exit = self.vm().enter_event_loop_scope();
if let Err(e) = on_close.call(&global_object, this_value, &[value]) {
global_object.report_active_exception_as_unhandled(e);
}
}
}

fn close_socket_next_tick(&self) {
if self.client.get().socket.is_closed() {
return;
Expand Down Expand Up @@ -1909,9 +1914,6 @@
err_value: JSValue,
) -> JsTerminatedResult<()> {
let _exit = this.vm().enter_event_loop_scope();
this.client_mut().flags.is_manually_closed = true;
let this_br = BackRef::new(this);
let _close = scopeguard::guard(this_br, |p| p.client_mut().close());
narrow_terminated(
this.client_mut()
.fail_with_js_value(&this.global_object, err_value),
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
Expand All @@ -1937,10 +1939,10 @@
let _guard = this.ref_scope();
// Ensure the socket pointer is updated.
this.client_mut().socket = Socket::SocketTcp(uws::SocketTCP::detached());
let _defer = scopeguard::guard(BackRef::new(this), |p| {
p.client_mut().status = valkey::Status::Disconnected;
p.update_poll_ref();
});
// Before `on_close()`: it runs `onclose` and settles the connect()
// promise, and a connect() called from either must see Disconnected.
this.client_mut().status = valkey::Status::Disconnected;
let _defer = scopeguard::guard(BackRef::new(this), |p| p.update_poll_ref());
Comment thread
robobun marked this conversation as resolved.

let _ = this.client_mut().on_close(); // TODO: properly propagate exception upwards
}
Expand All @@ -1962,10 +1964,8 @@
// Ensure the socket pointer is updated.
this.client_mut().socket = Socket::SocketTcp(uws::SocketTCP::detached());
let _guard = this.ref_scope();
let _defer = scopeguard::guard(BackRef::new(this), |p| {
p.client_mut().status = valkey::Status::Disconnected;
p.update_poll_ref();
});
this.client_mut().status = valkey::Status::Disconnected;
let _defer = scopeguard::guard(BackRef::new(this), |p| p.update_poll_ref());

narrow_terminated(this.client_mut().on_close())
}
Expand Down
30 changes: 18 additions & 12 deletions src/runtime/valkey_jsc/valkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ pub struct ConnectionFlags {
pub(crate) is_selecting_db_internal: bool,
pub(crate) enable_offline_queue: bool,
pub(crate) enable_auto_reconnect: bool,
/// Sticky until the next accepted HELLO, so it overlaps `Connecting`
/// (`reconnect()` reads it there) and `failed` (`update_poll_ref` reads it
/// there); that is why it is not a `Status` variant.
/// Set from the close that schedules a retry until the next accepted HELLO
/// or `fail()`, so it overlaps `Disconnected` and `Connecting`; that is why
/// it is not a `Status` variant.
pub(crate) is_reconnecting: bool,
/// Sticky until `on_open`/`connect()`, and orthogonal to `Status`: `fail()`
/// while `Connected` leaves the socket open and `status` unchanged.
/// Sticky until `on_open`/`connect()`; the socket is closed when it is set,
/// so it overlaps `Disconnected`.
Comment thread
alii marked this conversation as resolved.
Outdated
pub(crate) failed: bool,
pub(crate) enable_auto_pipelining: bool,
pub(crate) finalized: bool,
Expand Down Expand Up @@ -607,17 +607,16 @@ impl ValkeyClient {
return Ok(());
}
self.flags.failed = true;
self.flags.is_reconnecting = false;
Comment thread
robobun marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
let val = Self::reject_all_pending_commands(
&mut self.in_flight,
&mut self.queue,
global_this,
jsvalue,
);

if !self.connection_ready() {
self.flags.is_manually_closed = true;
self.close();
}
self.flags.is_manually_closed = true;
Comment thread
alii marked this conversation as resolved.
Outdated
self.close();
Comment thread
robobun marked this conversation as resolved.
Outdated
Comment thread
alii marked this conversation as resolved.
Outdated
val
}

Expand Down Expand Up @@ -652,6 +651,10 @@ impl ValkeyClient {
pub fn on_close(&mut self) -> JsTerminated<()> {
self.unregister_auto_flusher();
self.write_buffer.clear_and_free();
// A partial reply can never complete now; left in place it counts as
// pending activity in `update_poll_ref` and keeps the event loop alive.
self.read_buffer.clear_and_free();
self.reply_scanner.reset();

// If manually closing, don't attempt to reconnect
if self.flags.is_manually_closed {
Expand Down Expand Up @@ -743,6 +746,10 @@ impl ValkeyClient {
data.len(),
bstr::BStr::new(data)
);
// Handling a reply can close this socket and, from `onclose` or a
// rejection handler, dial the next one; the remaining replies came from
// the closed connection and must not reach the new one.
let socket = *self.socket.socket();
// Path 1: Buffer already has data, append and process from buffer
if !self.read_buffer.remaining().is_empty() {
self.read_buffer
Expand Down Expand Up @@ -810,7 +817,7 @@ impl ValkeyClient {
let mut value_to_handle = value; // Use temp var for defer
self.handle_response(&mut value_to_handle)?;

if self.status == Status::Disconnected || self.flags.failed {
if *self.socket.socket() != socket {
Comment thread
robobun marked this conversation as resolved.
return Ok(());
}
self.send_next_command();
Expand Down Expand Up @@ -869,8 +876,7 @@ impl ValkeyClient {
let mut value_to_handle = value; // Use temp var for defer
self.handle_response(&mut value_to_handle)?;

// Check connection status after handling
if self.status == Status::Disconnected || self.flags.failed {
if *self.socket.socket() != socket {
return Ok(());
}

Expand Down
Loading