Repository navigation
fix: macOS clients resolving localhost to ::1 first get connection refused - #843
Conversation
sgamelin
left a comment
There was a problem hiding this comment.
Thanks for this, and for the detailed write-up. Binding [::] with IPV6_V6ONLY cleared is the right fix, and it matches what Kafka's own default listener ends up with: for a blank host, SocketServer.openServerSocket binds new InetSocketAddress(port), which the JDK opens as a dual-stack IPv6 socket. The socket2 setup keeps close-on-exec and SO_REUSEADDR as TcpListener::bind did. It also merges cleanly with #850.
A few points inline, none blocking:
- The new default can't start on a host without IPv6, where Kafka's default still can, and the bind error doesn't name the address that failed.
- The dual-stack test still passes with
set_only_v6(false)deleted. I ran that mutation on macOS (net.inet6.ip6.v6only=0, the same default as the Ubuntu runners). - The backlog comment doesn't match what mio does.
- Comment accuracy around
DEFAULT_BROKER, and a few comments that describe other files.
One more outside the diff: CHANGELOG.md has an empty [Unreleased] section, and this PR changes two defaults users can see: --kafka-listener-url is now tcp://[::]:9092, and the default advertised listener and client broker URL are now 127.0.0.1. Could you add a ### Changed entry for each, including the LISTENER_URL=tcp://0.0.0.0:9092 workaround for hosts without IPv6?
8904ae0 to
23ea230
Compare
On macOS, a client whose resolver returns `::1` before `127.0.0.1` for `localhost` cannot always reach the broker: the listener bound `0.0.0.0` (IPv4 only), and the default advertised/broker URLs used the `localhost` hostname rather than a concrete address. - Bind the Kafka listener dual-stack via `socket2`, clearing `IPV6_V6ONLY` so `[::]` accepts both IPv4 and IPv6 clients regardless of host `bindv6only`/`IPV6_V6ONLY` tuning, and default `--kafka-listener-url` to `tcp://[::]:9092`. - Default the advertised listener and the `cat`/`proxy` broker URLs to the IPv4 loopback literal `127.0.0.1` instead of `localhost`, so they no longer depend on resolver ordering. - Leave `nisshi-proxy`'s own listener default (`0.0.0.0`) and `example.env`/`compose.yaml`'s `ADVERTISED_LISTENER="localhost:9092"` untouched: the proxy's bind path goes through `host_port`, which resolves and filters to IPv4 only (tracked in SOL-155345), and whether Docker Desktop's port-publishing on macOS makes the dockerized dev loop a non-issue either way was not investigated here. Follow-ups filed: SOL-155345 (`host_port`'s IPv4-only filter, affecting both the proxy listener and the client connect path) and SOL-155346 (README `--help` block staleness, dead `PROMETHEUS_LISTENER_URL` CI env var). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2qdgPhMyLGZN5dLR8CVHs Signed-off-by: Andrea Ross <168456375+solace-aross@users.noreply.github.com>
…095) The dual-stack test added in 7dd2026 only exercises configure_listener's IPv6 path. If the `if addr.is_ipv6()` gate around `set_only_v6(false)` were removed, that test would still pass, and the regression would only surface via the less-frequently-run Tier B smoke job, since compose.yaml's explicit `--kafka-listener-url tcp://0.0.0.0:9092/` override exercises exactly this IPv4-literal path and nothing in Tier A CI does. Add a unit test that binds `127.0.0.1:0`, confirms configure_listener succeeds, and connects to the resulting socket over IPv4. Verified by temporarily removing the gate: the new test fails with `Os { code: 22, kind: InvalidInput }`, matching the real macOS failure mode for calling set_only_v6 on an IPv4 socket. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2qdgPhMyLGZN5dLR8CVHs Signed-off-by: Andrea Ross <168456375+solace-aross@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Andrea Ross <168456375+solace-aross@users.noreply.github.com>
…r tests When the default `[::]` listener cannot create its socket because the host does not support IPv6 (EAFNOSUPPORT), bind `0.0.0.0` on the same port and warn. An explicit IPv6 address still fails. A bind failure now logs the address it tried, and an EAFNOSUPPORT failure names --kafka-listener-url / LISTENER_URL. The dual-stack test now starts from a socket set v6-only, so it fails on any host if IPV6_V6ONLY stops being cleared. Comments are corrected, and the CHANGELOG records the new listener and advertised-listener defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011P97dHLhTRdJ35fMJFpYPg Signed-off-by: Andrea Ross <168456375+solace-aross@users.noreply.github.com>
23ea230 to
8494ff8
Compare
sgamelin
left a comment
There was a problem hiding this comment.
Thanks for working through all of these. I re-reviewed 8494ff8. git range-diff shows it's the 23ea230 series rebased onto main with no other changes, and the one new commit since my last review is the IPv4 fallback.
Earlier points:
- Host without IPv6: addressed.
ipv4_fallbackfalls back only for[::]withEAFNOSUPPORT, which is whatsocket(AF_INET6)returns on a kernel booted withipv6.disable=1or built without IPv6, and an explicit IPv6 address still fails. A container with thedisable_ipv6sysctl still binds[::]and doesn't need it. Bind failures now log%addr. - Dual-stack test: addressed. Starting from a v6-only socket makes the
only_v6()assert fail on any host ifset_only_v6(false)goes. - Backlog,
DEFAULT_BROKERand remote comments: addressed. - CHANGELOG: addressed.
Two small, optional points. One is inline. The other is in CHANGELOG.md: on a dual-stack host the default now also listens on the host's IPv6 addresses, where 0.0.0.0 didn't. Kafka's blank-host listener does the same, but an operator whose firewall rules for 9092 cover only IPv4 gets no other signal. Could the first entry say so? For example: "...which accepts both IPv6 and IPv4 clients, on every IPv6 and IPv4 address of the host."
What does this change do, and why?
On macOS, a client whose resolver returns
::1before127.0.0.1forlocalhost(kcat, librdkafka) gets "connection refused" against a freshly started broker. The broker only listened on IPv4 (tcp://0.0.0.0:9092), and the advertised listener defaulted to thelocalhosthostname, so the follow-up connection a Metadata response sends a client back to depends on resolver ordering.Both macOS and Linux default a new IPv6 socket to dual-stack, but that default is a tunable OS setting (
net.inet6.ip6.v6only/bindv6only) a host can override, so relying on it is not safe.Fix:
socket2, explicitly clearingIPV6_V6ONLYso[::]accepts both IPv4 and IPv6 clients regardless of host tuning, and default--kafka-listener-urltotcp://[::]:9092.cat/proxybroker URLs to the IPv4 loopback literal127.0.0.1instead oflocalhost, removing the dependency on resolver order entirely.nisshi-proxy's own listener default (0.0.0.0) untouched, and leaveexample.env/compose.yaml'sADVERTISED_LISTENER="localhost:9092"untouched (see Open question below).This partially reverts commit
1471be6(Jun 2025), which changed the broker'sLISTENER_URLdefault fromtcp://[::]:9092back totcp://0.0.0.0:9092as a side effect of an otel/dashboards change, not as a deliberate networking decision. Reverting it for the broker specifically is safe: the broker's own bind code already had a[::]fallback for a bare host, it just never ran because the CLI default always supplied0.0.0.0explicitly. The proxy's default is left as0.0.0.0on purpose — its bind path goes throughhost_port, which resolves and filters to IPv4 only, tracked separately below.Follow-ups filed (not fixed here)
nisshi-proxyandnisshi-client'shost_port()resolves and binds/connects IPv4 only, affecting both the proxy listener and the client connect path.--helpblock is stale (shows the old listener default) and references a deadPROMETHEUS_LISTENER_URLenv var.Open question (left as-is, documented here for visibility)
example.env/compose.yaml'sADVERTISED_LISTENER="localhost:9092"was left untouched. Settling whether this matters in practice would need a container test (does Docker Desktop's port-publishing on macOS make the dockerized dev loop a non-issue either way?), and Docker wasn't available to test this in either this session or the review pass before it. Stays open, not blocking.Non-blocking: Docker image default worth a manual smoke-check
The shipped image's
CMD ["broker"](no explicit listener arg) now defaults to binding[::]:9092instead of0.0.0.0:9092. This should be fine on any Linux kernel with the ipv6 module loaded (Docker's own default even with IPv6 networking disabled at the daemon level), and matches what shipped before1471be6, but neither this session nor the review pass before it had Docker available to confirm with an actualdocker runof the built image. Worth one manual check before merge if convenient.How was this tested?
just fmt— clean.just clippy— clean (only an unrelated upstreamproc-macro-error2future-incompat notice).cargo nextest run -p nisshi-broker --all-features: 395 tests run, 395 passed, 31 skipped (0 failed), including the new IPv4-literal bind test.just test(full workspace): 1103 tests run, 1072 passed, 66 skipped, 31 failed — all 31 failures are pre-existingnisshi-schemaIceberg/lakehouse integration tests requiring thejust ciDocker Compose services (lakehouse catalog atlocalhost:8181), which aren't available in this environment; confirmed the failure isConnection refusedto that catalog, unrelated to this change.ipv4_literal_listener_accepts_v4_clientsinnisshi-broker/src/broker.rs), closing a coverage gap found via mutation testing: the existing dual-stack test only exercises the IPv6 bind path, so if theif addr.is_ipv6()gate aroundset_only_v6(false)were ever removed, that test would still pass. Verified by temporarily removing the gate: the new test fails withOs { code: 22, kind: InvalidInput }, the real macOS failure mode; restored the gate and confirmed green.Checklist
git commit -s)just fmt,just clippy, andjust testpass locally (see test notes above for the one environment-dependent exception)🤖 Generated with Claude Code
https://claude.ai/code/session_01D2qdgPhMyLGZN5dLR8CVHs