Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
14 changes: 6 additions & 8 deletions src/runtime/valkey_jsc/js_valkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1937,10 +1937,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());

Check failure on line 1943 in src/runtime/valkey_jsc/js_valkey.rs

View check run for this annotation

Claude / Claude Code Review

fail_handshake's _close scopeguard now closes the socket a connect()-from-onclose opened

The `_close` scopeguard in `SocketHandler::fail_handshake` (js_valkey.rs:1914) is now both redundant and harmful: `fail_with_js_value` unconditionally calls `close()` (this PR removed the `!connection_ready()` guard), and because `status = Disconnected` is now set before `onclose` runs, a user's `connect()` from `onclose` opens a new socket that the `_close` guard then swaps out and closes on unwind. This is the same 'deferred cleanup stomps state that connect()-from-onclose set up' class this P
Comment thread
robobun marked this conversation as resolved.

let _ = this.client_mut().on_close(); // TODO: properly propagate exception upwards
}
Expand All @@ -1962,10 +1962,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
17 changes: 8 additions & 9 deletions src/runtime/valkey_jsc/valkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@
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 @@
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();

Check failure on line 619 in src/runtime/valkey_jsc/valkey.rs

View check run for this annotation

Claude / Claude Code Review

on_data's loop-exit guard is defeated when user's onclose calls connect()

Now that `fail_with_js_value` unconditionally calls `close()` and `SocketHandler::on_close` sets `status = Disconnected` before running the user's `onclose`, a `connect()` called from `onclose` clears `flags.failed` and sets `status = Connecting` before the stack unwinds to `on_data` — so the guard `if self.status == Status::Disconnected || self.flags.failed` (both the buffer path and stack path) no longer returns, and the loop keeps consuming RESP values from the old connection's data. Since `s
Comment thread
robobun marked this conversation as resolved.
Outdated
Comment thread
alii marked this conversation as resolved.
Outdated
val
}

Expand Down
89 changes: 89 additions & 0 deletions test/js/valkey/reliability/connection-failures.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { RedisClient } from "bun";
import { describe, expect, mock, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
import net from "net";
import { DEFAULT_REDIS_OPTIONS, DEFAULT_REDIS_URL, delay, isEnabled } from "../test-utils";

Expand Down Expand Up @@ -441,3 +442,91 @@ describe("Valkey: Auto-Reconnect In-Flight Commands", () => {
}
});
});

describe("Valkey: Recovering After fail()", () => {
function helloServer(onConnection?: (socket: net.Socket, connection: number) => void) {
let connections = 0;
const server = net.createServer(socket => {
connections += 1;
onConnection?.(socket, connections);
socket.on("data", chunk => {
const text = chunk.toString("latin1");
if (text.includes("HELLO")) socket.write("+OK\r\n");
if (text.includes("PING")) socket.write("+PONG\r\n");
});
});
return {
server,
get connections() {
return connections;
},
listen: () =>
new Promise<number>(resolve =>
server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)),
),
};
}

test("an idle timeout while connected closes the socket, fires onclose, and connect() reconnects", async () => {
const closed = Promise.withResolvers<Error>();
const fake = helloServer();
const port = await fake.listen();
try {
const client = new RedisClient(`redis://127.0.0.1:${port}`, {
// The timer armed by connect() is only re-armed by send(), so on an idle
// connection the idle timeout fires once connectionTimeout elapses.
connectionTimeout: 100,
idleTimeout: 50,
autoReconnect: false,
});
client.onclose = err => closed.resolve(err);
await client.connect();
expect(client.connected).toBe(true);
const err = await closed.promise;
expect(err).toBeInstanceOf(Error);
expect(client.connected).toBe(false);
await client.connect();
expect(await client.ping()).toBe("PONG");
expect(fake.connections).toBe(2);
client.close();
} finally {
fake.server.close();
}
});

test("connect() rejects again after a failed attempt instead of hanging", async () => {
// Nothing listens on the port a just-closed listener used.
const fake = helloServer();
const port = await fake.listen();
await new Promise(resolve => fake.server.close(resolve));
const client = new RedisClient(`redis://127.0.0.1:${port}`, { autoReconnect: false });
expect(client.connect()).rejects.toThrow();
await client.connect().catch(() => {});
expect(client.connect()).rejects.toThrow();
await client.connect().catch(() => {});
Comment thread
robobun marked this conversation as resolved.
Outdated
client.close();
});

test("the process exits once auto-reconnect gives up", async () => {
const fake = helloServer();
const port = await fake.listen();
await new Promise(resolve => fake.server.close(resolve));
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const client = new Bun.RedisClient("redis://127.0.0.1:${port}", { autoReconnect: true, maxRetries: 1 });
client.onclose = err => console.log("onclose", err.code);
await client.connect().catch(err => console.log("connect rejected", err.code));
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
Comment thread
robobun marked this conversation as resolved.
Outdated
expect(stdout).toContain("connect rejected");
expect(exitCode).toBe(0);
});
});