Conversation
|
Warning Review limit reached
Next review available in: 21 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
Comment |
|
Updated 6:51 PM PT - Jul 6th, 2026
❌ @robobun, your commit 5170d70 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 33473That installs a local version of the PR into your bun-33473 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
On the What this PR fixes is the symptom @unek describes in that issue's comments: with The repro in the issue body is a different bug and still reproduces on this branch. Connecting to a closed port with
So #18895 should stay open for the behavior in its body, and I've left the PR description alone. |
There was a problem hiding this comment.
Both issues I flagged earlier are now addressed (reset_for_new_connection() in connect(), timer cancellation in on_valkey_reconnect/on_valkey_close, plus regression tests for each). I didn't find anything new, but the connection state-machine and timer-lifecycle interactions here are subtle enough that a human pass is still worthwhile.
Extended reasoning...
Overview
This PR fixes RedisClient's idleTimeout option, which previously reused the connect-phase timer and so fired connectionTimeout ms after connecting regardless of activity. The fix adds reset_idle_timeout() (re-arms the timer on every on_data/send once connected), makes the idle-timeout close path set is_manually_closed/needs_to_open_socket and actually close the socket, moves status = Disconnected before JS runs in on_close/on_connect_error, cancels the stale timer in on_valkey_reconnect/on_valkey_close, and centralizes per-connection flag reset into reset_for_new_connection() called from connect(). Also touches docs (redis.mdx), types (redis.d.ts), and adds a new self-contained test file with an in-process RESP3 mock server.
Prior review follow-up
My earlier run flagged two issues on the first commit: (1) the lazy-reconnect prologue left is_authenticated stale-true, causing non-pipelineable commands to be written to a not-yet-open socket and orphaned in in_flight; (2) the idle timer wasn't cancelled on server-initiated close, so it could fire during the reconnect backoff and permanently poison auto-reconnect via is_manually_closed. The follow-up commit (5124d8b) addresses both — reset_for_new_connection() now clears all four flags and is called from connect() (covering send(), do_connect(), and reconnect() uniformly), and both on_valkey_reconnect and on_valkey_close now call disable_connection_timeout(). Each fix has a dedicated regression test in valkey-timeout.test.ts (the non-pipelineable/enableAutoPipelining: false reopen tests, the server-initiated-close test, and the un-awaited-connect race test).
Security risks
None identified. No auth, crypto, or input-validation surface is touched; the change is timer arming/cancellation and boolean flag resets on an existing state machine.
Level of scrutiny
Moderate-to-high. The Redis client's connection lifecycle is a bag of ~10 interacting boolean flags plus two timers across several close/reconnect/reopen paths, and my first pass found two real off-by-one-state bugs. The follow-up commit looks correct and is well-tested, and the bug-hunting system found nothing further, but the interaction surface (idle-timeout close → lazy reopen, server-close → auto-reconnect, connect() now resetting flags on every call including from reconnect()) is intricate enough that a human familiar with this state machine should confirm nothing else is perturbed.
Other factors
Test coverage is good: five scenarios against an in-process RESP3 server (busy-survives-connectionTimeout, idle-close-then-reopen, non-pipelineable reopen ×2, server-close-doesn't-leak-timer, handshake-timeout-still-fires, connect-race), and the PR description reports the docker-gated reliability/unit suites pass. The redundancy between do_connect()'s manual flag clears and connect()'s new reset_for_new_connection() is harmless.
|
Agreed on wanting a human pass, and thanks for the two catches. On the one loose end: the overlap between Worth noting for whoever picks this up:
That was patched by clearing the flag in |
RedisClient had one timer, armed at connect with connectionTimeout and never re-armed. What its firing meant was decided from the status at that moment, so on a connected client it was reported as an idle timeout: any client with idleTimeout > 0 died connectionTimeout ms after connecting no matter how busy it was, and a connection that was genuinely idle was never closed. Cancel the connect-phase timer once the handshake completes, arm the idle timer instead, and reset it on every command sent and every reply received. When it fires, close the socket so onclose runs and the next command opens a new connection, rather than leaving a client that reports connected but rejects every command. Also flip status to Disconnected before on_close runs JS: onclose handlers and rejected promises were observing connected === true on a dead socket.
…lose Two problems with the previous commit, both found in review. A reopened connection kept the closed connection's is_authenticated, so enqueue() saw connection_ready() and wrote the command straight to a socket that had not opened yet. on_open then dropped those bytes and left the promise in the in-flight queue, so every later reply was paired with the wrong command. Reachable with any non-pipelineable command (INFO, MULTI, WATCH, SUBSCRIBE, ...) or with enableAutoPipelining: false. The same state confusion was already reachable without idleTimeout, via close() followed by an un-awaited connect(). Reset the per-connection flags in connect(), which every path that opens a socket goes through. The timer was not cancelled when the connection closed, so it could fire during the reconnect delay, reject the offline queue with a "Connection timeout reached after 10000ms" that never applied, and set is_manually_closed, which on_open does not clear: auto-reconnect was then disabled for good. Cancel it in on_valkey_close and on_valkey_reconnect, the two hooks every close path funnels through. connect() arms a fresh deadline for the next attempt.
5124d8b to
40445fb
Compare
There was a problem hiding this comment.
Both earlier issues are addressed — reset_for_new_connection() in connect() and the disable_connection_timeout() calls in on_valkey_close/on_valkey_reconnect look correct, and the new tests cover the variants. No new issues on this pass; leaving final sign-off to a maintainer given how many connection-state flags and timer transitions this touches.
Extended reasoning...
Overview
This PR fixes idleTimeout on Bun.RedisClient: previously the single connect-phase timer was never re-armed, so it fired connectionTimeout ms after connecting and was misreported as an idle timeout. The fix introduces reset_idle_timeout() (called from on_data and send), cancels the timer in on_valkey_close/on_valkey_reconnect, adds reset_for_new_connection() to clear per-connection flags before opening a new socket, moves status = Disconnected ahead of the JS-visible close path, and makes the idle-timeout close suppress auto-reconnect while allowing lazy reopen on the next command. Docs and .d.ts are updated, and a new 267-line test file exercises the timer/reopen matrix against an in-process RESP3 server.
Since my previous review, the author addressed both issues I raised: (1) stale is_authenticated on the lazy-reconnect path is now cleared in connect() via reset_for_new_connection(), which also fixes a pre-existing close() + un-awaited connect() off-by-one; (2) the idle timer is now cancelled on every close path via disable_connection_timeout() in both on_valkey_close() and on_valkey_reconnect(). Both fixes have dedicated tests, and the two inline threads are resolved.
Security risks
None identified. This is client-side timer and connection-state management; no auth, crypto, or untrusted-input parsing is touched. The RESP3 mock server in the test file is test-only.
Level of scrutiny
Moderate-to-high. The change is well-reasoned and well-tested, but it reworks the interaction between five connection flags (is_authenticated, is_manually_closed, failed, needs_to_open_socket, is_selecting_db_internal), two timers, and three close/reconnect entry points. My first pass found two real bugs in exactly these interactions, and the author's own follow-up notes a remaining (intentional) overlap between do_connect()'s flag clears and reset_for_new_connection(). The design decision to suppress auto-reconnect on idle-timeout (setting is_manually_closed = true) is reasonable but is a behavioral choice a maintainer should confirm.
Other factors
- The author explicitly agreed a human pass is warranted.
- Test coverage is thorough (7 new tests including the non-pipelineable /
enableAutoPipelining: falsevariants, server-initiated close, and the un-awaitedconnect()race), and the docker-gated reliability suites were run. - The current bug-hunting pass found no issues.
- The
status = Disconnectedreordering inSocketHandler::on_close/on_connect_erroris a small but user-visible behavior change (.connectednow readsfalseinsideonclose); it's clearly correct but worth a maintainer glance.
|
CI status: the diff is green; the only red lane is Buildkite infrastructure, not this change. Both runs (builds 69033 and 69263) fail on the same lane, The I re-triggered once already and it hit the identical timeout, so I am not going to keep re-rolling. A maintainer re-running just the failed darwin job (or a merge, since the failure cannot involve this diff) is the path forward. Review threads are all resolved and the latest Claude review signed off. |
|
Superseded by #38281, which re-arms the timer with idleTimeout once HELLO is accepted and again on every read, and documents what idleTimeout counts. Tests are in test/js/valkey/reliability/connection-failures.test.ts. |
|
Agreed, thanks. I checked the merged stack (#39513, #39511, #38281) against the test file from this PR on current main (4c68990), since review here had turned up two extra bugs beyond the timer itself. All of them are covered:
The only tests from here that fail on main are the three that expected the next command after an idle close to reopen the connection by itself. Main documents the opposite ( |
Repro
idleTimeout: 60_000withconnectionTimeout: 250, against a scripted RESP3 server, issuing a command every 25ms:The client dies exactly
connectionTimeoutms after it connected, while it is busy, andonclosenever fires. With the defaultidleTimeout: 0the same mis-armed timer fires too, but its handler is a no-op, which is why only opt-in users see it. Previously reported as #18897 and #19044, both closed by changing the default to0.Cause
There is one timer.
connect()arms it withconnection_timeout_ms, and nothing cancels or re-arms it afterwards.on_connection_timeoutthen decides what the firing means from the status at that moment:So the connect-phase timer detonates on a now-connected client and is reported as an idle timeout. Conversely, a connection that really is idle is never closed, because the idle interval is never armed.
The failure path then left the client unusable but undetectable:
fail_with_js_valueonly closes the socket when!connection_ready(), so a connected client keptstatus == Connected(.connected === true), never firedonclose, never auto-reconnected, and rejected every later command withConnection has failed.Fix
The two-phase timer mirrors what
PostgresSQLConnectionalready does.reset_idle_timeout()re-arms the timer only once the handshake completed, so traffic cannot push out the connect deadline. Called after every reply (on_data) and every command (send). The handshake reply lands inon_datatoo, so that is also where the connect-phase timer gets swapped for the idle timer (or cancelled, whenidleTimeoutis 0).oncloseruns,.connectedgoes false, pending commands reject withERR_REDIS_IDLE_TIMEOUT. Auto-reconnect is suppressed (dropping an idle connection is deliberate, reconnecting it immediately would defeat the option), and the client reopens lazily on the next command, like a freshly constructed one.on_valkey_close()andon_valkey_reconnect(), the two hooks every branch ofValkeyClient::on_close()funnels through. Left armed, it fired during the reconnect backoff and rejected the offline queue with aconnectionTimeoutthat never applied, while settingis_manually_closed, whichon_opendoes not clear: auto-reconnect was then off for good.connect()calls the newValkeyClient::reset_for_new_connection()before opening a socket, so a reopened connection does not inherit the closed one'sis_authenticated. Without it,enqueue()sawconnection_ready()and wrote the command straight to a socket that had not opened yet;on_opendropped those bytes and the promise stayed in the in-flight queue, pairing every later reply with the wrong command. That one was already reachable onmainwithoutidleTimeout, viaclose()plus an un-awaitedconnect().statusis set toDisconnectedbeforeon_closeruns JS, soonclosehandlers and rejected promises no longer observe.connected === trueon a dead socket.ValkeyClient::close()already ordered it this way on its semi-socket path.connectionTimeoutnow bounds exactly the connect phase, which is what it is documented to do.Verification
test/js/valkey/valkey-timeout.test.ts(new, talks to an in-process RESP3 server so it needs no redis):connectionTimeoutidleTimeout, reopens on next commandonclosenever firesenableAutoPipelining: falsereopens after an idle closeERR_REDIS_CONNECTION_TIMEOUTconnect()gets its own replyconnectionTimeoutstill fires on a stalled handshakeThe server-close test is deterministic rather than racy: the hangup (270ms), the idle deadline (300ms) and the reconnect (~320ms) are all deadlines in one timer heap, so their order holds even under a stall. 10/10 clean runs.
Also ran the docker-gated suites against a local redis (
reliability/recovery.test.ts(#29925),reliability/connection-failures.test.ts,unit/*,integration/complex-operations.test.ts): 84 pass, 1 fail, and that one needs a route to192.0.2.1and fails identically onmainin the same container.Manual check against a real redis (pub/sub, manual close, server-side
CLIENT KILL+ autoReconnect, idle close + reopen)