fix: extract target normalisation and fix IPv6 parsing crashes#521
Merged
sleipnir merged 6 commits intoApr 29, 2026
Merged
Conversation
35635e0 to
9aa9e12
Compare
sleipnir
requested changes
Apr 28, 2026
Collaborator
sleipnir
left a comment
There was a problem hiding this comment.
Hi, thanks for the PR.
Could you reduce the number of comments in the code that don't translate into some high-level explanation? I've seen many comments that are self-explanatory within the code itself, below the comment.
`GRPC.Client.Connection` crashed with a `CaseClauseError` when any IPv6
target was passed (e.g. `"[::1]:50051"`, `"::1:50051"`, `"2001:db8::1:8080"`),
because the schemeless branch only handled single-colon strings. A second
bug in `split_host_port/1` silently produced a malformed host (`":1]"`)
for bracketed IPv6 addresses without an explicit port.
Both bugs are fixed by extracting the parsing logic to a dedicated
`GRPC.Client.Connection.Target` module:
- `normalize/2` now handles bracketed IPv6, bare IPv6, and all resolver
scheme prefixes explicitly.
- `split_host_port/1` uses a bracket-aware regex fallback instead of
splitting on the first colon when no port is present.
The function signatures are also simplified: `normalize/2` now accepts
`cred :: GRPC.Credential.t() | nil` directly instead of a full opts
keyword list, and returns `{norm_target, scheme, cred}` instead of
threading opts through. The `nimble_options` dependency is removed.
An exhaustive test suite is added for `Target`, covering all IPv6 input
shapes, scheme inference, credential propagation, and error cases. The
bracketed-IPv6-without-port bug in `split_host_port/1` was itself
discovered during the test review pass.
Made-with: Cursor
Address PR review feedback from sleipnir: strip narrating comments that restate what the code does and decorative section-divider banners that duplicate structure already provided by `describe` blocks and `defp` visibility. Made-with: Cursor
Collaborator
|
@arctarus We also have an error in the CI. |
0a7cc48 to
d1de4f1
Compare
The Target.normalize/2 refactor changed the return value from
{norm_target, norm_opts, scheme} to {norm_target, scheme, cred},
but the connect_opts assignment was not updated, causing a
compilation error.
Made-with: Cursor
Contributor
Author
|
@sleipnir feedback applied and errors fixed! Let me know if there is something else I can do. |
sleipnir
requested changes
Apr 28, 2026
sleipnir
requested changes
Apr 28, 2026
Rename GRPC.Client.Connection.Target to GRPC.Client.Connection.EndpointResolver for clarity. Update all references in Connection and tests, and rename the source and test files accordingly. Made-with: Cursor
normalize/2 previously hardcoded the "ipv4:" resolver prefix for all addresses, including IPv6. This routed IPv6 targets to the wrong resolver. Introduce resolver_prefix/1 to detect colons in the host and emit "ipv6:" when appropriate. Made-with: Cursor
sleipnir
approved these changes
Apr 28, 2026
Replace the string heuristic with :inet.parse_address/1 to detect the address family, matching the existing pattern in GRPC.Stub. Made-with: Cursor
91f474d to
138a94d
Compare
Collaborator
|
@arctarus thank you |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
GRPC.Client.Connectioncontained two private functions,normalize_target_and_opts/2andsplit_host_port/1, responsible for parsing and normalising the raw target string passed toGRPC.Stub.connect/2.Both functions had untested edge cases around IPv6 addresses that caused runtime crashes in production:
1.
normalize_target_and_opts/2—CaseClauseErroron IPv6 targetsThe function delegated schemeless targets to a
condblock that only handled two shapes: "a string with one colon" (host:port) and "a string with no colon" (unix socket). When an IPv6 address was passed — which always contains multiple colons — no branch matched and Elixir raised aCaseClauseError, crashing theConnectionprocess before a connection was even attempted.Affected inputs included any of:
2.
split_host_port/1— incorrect parsing of bracketed IPv6 without portWhen a bracketed IPv6 address was provided with no port (e.g.
"[::1]"), the regex for the[addr]:portpattern did not match. The fallback calledstrip_scheme/1, which splits on the first:character. Applied to"[::1]"this produced the host":1]"— a silently wrong value that would cause a connection failure at the adapter level with a confusing error.Why these bugs matter
Both failures happen before any network activity. The
Connectionprocess is aGenServer: aCaseClauseErrorduringinit/1means the process never starts, and the supervision tree restarts it immediately with the same arguments, causing a restart loop. The bracketed-IPv6-without-port bug silently constructs a malformed host and attempts to connect to it, producing a cryptic adapter error. Neither failure points back to IPv6 as the root cause, making both hard to diagnose in production.While most Kubernetes clusters today connect to gRPC services via DNS names rather than raw IP addresses, there are real and growing scenarios where a caller passes an IPv6 address directly to
GRPC.Stub.connect/2: local development and testing ([::1]:50051is the natural loopback on any IPv6-enabled machine), dual-stack clusters (stable since Kubernetes 1.21, increasingly enabled by cloud providers), telco and edge deployments (5G core network functions are specified over IPv6, where gRPC is a primary protocol), and bare-metal clusters going IPv6-first to avoid RFC 1918 exhaustion.A bug that causes a silent restart loop with no actionable error message warrants a fix even if it affects a minority of users today.
The fix
Extraction to
GRPC.Client.Connection.TargetThe normalisation logic was extracted from
Connectioninto a dedicated internal module,GRPC.Client.Connection.Target, with two public functions:normalize/2— takes a raw target string and an optional%GRPC.Credential{}, returns{norm_target, scheme, cred}.split_host_port/1— takes a normalised target string, returns{host, port}.This separation keeps
Connectionfocused on lifecycle concerns and makes the parsing logic independently testable.IPv6 handling in
normalize/2The schemeless branch now explicitly handles three shapes before falling through:
"[::1]:50051"matched by~r/^\[([^\]]+)\]:(\d+)$/, yielding"ipv4:::1:50051"."[::1]"matched by a fallback~r/\[([^\]]+)\]/regex, stripping the brackets and returning the default port."2001:db8::1:8080"handled by inspecting whether the last colon-separated segment is an integer, treating it as the port and joining the remaining segments as the address.Bracketed IPv6 fallback in
split_host_port/1The
_ ->fallback when the[addr]:portregex does not match now uses a second regex~r/\[([^\]]+)\]/to extract the address from the brackets, rather than callingstrip_scheme/1which blindly splits on the first:.Interface simplification
normalize/2previously accepted akeyword()opts list and returned the full opts back to the caller with:credinjected. The function only ever read one key from opts (opts[:cred]). The return type now reflects the module's actual concern:Connectionnow callsTarget.normalize(target, opts[:cred])and reassembles its own state directly, keepingoptsmanagement inConnectionwhere it belongs.The
nimble_optionsdependency that was previously added to validateopts[:cred]was removed, as it is no longer needed — the type is enforced by theis_struct(cred, GRPC.Credential)guard clause.Tests
A new test file,
grpc/test/grpc/client/connection/target_test.exs, provides exhaustive coverage ofTarget.normalize/2andTarget.split_host_port/1, including all IPv6 input shapes, scheme inference, credential propagation, and theArgumentErrorraised when a credential is passed alongside anhttp://target.The test suite was written first as failing tests to reproduce the original crashes, then made green by the implementation. The bracketed-IPv6-without-port bug in
split_host_port/1was itself discovered by the new tests during the review pass.