Skip to content

valkey: don't let a stale reconnect timer clobber an in-flight socket - #32803

Open
robobun wants to merge 2 commits into
mainfrom
farm/29c76f4e/valkey-orphaned-reconnect-socket
Open

valkey: don't let a stale reconnect timer clobber an in-flight socket#32803
robobun wants to merge 2 commits into
mainfrom
farm/29c76f4e/valkey-orphaned-reconnect-socket

Conversation

@robobun

@robobun robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a Bun.RedisClient connection-lifecycle bug where a stale auto-reconnect timer starts a second, competing connection and orphans the one already in flight. On debug/assertion builds the orphan's late traffic trips:

panic: assertion failed: self.this_value.get().is_strong()
  on_valkey_connect <- handle_hello_response <- handle_response <- ValkeyClient::on_data <- SocketHandler::on_data<false>

On release builds the same sequence silently runs socket callbacks against a client whose JS wrapper is only weakly referenced (it is also an fd leak: the orphaned socket is never closed).

Reproduction (scripted in-process RESP3 server; full version is the test):

  1. A connected client loses its connection; the auto-reconnect branch arms a 50ms reconnect timer.
  2. Before the timer fires, user code calls connect(). do_connect() starts a new socket; the timer stays armed.
  3. The server withholds the new socket's HELLO reply, so is_reconnecting is still set when the timer fires. on_reconnect_timer() -> reconnect() -> connect() creates another socket and assigns it to client.socket, orphaning the in-flight one.
  4. The orphan is live and un-owned. Its late on_open resets is_authenticated and writes a HELLO, but never sets status = Connecting or re-upgrades the wrapper ref, so by the time the reply arrives the client is Disconnected with a weak (or collected) wrapper, and on_valkey_connect asserts.

Cause

reconnect() only checked is_reconnecting. A reconnect timer armed by a connection loss is not cancelled by a later explicit connect(), and connect() assigns client.socket without closing the previous one, so the timer firing while a connection is in flight (Status::Connecting, or even Connected) clobbers a live socket. is_reconnecting is only cleared by a successful HELLO, so a withheld or slow HELLO response is enough to keep the stale timer armed and active.

Fix

  • reconnect() returns unless status == Disconnected. Disconnected is the only state with no live socket attached (every transition into it detaches or closes the socket first), so connect() can no longer overwrite a live one.
  • connect() now debug_assert!s that the previous socket is closed or detached, so any future path that violates the one-live-socket invariant fails loudly in debug and fuzz builds instead of orphaning a socket.

Not included here: making the SocketHandler callbacks ignore events from a socket other than client.socket (defense in depth for the same class). With the invariant restored at the source that condition is unreachable, and filtering on_close/on_connect_error interacts with the per-socket keep-alive refcount, so it deserves its own change if we want it.

Related but distinct open PRs in this area: #32768 and #32779 fix synchronously-failing connect() paths; this one is about a successful competing connect.

Verification

test/js/valkey/valkey-gc.test.ts gets a spawned fixture with a scripted in-process RESP3 server. It has two oracles, so it does not depend on debug assertions:

  • the server counts accepted connections: 3 with the fix (control, first connection, explicit reconnect), 4 on broken builds (the timer's competing socket);
  • the fixture must exit 0 (on unfixed debug builds it dies on the assertion above).
# unfixed (src/ stashed), bun bd test test/js/valkey/valkey-gc.test.ts
panic: assertion failed: self.this_value.get().is_strong()
(fail) explicit connect() while the auto-reconnect timer is armed does not orphan the in-flight socket

# unfixed release (USE_SYSTEM_BUN=1 bun test test/js/valkey/valkey-gc.test.ts)
error: attempt 0: expected 3 accepted connections, got 4
(fail) explicit connect() while the auto-reconnect timer is armed does not orphan the in-flight socket

# with the fix (bun bd test test/js/valkey/valkey-gc.test.ts)
6 pass, 0 fail

[review] gate passed · iteration 4 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/valkey/valkey-gc.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (e7f52e674)

test/js/valkey/valkey-gc.test.ts:
(pass) custom setter with a foreign receiver throws instead of corrupting the heap [552.14ms]
(pass) RedisClient survives GC after a command throws during argument validation [617.77ms]
(pass) rejects a RESP simple-string reply whose line terminator never arrives [565.30ms]
(pass) RedisClient survives GC across many short-lived instances [725.10ms]
============================================================
Bun Debug v1.4.0 (e7f52e674) Linux x64
Linux Kernel v6.17.0 | glibc v2.41
CPU: sse42 popcnt avx avx2 avx512
Args: "/workspace/bun/build/debug/bun-debug" "-e" "\n    const ATTEMPTS = 3;\n\n    async function attempt() {\n      const helloHeld = Promise.withResolvers();\n      let srvFi
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/valkey/valkey-gc.test.ts:
(pass) RedisClient survives GC after a command throws during argument validation [15.80ms]
(pass) custom setter with a foreign receiver throws instead of corrupting the heap [14.44ms]
(pass) RedisClient survives GC across many short-lived instances [15.12ms]
(pass) rejects a RESP simple-string reply whose line terminator never arrives [18.35ms]
113 |       const accepted = await attempt();
114 |       // Exactly three: control, the first connection, the explicit reconnect.
115 |       // A fourth means a stale reconnect timer opened a competing socket and
116 |       // orphaned one of them.
117 |       if (accepted !== 3) {
118 |         throw new Error("attempt " + i + ": expected 3 accepted connections, got " + accepted);
                        ^
error: attempt 0: expected 3 accepted connections, got 4
      at /workspace/bun/[eval]:118:19

Bun v1.4.0-canary.1+1498d7b77 (Linux x64)
345 |     stderr: "inherit",
346 |   });
347 | 
348 |   const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
349 | 
350 |   expect(stdout.trim()).toBe("OK");
                           
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/valkey/valkey-gc.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (e7f52e674)

test/js/valkey/valkey-gc.test.ts:
(pass) custom setter with a foreign receiver throws instead of corrupting the heap [517.24ms]
(pass) RedisClient survives GC after a command throws during argument validation [698.10ms]
(pass) rejects a RESP simple-string reply whose line terminator never arrives [656.70ms]
(pass) RedisClient survives GC across many short-lived instances [763.68ms]
(pass) explicit connect() while the auto-reconnect timer is armed does not orphan the in-flight socket [1598.13ms]
(pass) getBuffer replies survive GC with adopted backing stores intact [2197.56ms]

 6 pass
 0 fail
 17 expect() calls
Ran 6 tests across 1 file. [6.91s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     e7f52e674d
  features     (none)

22 deps, 106 codegen, 1168 objects in 831ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1231] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [16.00ms]
[2/1231] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [8.00ms]
[3/1231] fetch picohttpparser
[picohttpparser] up to date
[4/1231] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [10.00ms]
[5/1231] gen bindgenv2
[6/1231] gen ErrorCode+*.h
[7/1231] fetch libjpeg-turbo
[libjpeg-turbo] up to date

... (truncated)
diff hotspot
src/runtime/valkey_jsc/js_valkey.rs |  11 +++
 test/js/valkey/valkey-gc.test.ts    | 143 ++++++++++++++++++++++++++++++++++++
 2 files changed, 154 insertions(+)

gate history · 1 passed · 0 rejected · iteration 4

evidence per changed file
file                                 reads  edits  tests
src/runtime/valkey_jsc/js_valkey.rs     13      3     15
test/js/valkey/valkey-gc.test.ts         4      3     15

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds a reconnect-status guard and socket-closed assertion in the Valkey client, plus a concurrent integration test that exercises explicit connect() while the auto-reconnect timer is armed and checks the connection count.

Changes

Valkey reconnect race fix

Layer / File(s) Summary
Reconnect guard and socket precondition
src/runtime/valkey_jsc/js_valkey.rs
reconnect() now returns only when the client is Disconnected, and connect() asserts that the previous socket is closed before replacing it.
Concurrent reconnect race test
test/js/valkey/valkey-gc.test.ts
A concurrent integration test drives explicit connect() while the auto-reconnect timer is armed and verifies the server accepts exactly three connections.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: preventing a stale reconnect timer from clobbering an in-flight socket.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR clearly explains the bug, fix, and verification, and covers the template’s required content despite a non-exact verification heading.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:11 AM PT - Jul 11th, 2026

@robobun, your commit e7f52e6 has 2 failures in Build #71876 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32803

That installs a local version of the PR into your bun-32803 executable, so you can run:

bun-32803 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. redis.connect behave very wrong when failed to connect (see description) #18895 - redis.connect misbehaves on failed connections (silent exit, stuck promises), likely caused by stale reconnect timers firing and creating competing connection attempts

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #18895

🤖 Generated with Claude Code

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

#18895 is a different bug, so I'm not adding the auto-close line. That issue is about connection attempts that fail (connection refused or an unresolvable host): the connect() promise never settles, connectionTimeout is not honored, and the process can exit silently. Those are the synchronous/asynchronous connect-failure paths, which #32779 addresses and already references.

This PR is about the opposite situation: a connection attempt that succeeds while a stale reconnect timer starts a second one, which orphans the in-flight socket. It does not make #18895's promises settle, so it should not close it.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the guard is strictly narrowing (reconnect only from Disconnected) and the debug_assert is release-inert.

Extended reasoning...

Overview

This PR touches src/runtime/valkey_jsc/js_valkey.rs (two hunks: a 3-line early-return guard in reconnect() and a debug_assert! in connect()) and adds a regression test to test/js/valkey/valkey-gc.test.ts. The fix prevents a stale auto-reconnect timer from calling connect() while another connection is already in Connecting/Connected, which previously overwrote client.socket and orphaned a live fd whose late callbacks then drove a client with a weak/collected JS wrapper.

Security risks

None. This is internal connection-lifecycle bookkeeping for Bun.RedisClient; no auth, crypto, parsing, or user-input handling is touched. The change strictly reduces the set of states from which a new socket can be opened.

Level of scrutiny

Low-to-moderate. The source change is tiny and conservative:

  • The new guard only adds an early return — it cannot cause reconnect() to do more than before, only less. The one other caller of reconnect() (do_connect()) already gates on Status::Disconnected before calling it, so the new check is a no-op there and only constrains the timer path.
  • debug_assert!(socket.is_closed()) is release-inert; I verified is_closed() returns true for the Detached variant (src/uws_sys/socket.rs:272-276), which is what every transition into Disconnected and the initial state assign, so the assert holds on existing call paths.

Other factors

  • No CODEOWNERS entry for valkey.
  • The PR description is unusually thorough (full repro trace, cause analysis, before/after test output on both debug and release builds).
  • The regression test has two independent oracles (accepted-connection count, clean exit) so it does not depend on debug assertions, and I traced the with-fix path to confirm the count is deterministically 3 regardless of whether the stale timer fires before or after the explicit connect().
  • The test is somewhat timing-orchestrated (sleepSync(60), 50ms reconnect-delay assumption), but the primary oracle (no 4th accept) holds under any interleaving once the guard is in place, so flakiness risk is low.
  • Bug hunter found nothing; no prior human review comments to address.

@robobun
robobun force-pushed the farm/29c76f4e/valkey-orphaned-reconnect-socket branch from 2dd220a to 71214e2 Compare June 27, 2026 02:12

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/valkey/valkey-gc.test.ts`:
- Line 230: The timing-sensitive subprocess fixture in valkey-gc.test.ts is
being run under test.concurrent, which can skew reconnect-timer deadlines and
leave child processes unmanaged. Change the affected test group to run
sequentially using the existing test wrapper in this file, and keep the
child-process/reconnect logic under a non-concurrent test declaration. Use the
surrounding test case setup in valkey-gc.test.ts to locate the concurrent block
and make only this fixture sequential.
- Around line 212-229: The comment block in the valkey GC test is too long and
includes bug-history details that should not live in source comments. Trim the
preamble in the test fixture to only the durable invariant and keep the rest of
the failure narrative in the PR description; update the comment near the
scripted RESP3 server/reconnect scenario so it stays within the 3-line limit and
still identifies the reconnect/orphaned socket edge case.
- Around line 316-324: The reconnect flow in the valkey GC test is not being
asserted, because c.connect() is fire-and-forget and its rejection is ignored.
Update the test around the explicit reconnect on c so it awaits the reconnect
promise and fails the test if it rejects, then only close c after the awaited
reconnect completes. Use the c.connect() call and the surrounding HELLO-reply
release logic to locate the spot, and make the test assert that reconnect really
succeeds before checking the accepted socket count.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: abe01aa8-63d0-4631-a6f5-cfb5b7320113

📥 Commits

Reviewing files that changed from the base of the PR and between c2b5cef and 71214e2.

📒 Files selected for processing (2)
  • src/runtime/valkey_jsc/js_valkey.rs
  • test/js/valkey/valkey-gc.test.ts

Comment thread test/js/valkey/valkey-gc.test.ts Outdated
Comment thread test/js/valkey/valkey-gc.test.ts Outdated
Comment thread test/js/valkey/valkey-gc.test.ts Outdated
Comment thread src/runtime/valkey_jsc/js_valkey.rs
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

CI on the rebased head (e7f52e6, build 71876, final): 284 jobs passed. The new regression test in test/js/valkey/valkey-gc.test.ts passed on every lane it ran on, including darwin-14-aarch64 (test [3/2532], 6/6 pass). No valkey failures anywhere.

The two red lanes are both darwin-14-aarch64 shards failing on runner environment issues unrelated to this diff:

  • test/js/third_party/grpc-js/test-tonic.test.ts: rustup could not choose a version of cargo to run, because one wasn't specified explicitly, and no default is configured (missing rustup default toolchain on the box; the test needs cargo to compile a tonic server)
  • test/js/web/websocket/autobahn.test.ts: docker daemon on the runner

Their first attempt also expired waiting on an agent before the retries above ran. One yellow: test/js/node/http/node-http-connect.test.ts on Windows 2019 x64 (flaked once, passed on retry).

Review feedback addressed, all threads resolved. The diff is green; the red lanes need a runner fix or a skip, not a code change. I'm not pushing retrigger commits.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Independently reproduced the mechanism in this PR on stock 1.4.0 with a scripted RESP3 server: after connect() races the armed 50ms timer, the server sees 3 connections where 2 are expected, and the extra one stays ESTABLISHED after client.close() because client.socket no longer points at it. The reconnect() guard here fixes that, so I'm not opening a competing change.

While reproducing it I hit an adjacent bug this diff does not cover: close() on a client that sits between retries is a complete no-op, because js_disconnect returns early on Status::Disconnected and leaves the timer armed. The client reconnects moments later (connected flips back to true after the user closed it) and the offline queue never settles. That one is #33306.

Verified both are independent: with the reconnect() guard applied, close() during a retry still reconnects (status is Disconnected and is_reconnecting is set when the timer fires, so the guard passes).

robobun added 2 commits July 11, 2026 06:25
An auto-reconnect timer armed by a connection loss stays armed when an
explicit connect() starts a new connection. When it fired, reconnect()
called connect() again, which overwrote `client.socket` without closing
the in-flight socket. The orphaned socket's late open/data callbacks then
drove a client that had already moved on: they reset `is_authenticated`,
sent another HELLO, and handled its reply while the client was
Disconnected and its JS wrapper only weakly referenced, failing
`debug_assert!(self.this_value.get().is_strong())` in on_valkey_connect
and, in release builds, running against a wrapper the GC is free to
collect.

reconnect() now only proceeds from Status::Disconnected, and connect()
debug-asserts that the previous socket is closed or detached.
Run it sequentially, trim the preamble to the invariant, and await the
explicit connect() so a rejection fails the test instead of being
swallowed.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status note: this still reproduces. On the current release (1.4.0) a client whose first dial was refused, then given an explicit connect() during the 50ms retry window against a server that delays its HELLO reply, opens 2 connections (the explicit dial plus the one the stale timer starts). On current main (18391f6) the path is unchanged: do_connect()'s Disconnected arm calls reconnect() without disarming reconnect_timer, and reconnect() still only checks is_reconnecting, which stays set until a HELLO is accepted.

The same sequence came up again in review of #37993 (noted there as pre-existing and out of that PR's scope), so this is worth landing. The branch now conflicts with main: #37971 removed needs_to_open_socket, which is in the context of the connect() hunk. The reconnect() guard itself applies unchanged; disarming reconnect_timer in do_connect() before it calls reconnect() was also suggested there and would pair well with the guard.

repro script used (in-process net server, no redis needed)
import net from "node:net";
import { RedisClient } from "bun";

const port: number = await new Promise(resolve => {
  const l = net.createServer();
  l.listen(0, "127.0.0.1", () => {
    const p = (l.address() as net.AddressInfo).port;
    l.close(() => resolve(p));
  });
});

const client = new RedisClient(`redis://127.0.0.1:${port}`, { maxRetries: 5 });
// first dial via a command so no connect() promise is cached; refused -> 50ms retry armed
const cmd = client.get("x").catch(() => {});
await new Promise(r => setTimeout(r, 15));

let accepted = 0;
const sockets: net.Socket[] = [];
const server = net.createServer(sock => {
  accepted++;
  sockets.push(sock);
  sock.on("data", () => setTimeout(() => sock.write("+OK\r\n"), 200));
});
await new Promise<void>(r => server.listen(port, "127.0.0.1", () => r()));

await Promise.all([client.connect(), new Promise(r => setTimeout(r, 400))]);
console.log({ accepted }); // { accepted: 2 } on 1.4.0, expected 1
client.close();
for (const s of sockets) s.destroy();
server.close();
await cmd;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant