Skip to content

[core] Scoped connections fail on IPv6 targets: build_scope_connection constructs an invalid URL #7124

Description

@nderraugh

Describe the bug

build_scope_connection in glide-core/src/scope.rs builds a redis:// / rediss:// URL string from the target address and passes it to redis::Client::open. For an IPv6 host, the resulting URL is invalid because the host is not bracketed, so Client::open rejects it with ScopeCreateError::InvalidUrl. The max_total reservation is released, no connection is seated, and the borrower eventually surfaces a "pool exhausted" timeout rather than an addressing error.

Both target arms are affected:

  • ScopeTarget::ClusterPrimary(addr) (scope.rs:469): format!("{}://{}", scheme, addr). addr comes from Client::address_for_slot, which returns the topology's {host}:{port} string (ConnectionAddr Display, redis-rs/redis/src/connection.rs:179). Valkey's CLUSTER SLOTS returns IPv6 IPs bare (::1), so the stored string is ::1:7801 and the URL is redis://::1:7801.
  • ScopeTarget::Standalone (scope.rs:468): format!("{}://{}:{}", scheme, addr.host, port) from the seed NodeAddress. A user-configured host = "::1" — which the main client accepts, since client/mod.rs:312 builds a structured ConnectionAddr — produces the same invalid redis://::1:6379.

Every other connection path in glide-core is IPv6-safe because it never round-trips through a URL: the cluster path re-parses host:port with rsplit_once(':') + bracket-trim in cluster.rs:1002 get_connection_info, and the main client builds ConnectionAddr::Tcp(host, port) directly. Scoped connections are the only path that serialises to a URL and re-parses it.

Expected behavior

scoped_connection() works against an IPv6 cluster primary, and against a standalone server configured with a bare IPv6 host, exactly as the non-scoped client does.

Current behavior

Every scope acquisition against an IPv6 target fails at connection creation. Because the failure is a ScopeCreateError::InvalidUrl logged at the single failure exit, the caller sees only the eventual pool-exhaustion timeout.

Steps to reproduce

valkey-server --port 7801 --bind ::1 --cluster-enabled yes --cluster-announce-ip ::1 \
  --cluster-config-file n1.conf --save "" --appendonly no
valkey-cli -h ::1 -p 7801 cluster addslotsrange 0 16383
valkey-cli -h ::1 -p 7801 cluster slots     # returns "::1" and "7801" as separate fields

Then, with any wrapper, create a GlideClusterClient on NodeAddress("::1", 7801) (the main client connects fine) and call scoped_connection(routing_key=...). The scope never seats; with debug logging enabled the failure exit logs invalid target url: ....

The URL grammar rejection can be confirmed independently:

redis://::1:7801           -> invalid ("Port could not be cast to integer value as ':1:7801'")
redis://[::1]:7801         -> host="::1" port=7801

Proposed fix

Do not build a URL. Construct redis::ConnectionInfo directly, mirroring glide-core/src/client/mod.rs:312:

  • For Standalone, use the seed NodeAddress's host/port and the request's tls_mode to build ConnectionAddr::Tcp(host, port) or ConnectionAddr::TcpTls { host, port, insecure, tls_params }.
  • For ClusterPrimary(addr), split with rsplit_once(':') and trim [/] from the host (the same rule cluster.rs:1002 uses, so the two paths cannot drift), then build the same structured ConnectionAddr.
  • Then redis::Client::open(connection_info)Client::open accepts IntoConnectionInfo, so the URL layer is not needed.

This removes ScopeCreateError::InvalidUrl as a reachable failure mode and also drops the rediss:// + #insecure fragment convention that the URL path relies on for TLS.

Bracketing the string before formatting (as suggested inline on #7114) also works, but leaves a second address-serialisation rule in the codebase for cluster.rs to drift from.

Tests

  • Unit test in scope.rs that build_scope_connection / the ConnectionInfo construction yields host = "::1" for both ClusterPrimary("::1:7801") and a Standalone seed with host = "::1", with and without TLS.
  • Integration test in glide-core/tests that opens a standalone scope against a server bound to ::1. pool.rs:1547 already has an IPv4+IPv6 free-port picker that can be reused for the fixture.

Additional context

Introduced in the scope-target refactor in #7054 (ScopeTarget::ClusterPrimary(Arc<String>)), which is where the ClusterPrimary arm's URL formatting was added; the Standalone arm's URL construction predates it. Surfaced by CodeRabbit's inline review on #7114, which named only the cluster arm. Related to the pool/scope tracking under #6975, #7005, #7112.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Core changes 🪐Used to label a PR as PR with significant changes that should trigger a full matrix tests.bug 🐞Something isn't workingpool/scopeIssues related to connection pooling and scoped connections

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions