Skip to content

valkey: reject connect() and call onclose with the real failure reason - #39569

Closed
robobun wants to merge 6 commits into
mainfrom
farm/eea5b9c0/valkey-connect-failure-reason
Closed

robobun wants to merge 6 commits into
mainfrom
farm/eea5b9c0/valkey-connect-failure-reason

Conversation

@robobun

@robobun robobun commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator

Adopted from #39542 (@alii). The four commits from that branch are unchanged. Two commits are added on top, see "Added in this PR" below. This PR supersedes #39542.

Problem

  • connect() rejects with ERR_REDIS_CONNECTION_CLOSED "Connection closed" for every failure. onclose gets the same generic error. A wrong password, a refused port, a missing unix socket path, a rejected certificate and an idle timeout all look the same (Connection to redis fails on prod 7.4 redis redislabs server (local redis works fine) #23467).
  • Only the commands queued behind the dial get the real error.
  • Cause: on_valkey_close (src/runtime/valkey_jsc/js_valkey.rs) builds a new generic error for the promise and for onclose. The code that closed the socket already had the real one.

Fix

  • ConnectionFlags.failed (a bool) is replaced by ValkeyClient.failure: Option<Strong>. It holds the JS error that rejected the queued commands. fail_with_js_value sets it once. connect() and on_open clear it. Every reader of the old bool reads is_some().
  • on_valkey_close rejects the connect() promise and calls onclose with that recorded error. The commands, the promise and onclose get one object.
  • ValkeyClient::on_close takes a CloseReason. The socket close callback passes SocketClosed ("Connection closed"). on_connect_error passes the errno or the resolver text in the node:net shape: connect ECONNREFUSED 127.0.0.1:6379, getaddrinfo ENOTFOUND host. A dial that fails inside connect() passes the errno it left, for example connect ENOENT /run/redis.sock. uws::ConnectError now carries that errno.
  • The errno normalisation of Bun.connect (WSA codes mapped on Windows, unix path errors kept, the rest ECONNREFUSED) moves to one function, bun_errno::connect_errno. Bun.connect and the redis client use it.
  • The TLS context failure in connect() no longer calls fail() early. It hands its reason to the deferred close and goes through on_close() like every other dial failure.
  • When the retries are used up, "Max reconnection attempts reached" replaces the last attempt's reason. The result is the same when the last dial failed inside connect() and when it failed from the event loop.
  • The value is a Strong because the TLS context failure crosses one event loop turn before on_valkey_close runs.
  • Error codes do not change. Authentication failures keep ERR_REDIS_AUTHENTICATION_FAILED. Timeouts keep ERR_REDIS_CONNECTION_TIMEOUT and ERR_REDIS_IDLE_TIMEOUT. TLS verify errors keep their OpenSSL code. Every other close keeps ERR_REDIS_CONNECTION_CLOSED.

Added in this PR

  • packages/bun-usockets/src/bsd.c: bsd_create_connect_socket and bsd_create_connect_socket_unix set errno from the connect result after they close the failed socket. The Windows path and the bind() failure above them already do this. Before, the POSIX path relied on close(2) not touching errno. The Rust side reads errno right after these calls. The Linux close of the long path dirfd keeps errno for the same reason.
  • A test for the resolver branch of on_connect_error. The host has a 64 byte label, so the lookup fails locally. It expects getaddrinfo ENOTFOUND <host> on one object.

Verification

  • bun bd test test/js/valkey/reliability/connection-failures.test.ts: 38 pass, 13 skip (the skipped tests need the redis_unified container, they ran in the CI of valkey: reject connect() and call onclose with the real failure reason #39542).
  • USE_SYSTEM_BUN=1 bun test ...connection-failures.test.ts -t "connect() error identity": all 10 tests fail. Nine fail on expect(await get).toBe(err) (the objects differ), one hangs on the older binary.
  • bun bd test test/js/valkey/valkey-gc.test.ts and the redis regression tests (22243, 23621, 24385, 29925): 40 pass.
  • bun bd test test/js/node/net/node-net.test.ts (it covers the sync dial errnos that the bsd.c change touches): the ENOENT, ENOTSOCK and EADDRINUSE tests pass. The 10 failures in that file are the same with the unmodified binary in this container (localhost resolution).
  • CI of valkey: reject connect() and call onclose with the real failure reason #39542 at e99cc42 was green.

Visible changes

connect() rejections and the onclose argument carry the specific reason. Six existing tests in connection-failures.test.ts pinned the generic one and are updated:

  • idle timeout over redis:// and rediss://: onclose gets ERR_REDIS_IDLE_TIMEOUT.
  • a refused connection: the message is connect ECONNREFUSED 127.0.0.1:<port>.
  • connect() from onclose after WRONGPASS: ERR_REDIS_AUTHENTICATION_FAILED with the server text.
  • a rejected SELECT: onclose gets the server's ERR DB index is out of range.
  • a failed TLS handshake: connect() rejects with the TLS error (ECONNRESET).

Not in this PR: a new error code for a refused connection, and a docs change for the error code list.

Note for the merge order: #39546 adds a call to on_close() and #39547 reads flags.failed. Whichever of those lands after this PR needs a one line update (on_close(CloseReason::SocketClosed), failure.is_some()).

Background

  • The redis client has two layers. ValkeyClient (valkey.rs) owns the protocol state and the command queues. JSValkeyClient (js_valkey.rs) owns the JS wrapper, the connect() promise and the onclose callback. ValkeyClient::on_close runs the retry policy and then calls JSValkeyClient::on_valkey_close, which settles the promise and calls onclose.
  • fail() rejects every queued command with one JS error object and closes the socket. It is the place that knows the real reason.
  • A Strong is a GC root for one JS value. A JS value that is only stored in a native struct field is not seen by the garbage collector. The error must survive from fail() until on_valkey_close runs, and one path crosses an event loop turn in between, so the field is a Strong. The queues in the same struct already hold JSPromiseStrong values, so the struct already has to be dropped on the JS thread.
  • A dial can fail in three places. Inside connect() (for example a unix path that does not exist, errno is read at once). From the event loop through on_connect_error (a refused port or a failed lookup, uSockets passes the errno or the resolver code). Or after a socket exists (auth, TLS verify, timeouts), which goes through fail(). The first two did not go through fail() with a specific message before, which is why on_close now takes a CloseReason.
  • uSockets reports a dial that fails inside connect(2) by returning no socket. The only channel for the errno is the thread's errno, which is why bsd.c has to set it after it closes the socket.

Fixes #23467


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/valkey/reliability/connection-failures.test.ts

alii and others added 6 commits August 18, 2026 04:27
… closed

The Rust callers read errno after bsd_create_connect_socket and
bsd_create_connect_socket_unix fail. Until now the POSIX path relied on
close(2) leaving errno alone. Set it from the connect result the same way
the Windows path and the bind failure above it already do, and keep it
across the close of the long path dirfd on Linux.
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 27 seconds

Limit details: You’ve used all 5 included reviews currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 37111f07-59b8-49f4-ad66-32aab7023bb4

📥 Commits

Reviewing files that changed from the base of the PR and between 4c68990 and ef6741a.

📒 Files selected for processing (7)
  • packages/bun-usockets/src/bsd.c
  • src/errno/lib.rs
  • src/runtime/socket/socket_body.rs
  • src/runtime/valkey_jsc/js_valkey.rs
  • src/runtime/valkey_jsc/valkey.rs
  • src/uws_sys/socket.rs
  • test/js/valkey/reliability/connection-failures.test.ts

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

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status: adopted from #39542 at the request of @alii. The four commits from that branch are unchanged. On top: the errno restore in bsd.c (44dfadb) and the resolver failure test (ef6741a). Verified locally on a debug build, see the PR body. Waiting for CI.

@alii

alii commented Aug 18, 2026

Copy link
Copy Markdown
Member

which is better 39569 or 39542 ? keep one and close the other plz

@alii alii closed this Aug 18, 2026
@alii

alii commented Aug 18, 2026

Copy link
Copy Markdown
Member

oh i see - just maintain the original pr. not a new one

@robobun
robobun deleted the farm/eea5b9c0/valkey-connect-failure-reason branch August 18, 2026 18:33
@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Understood. I pushed the two extra commits (44dfadb, ef6741a) to #39542 and will maintain that PR. This one stays closed.

@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.

Beyond the inline nits, I also checked whether the TLS-context-failure path (which no longer calls fail() early) leaves the connection-timeout timer armed — it doesn't: close_without_socket_next_tick is entered before reset_connection_timeout, and the deferred close's on_close → on_valkey_close path releases the socket ref without needing the timer. The Strong in failure is cleared on every path that would otherwise leak it (connect(), on_open, and struct drop).

Extended reasoning...

This PR replaces the valkey client's flags.failed: bool with failure: Option<Strong> and threads the actual failure reason through on_close/on_connect_error/on_valkey_close. It also touches packages/bun-usockets/src/bsd.c (errno preservation across close(2)), extracts bun_errno::connect_errno for reuse between Bun.connect and the redis client, and adds an errno field to uws::ConnectError. The two inline findings are message-accuracy and test-hygiene nits; neither is blocking. Given the scope (GC-rooted state on a struct with re-entrant close paths, C-level errno handling, cross-PR merge-order notes for #39546/#39547, and user-visible error-message changes), a human maintainer should still review this rather than have it auto-approved.

Comment on lines +1949 to +1953
}
None => JSValkeyClient::connect_error_message(address, code),
};
this.client_mut()
.on_close(valkey::CloseReason::DialFailed(&message))

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.

🟡 Calling close() while an InternalSocket::Connecting is in flight (async DNS / happy-eyeballs) now surfaces connect ECONNREFUSED <host>:<port> instead of the previous neutral Connection closed, because us_connecting_socket_close synthesises ECONNABORTED and connect_errno maps that to ECONNREFUSED. Nothing was refused — the user aborted; consider checking is_manually_closed (or special-casing ECONNABORTED) in on_connect_error and passing CloseReason::SocketClosed in that case. Nit: error code stays ERR_REDIS_CONNECTION_CLOSED and the window is narrow.

Extended reasoning...

What the bug is

When a user calls client.close() while the client's socket is an InternalSocket::Connecting (a hostname that did not parse as an IP literal and had no single cached DNS result — i.e. async DNS or happy-eyeballs is in flight), the connect() promise and onclose now receive the message connect ECONNREFUSED <host>:<port>. Before this PR they received the neutral Connection closed. Nothing was refused; the user aborted the dial themselves.

The specific code path

Step-by-step:

  1. disconnect() → ValkeyClient::close(FastShutdown) → AnySocket::close → ConnectingSocket::close → us_connecting_socket_close(c).
  2. us_connecting_socket_close (socket.c:215-218): if (!c->error) c->error = ECONNABORTED;, then dispatches us_dispatch_connecting_error(c, c->error).
  3. That reaches SocketHandler::on_connect_error(this, from_connecting(c), ECONNABORTED).
  4. socket.dns_error() reads c->error_is_dns ? c->error : 0; error_is_dns was never set for a manual abort → returns 0.
  5. The DNS branch of on_connect_error is skipped (dns_error == 0), so it falls to JSValkeyClient::connect_error_message(address, ECONNABORTED).
  6. bun_errno::connect_errno(ECONNABORTED): ECONNABORTED is not in the KEPT list (ENOENT, ENOTSOCK, EACCES, EINVAL, ECONNRESET, EADDRINUSE, EADDRNOTAVAIL) → returns SystemErrno::ECONNREFUSED.
  7. Message becomes b"connect ECONNREFUSED <host>:<port>"; on_close(CloseReason::DialFailed(&message)) is called.
  8. In ValkeyClient::on_close, is_manually_closed == true and failure.is_none(), so it takes the first branch and calls self.fail(message, ConnectionClosed), which records that string as self.failure.
  9. on_valkey_close rejects the pending connect() promise and calls onclose with that recorded error.

Why existing code doesn't prevent it

Before this PR, on_connect_error ignored code entirely and called on_close() with no argument, which unconditionally used b"Connection closed". Now on_close takes the reason from the caller, and on_connect_error builds one from the errno without distinguishing a caller-initiated abort from a dial that actually failed.

The other close-while-connecting shapes are unaffected: a Connected semi-socket goes through the is_semi_socket branch of ValkeyClient::close (CloseReason::SocketClosed), and a fail()-initiated close of a Connecting socket already has failure.is_some(), so the second fail() inside on_close is a no-op and on_valkey_close reports the previously-recorded failure. Only the manual disconnect() while InternalSocket::Connecting regresses.

Concrete example

const c = new RedisClient('redis://some-hostname-not-in-cache:6379', { autoReconnect: false });
c.onclose = e => console.log(e.message);
const p = c.connect();
c.close();
// onclose and p now carry: "connect ECONNREFUSED some-hostname-not-in-cache:6379"
// before this PR: "Connection closed"

Impact

Message-accuracy regression only. The error code stays ERR_REDIS_CONNECTION_CLOSED, the trigger window is narrow (manual close() during the brief async-DNS/happy-eyeballs window; hostname must not be an IP literal and not in the DNS cache), and the user called close() themselves so they already know why the connection ended. No functional breakage. Still worth noting since the PR's stated purpose is accurate failure messages, and per REVIEW.md "error messages are reviewed word-for-word as code."

How to fix

Either check this.client.get().flags.is_manually_closed in on_connect_error and pass CloseReason::SocketClosed in that case, or special-case code == ECONNABORTED (which uSockets uses specifically as its "caller aborted" sentinel) to CloseReason::SocketClosed. The former is more direct since is_manually_closed is exactly the signal that the user initiated this.

}
});
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));

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.

🟡 nit: stubServer(), closedPort(), and the two inline server setups in this describe block await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve)) without wiring server.on('error', reject), so a listen failure (e.g. EMFILE on a busy runner) would hang to the file timeout instead of surfacing the real error. The pre-existing helloServer() in this file already uses await once(server, 'listening') and comments why ("events.once() rejects if the listen fails instead of leaving the test to time out"); the new helpers should follow that convention. Also affects the listen calls at ~380 (closedPort), ~464 (tls.createServer) and ~500 (net.createServer).

Extended reasoning...

What the issue is

The new connect() error identity describe block adds several test helpers and inline server setups that await server.listen() via a resolve-only Promise:

  • stubServer() at line 373: await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
  • closedPort() at ~380: same pattern for both listener.listen() and listener.close()
  • The inline tls.createServer listen at ~464 (certificate-verify test)
  • The inline net.createServer listen at ~500 (connect-after-auth-failure test)

None of these wire server.on('error', reject). In Node semantics the listen(port, host, callback) callback is registered as a one-shot 'listening' listener, so if listen emits 'error' instead (EMFILE, ENFILE, etc.), the promise never settles.

Why REVIEW.md flags this

REVIEW.md's "Tests reviewers reject" section is explicit: "Wire EVERY failure event (error, close, abort, process exit) to reject the awaited promise — never throw inside event callbacks." This is a repo-level check, not a subjective style preference.

The in-file convention it diverges from

The pre-existing helloServer() helper in the very same file already handles this correctly and documents why:

// events.once() rejects if the listen fails instead of leaving the test to time out.
listen: async () => {
  server.listen(0, "127.0.0.1");
  await once(server, "listening");
  ...
},

events.once() automatically rejects when the emitter emits 'error', which is exactly the behaviour the new helpers are missing.

Step-by-step: how it manifests

  1. A CI runner is fd-exhausted (many parallel tests, ASAN quarantine holding fds, etc.).
  2. net.createServer().listen(0, '127.0.0.1', resolve) is called inside stubServer().
  3. The kernel returns EMFILE from socket(2); Node emits 'error' on the server with an EMFILE error.
  4. No 'error' handler is registered on the server, and the resolve callback (a 'listening' listener) never fires.
  5. The await new Promise<void>(...) never settles; the test hangs until the file-level timeout kills it.
  6. CI reports a timeout with no indication that EMFILE was the actual cause, making the failure hard to diagnose.

With await once(server, 'listening') (or an explicit server.on('error', reject)), step 5 would instead reject with the EMFILE error and the test would fail immediately with a useful message.

Why this is a nit, not blocking

  • listen(0, '127.0.0.1') on an ephemeral loopback port essentially cannot hit EADDRINUSE (port 0 = OS-assigned) or address-unavailable; the only realistic failure is fd exhaustion, which is rare.
  • The identical resolve-only pattern already exists in pre-existing tests in this file (the "Auto-Reconnect In-Flight Commands" test and the "failed TLS handshake" test both do await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))), so the PR is copying one of two existing local conventions rather than introducing a new anti-pattern.
  • Impact is diagnosability (hang vs. clear failure), not correctness of what the tests assert.

Fix

Swap each await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve)) for:

server.listen(0, "127.0.0.1");
await once(server, "listening");

(once from events is already imported at the top of the file.) This matches helloServer() and satisfies the REVIEW.md rule.

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.

Connection to redis fails on prod 7.4 redis redislabs server (local redis works fine)

2 participants