Conversation
|
Updated 9:23 PM PT - Jun 29th, 2026
❌ @robobun, your commit baca8a8 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 33104That installs a local version of the PR into your bun-33104 --bun |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughValkey client shutdown now releases poll references for manually closed or failed subscribers, clears buffered reply state on socket close, and adds concurrent child-process tests for subscriber exit and partial-reply disconnect paths. Valkey client lifecycle and exit paths
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 403: The test currently adds an explicit per-test timeout via the
trailing 30_000 on the relevant valkey GC test, which conflicts with the
guideline to rely on Bun’s built-in timeouts. Remove the test-level timeout from
the affected test case in valkey-gc.test.ts and leave the child Bun.spawn({
timeout: 10_000 }) to bound the hang path instead; use the surrounding test
block in valkey-gc.test.ts to locate the change.
🪄 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: 6c92b19c-0d34-404a-b9a3-4abb5544e0a3
📒 Files selected for processing (2)
src/runtime/valkey_jsc/js_valkey.rstest/js/valkey/valkey-gc.test.ts
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
1b6c197 to
006b4c3
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
test/js/valkey/valkey-gc.test.ts:403— nit:test/CLAUDE.mdsays "CRITICAL: Do not set a timeout on tests. Bun already has timeouts." TheBun.spawntimeout: 10_000already bounds the pre-fix hang for thesignalCodeassertion, so the outer30_000test-level timeout can be dropped to match the other tests in this file.Extended reasoning...
What
The new test at
test/js/valkey/valkey-gc.test.ts:403passes30_000as the second argument totest.concurrent(...), which sets an explicit per-test timeout inbun:test.test/CLAUDE.md(line 120, under the "### No timeouts" heading) states:CRITICAL: Do not set a timeout on tests. Bun already has timeouts.
None of the other four tests in
valkey-gc.test.tsset a test-level timeout, so this is also locally inconsistent with the surrounding style.Why it's redundant
The load-bearing bound for this test is the
timeout: 10_000passed toBun.spawnat line 386. That spawn timeout is what kills the hung subscriber subprocess in the pre-fix case and produces a non-nullsignalCode, which is exactly what the assertion at line 398 checks. The outer 30s test-level timeout never participates in the pass/fail decision of the assertions themselves.Step-by-step
- Post-fix (happy path): the subprocess prints
closedand exits 0 in well under a second.proc.exitedresolves immediately, all assertions pass. Neither the 10s spawn timeout nor the 30s test timeout fires. - Pre-fix (regression path): the subprocess hangs after printing
closed. After 10s,Bun.spawn'stimeoutsends SIGTERM,proc.exitedresolves,proc.signalCodeis"SIGTERM", andexpect(proc.signalCode).toBeNull()fails with a clear diff. The 30s outer timeout still never fires.
So in both the passing and failing scenario the explicit per-test timeout is dead weight.
Caveat
There is one defensible reason for it: Bun's default per-test timeout is 5s, which is shorter than the 10s spawn timeout. On regression, without the explicit
30_000, the test framework would report a generic "timed out after 5000ms" before the spawn timeout fires and thesignalCodeassertion runs. That's a slightly worse diagnostic, but it still correctly fails the test — and the project rule is explicit that per-test timeouts shouldn't be used. If the diagnostic matters, lowering the spawntimeoutto under 5s (e.g.4_000) would preserve the cleansignalCodediff while staying within the convention. Also worth noting: a few other files undertest/js/valkey/(e.g.test-utils.ts,valkey.test.ts) do set explicit timeouts, so the rule isn't uniformly enforced repo-wide — hence flagging this as a nit rather than a blocker.Fix
Drop the trailing
, 30_000from line 403:} finally { server.close(); } });
Optionally, lower the
Bun.spawntimeoutto ~4s so thesignalCodeassertion still produces a precise failure message under the default test timeout in the regression case. - Post-fix (happy path): the subprocess prints
3fa39a9 to
52c12d4
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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`:
- Around line 353-359: The mock server in the Valkey GC test is matching
protocol tokens directly against each raw TCP data chunk, so HELLO and SUBSCRIBE
can be missed when split across reads. Update the socket handler in the test’s
mock server logic to accumulate incoming bytes in a buffer and inspect the
assembled request framing before replying, using the existing
createServer/socket.on("data") flow so the responses are triggered once the full
command is observed.
🪄 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: 3e655fb1-3e52-4774-9da1-c07bbdb20158
📒 Files selected for processing (2)
src/runtime/valkey_jsc/js_valkey.rstest/js/valkey/valkey-gc.test.ts
6db13fc to
b1f7bac
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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`:
- Around line 450-453: The test in the RedisClient subscribe flow is swallowing
the subscribe() rejection and then unconditionally logging “subscribed,” which
can mask the real regression. Update the code around the Bun.RedisClient
connect/subscribe sequence to let subscribe() failures surface instead of
catching them, and only emit the “subscribed” log after the subscription is
actually established. This should be fixed in the test logic that uses
sub.connect() and sub.subscribe("chan", ...), so the assertion fails for the
correct reason if subscriber mode is never reached.
🪄 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: 7990475b-c32e-4dad-8463-5044ea507ad8
📒 Files selected for processing (2)
src/runtime/valkey_jsc/js_valkey.rstest/js/valkey/valkey-gc.test.ts
b1f7bac to
a9cb425
Compare
1a1cea5 to
4552774
Compare
| // failure (failed: server dropped us with no reconnect, retries | ||
| // exhausted). Both are reset by do_connect(), so a reconnect re-arms. | ||
| let subs_deletable: bool = self.client.get().flags.finalized | ||
| || self.client.get().flags.is_manually_closed |
There was a problem hiding this comment.
🔴 There's one more sibling path where the new is_manually_closed check can't fire: close() called during reconnect backoff. js_disconnect() (line 1040) early-returns when status == Disconnected without setting is_manually_closed, cancelling the reconnect timer, or re-running update_poll_ref() — and status == Disconnected is exactly the state an auto-reconnecting subscribed client sits in for the whole backoff window. The user's close() is silently discarded, is_reconnecting=true keeps has_activity true, and when the timer fires the client reconnects despite having been closed. The early-return is pre-existing, but it's the JS entry point for the very close() flow this PR fixes; setting is_manually_closed=true, clearing is_reconnecting, cancelling the reconnect timer, and calling update_poll_ref() before (or instead of) the early-return covers it.
Extended reasoning...
What the bug is
The new || is_manually_closed term at line 1779 relies on js_disconnect() → ValkeyClient::disconnect() (valkey.rs:1457-1463) having set flags.is_manually_closed = true. But js_disconnect() (js_valkey.rs:1039-1044) early-returns when status == Disconnected:
pub fn js_disconnect(&self, ...) -> JsResult<JSValue> {
if self.client.get().status == valkey::Status::Disconnected {
return Ok(JSValue::UNDEFINED); // ← never reaches disconnect()
}
self.client_mut().disconnect();
...
}status == Disconnected is exactly the state a subscribed auto-reconnecting client sits in for the entire reconnect-backoff window: SocketHandler::on_close's defer guard sets status = Disconnected, and ValkeyClient::on_close()'s reconnect branch (valkey.rs:677-682) sets is_reconnecting = true and arms the reconnect timer with a 50ms–2s+ delay. Calling close() in that window is therefore a complete no-op — is_manually_closed stays false, is_reconnecting stays true, the reconnect timer stays armed, update_poll_ref() is never re-run.
Why the PR's check doesn't help here
At line 1779, is_manually_closed = false and failed = false (the reconnect branch doesn't call fail()), so neither new term fires. Even if subs_deletable were true, is_reconnecting = true independently keeps has_activity = true at line 1788. And nothing re-runs update_poll_ref() because js_disconnect() returned before doing anything.
The PR's new source comment at lines 1774-1777 says "a manual close() … must stop pinning the loop" — but on this path manual close() does nothing at all.
Step-by-step proof
Default options (autoReconnect: true, maxRetries: 20):
await sub.subscribe("chan", cb)— subscription map populated,status = Connected.- Server drops the socket.
SocketHandler::on_close→ValkeyClient::on_close()→ reconnect branch:is_reconnecting = true, reconnect timer armed viaon_valkey_reconnect(). Defer guard then setsstatus = Disconnected. - User calls
sub.close()(e.g. shutting down after a network blip).js_disconnect()seesstatus == Disconnected→return Ok(UNDEFINED). Nothing changes:is_manually_closed = false,failed = false,is_reconnecting = true, reconnect timer still armed, subscription map still populated. - Reconnect timer fires →
reconnect()→connect().- If the server is back: client silently reconnects,
status → Connected,update_poll_ref()upgradesthis_valueand refs the loop. The process is pinned by a client the user believes is closed. - If the server stays down: retries continue until exhaustion, then hits the still-open
is_reconnectinghang from the other review comment.
- If the server is back: client silently reconnects,
This is the same user-visible failure the PR title describes ("close() while subscribed" not releasing the loop), on a sibling state the PR's tests don't exercise — both new tests call close() while status == Connected, or don't call close() at all.
Relationship to other comments
This is distinct from the resolved autoReconnect:false comment (no close() there), the open is_reconnecting-after-retries-exhausted comment (no close() there either, and that's post-exhaustion not mid-backoff), and bug_001 (read_buffer/has_pending_commands). The resolved comment's extended reasoning did flag this exact gap in passing ("the user can't even work around it by calling .close(): js_disconnect early-returns when status == Disconnected"), but it wasn't addressed.
Scope
The early-return in js_disconnect() is pre-existing — the PR doesn't touch that function. But it's the JS entry point for the very close() flow this PR fixes, in the same file, and it directly defeats the line-1779 addition on this path. The same "fix the whole class" rationale was already applied twice in this PR for the autoReconnect:false and failed siblings.
Suggested fix
Before (or instead of) the status == Disconnected early-return in js_disconnect(), set flags.is_manually_closed = true, clear flags.is_reconnecting, cancel the reconnect timer (self.remove_timer(&self.reconnect_timer)), and call self.update_poll_ref(). Alternatively drop the early-return and let disconnect() set the flag (it already guards close() on status), then cancel the timer and update_poll_ref() afterward. Either way reconnect() should probably also check is_manually_closed so an already-armed timer fizzles.
There was a problem hiding this comment.
Confirmed real, but scoping this one out of this PR (leaving the thread open for a maintainer to weigh in). Reasoning: it is distinct from the event-loop leak this PR targets. The pure leak aspect is already bounded by the retries-exhausted fix here: if the server comes back the client reconnects and is pinned as a live subscriber (the existing intentional behavior), and if it stays down, retries exhaust and the new is_reconnecting gate releases it. What remains is a close()-semantics bug: close() during the reconnect-backoff window is silently ignored and the client reconnects anyway. Fixing that correctly means cancelling the pending reconnect (timer + is_reconnecting) in js_disconnects status == Disconnected` early-return, plus deciding pending-command/onclose behavior for that window, which is a reconnect-lifecycle change rather than the poll-ref decision this PR fixes. It also has no deterministic hermetic trigger (no event fires at backoff start and the 50ms delay is internal), so it is not cleanly testable here. Happy to do it as a focused follow-up, or fold it in if you would prefer it here.
4552774 to
8406aa9
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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`:
- Around line 570-573: The truncated-reply test is too weak because it only
checks that the child exits cleanly and prints "done", so it can pass even if
the mock never actually sent the partial GET reply. In the valkey GC test around
the `sentPartial` flag and the child-process cleanup, add an explicit assertion
that `sentPartial` became true after the child finishes, so the test proves
`c.get("k")` exercised the truncated-reply path. Keep the assertion tied to the
existing `sentPartial`, `socket.write`, and child completion flow so the failure
clearly indicates the mock path was not triggered.
🪄 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: 33495fe6-f129-4929-8a6f-c6bd4bbe2171
📒 Files selected for processing (3)
src/runtime/valkey_jsc/js_valkey.rssrc/runtime/valkey_jsc/valkey.rstest/js/valkey/valkey-gc.test.ts
8406aa9 to
006da22
Compare
…ropped RedisClient kept the process alive forever when a connection ended without an explicit unsubscribe(). update_poll_ref decides whether to hold the event-loop poll ref (and the strong ref to the JS wrapper), and every term of that decision missed the terminal cases: - subs_deletable was driven only by the subscription callback map (cleared only by unsubscribe()), so a closed or failed client with a non-empty map stayed pinned. Treat a terminally-dead client (finalized, is_manually_closed, or failed) as having no live subscriptions. - has_activity ORs in is_reconnecting, set on the first reconnect attempt and only cleared on a successful HELLO. The retries-exhausted give-up branch never clears it, so a subscribed client that exhausted its retries stayed pinned. Gate that term on !failed. - has_activity also ORs in has_pending_commands, which counts read_buffer.len(). on_close cleared write_buffer but not read_buffer, so a reply that was only half-read when the socket died kept the client pinned. Clear read_buffer and reset the reply scanner in on_close, like on_open already does. is_manually_closed and failed are reset by do_connect(), so a reconnect re-arms the keep-alive. Covers manual close(), server-side terminal disconnects with autoReconnect:false, exhausted reconnect retries, and half-read replies.
006da22 to
baca8a8
Compare
|
CI red on this run is unrelated flake, not this diff:
This PR's own tests ( |
|
I'm encountering this in Bun 1.4.0 too. Looking forward to this being merged! |
|
Thanks for the nudge. The fix is up in this PR and its tests are green; it's waiting on a maintainer to review and merge. In the meantime, on 1.4.0 you can work around it by unsubscribing before closing: await subscriber.unsubscribe(); // all channels (or pass a specific channel)
subscriber.close();That releases the same event-loop reference the fix does, so the process can exit. |
|
Closing this PR. #42590 fixed the same defect on main.
Verified on a debug build of main at 0d3492e:
The fix is in the next canary build and the next release. Until then, call |
Fixes #33103.
Repro
With any Redis server running, close a
RedisClientthat is still in subscriber mode. The script prints its last line but the process never exits.Uncommenting
await subscriber.unsubscribe("chan")beforeclose()makes it exit 0 immediately.Cause
JSValkeyClient::update_poll_ref()decides whether to hold the event-loop poll ref (and a strong ref to the JS wrapper) fromhas_activity, which is true whenever the subscription callback map is non-empty:A manual
close()(disconnect()->close()->on_valkey_close()) setsis_manually_closed, closes the socket, and fails pending commands, but never clears the subscription callback map. Sohas_subscriptions()stays true,has_activitystays true, and the poll ref plus the strongthis_valueare held forever. The map is only emptied by an explicitunsubscribe(), which is why the workaround is to unsubscribe before closing.Fix
Treat a manually-closed client the same as a finalized one when deciding whether subscriptions still count as activity. Once
is_manually_closedis set the connection is terminally dead (the socket is gone and no reconnect happens), so its subscription handlers can no longer deliver a message and must stop pinning the event loop.is_manually_closedis reset indo_connect(), so a client that reconnects re-arms its keep-alive normally.Verification
New test in
test/js/valkey/valkey-gc.test.ts(hermetic: an in-process RESP3 mock server, no Docker). It spawns a subscriber subprocess that connects, subscribes, andclose()s, with the mock server in the parent process so the subprocess's exit depends only on the client releasing its refs.signalCodeisSIGTERM, notnull).Manual checks against a real Redis with the debug build:
close()while subscribed now exits 0.close()still keeps the process alive (no over-eager ref drop).