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.
Describe the bug
build_scope_connectioninglide-core/src/scope.rsbuilds aredis:///rediss://URL string from the target address and passes it toredis::Client::open. For an IPv6 host, the resulting URL is invalid because the host is not bracketed, soClient::openrejects it withScopeCreateError::InvalidUrl. Themax_totalreservation 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).addrcomes fromClient::address_for_slot, which returns the topology's{host}:{port}string (ConnectionAddrDisplay,redis-rs/redis/src/connection.rs:179). Valkey'sCLUSTER SLOTSreturns IPv6 IPs bare (::1), so the stored string is::1:7801and the URL isredis://::1:7801.ScopeTarget::Standalone(scope.rs:468):format!("{}://{}:{}", scheme, addr.host, port)from the seedNodeAddress. A user-configuredhost = "::1"— which the main client accepts, sinceclient/mod.rs:312builds a structuredConnectionAddr— produces the same invalidredis://::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:portwithrsplit_once(':')+ bracket-trim incluster.rs:1002 get_connection_info, and the main client buildsConnectionAddr::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::InvalidUrllogged at the single failure exit, the caller sees only the eventual pool-exhaustion timeout.Steps to reproduce
Then, with any wrapper, create a
GlideClusterClientonNodeAddress("::1", 7801)(the main client connects fine) and callscoped_connection(routing_key=...). The scope never seats; with debug logging enabled the failure exit logsinvalid target url: ....The URL grammar rejection can be confirmed independently:
Proposed fix
Do not build a URL. Construct
redis::ConnectionInfodirectly, mirroringglide-core/src/client/mod.rs:312:Standalone, use the seedNodeAddress'shost/portand the request'stls_modeto buildConnectionAddr::Tcp(host, port)orConnectionAddr::TcpTls { host, port, insecure, tls_params }.ClusterPrimary(addr), split withrsplit_once(':')and trim[/]from the host (the same rulecluster.rs:1002uses, so the two paths cannot drift), then build the same structuredConnectionAddr.redis::Client::open(connection_info)—Client::openacceptsIntoConnectionInfo, so the URL layer is not needed.This removes
ScopeCreateError::InvalidUrlas a reachable failure mode and also drops therediss://+#insecurefragment 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.rsto drift from.Tests
scope.rsthatbuild_scope_connection/ theConnectionInfoconstruction yieldshost = "::1"for bothClusterPrimary("::1:7801")and aStandaloneseed withhost = "::1", with and without TLS.glide-core/teststhat opens a standalone scope against a server bound to::1.pool.rs:1547already 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 theClusterPrimaryarm's URL formatting was added; theStandalonearm'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.