Skip to content

fix: macOS clients resolving localhost to ::1 first get connection refused - #843

Merged
solace-aross merged 4 commits into
mainfrom
fix/sol-155095-macos-ipv6-localhost
Oct 7, 2026
Merged

solace-aross merged 4 commits into
mainfrom
fix/sol-155095-macos-ipv6-localhost

Conversation

@solace-aross

@solace-aross solace-aross commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

What does this change do, and why?

On macOS, a client whose resolver returns ::1 before 127.0.0.1 for localhost (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 the localhost hostname, 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:

  • Bind the Kafka listener dual-stack via socket2, explicitly clearing IPV6_V6ONLY so [::] accepts both IPv4 and IPv6 clients regardless of host 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, removing the dependency on resolver order entirely.
  • Leave nisshi-proxy's own listener default (0.0.0.0) untouched, and leave example.env/compose.yaml's ADVERTISED_LISTENER="localhost:9092" untouched (see Open question below).

This partially reverts commit 1471be6 (Jun 2025), which changed the broker's LISTENER_URL default from tcp://[::]:9092 back to tcp://0.0.0.0:9092 as 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 supplied 0.0.0.0 explicitly. The proxy's default is left as 0.0.0.0 on purpose — its bind path goes through host_port, which resolves and filters to IPv4 only, tracked separately below.

Follow-ups filed (not fixed here)

  • Follow-up — nisshi-proxy and nisshi-client's host_port() resolves and binds/connects IPv4 only, affecting both the proxy listener and the client connect path.
  • Follow-up — README --help block is stale (shows the old listener default) and references a dead PROMETHEUS_LISTENER_URL env var.

Open question (left as-is, documented here for visibility)

example.env/compose.yaml's ADVERTISED_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 [::]:9092 instead of 0.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 before 1471be6, but neither this session nor the review pass before it had Docker available to confirm with an actual docker run of the built image. Worth one manual check before merge if convenient.

How was this tested?

  • just fmt — clean.
  • just clippy — clean (only an unrelated upstream proc-macro-error2 future-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-existing nisshi-schema Iceberg/lakehouse integration tests requiring the just ci Docker Compose services (lakehouse catalog at localhost:8181), which aren't available in this environment; confirmed the failure is Connection refused to that catalog, unrelated to this change.
  • Added a unit test for the IPv4-literal bind path (ipv4_literal_listener_accepts_v4_clients in nisshi-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 the if addr.is_ipv6() gate around set_only_v6(false) were ever removed, that test would still pass. Verified by temporarily removing the gate: the new test fails with Os { code: 22, kind: InvalidInput }, the real macOS failure mode; restored the gate and confirmed green.

Checklist

  • Commits are signed off (git commit -s)
  • just fmt, just clippy, and just test pass locally (see test notes above for the one environment-dependent exception)
  • Tests added or updated for the behavior changed
  • Docs updated if user-facing behavior changed (tracked separately in the README follow-up above)

🤖 Generated with Claude Code

https://claude.ai/code/session_01D2qdgPhMyLGZN5dLR8CVHs

@sgamelin sgamelin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread nisshi-broker/src/broker.rs Outdated
Comment thread nisshi-broker/src/broker.rs Outdated
Comment thread nisshi-broker/src/broker.rs
Comment thread nisshi-cli/src/cli.rs Outdated
Comment thread nisshi-cli/src/cli/broker.rs Outdated
@solace-aross
solace-aross force-pushed the fix/sol-155095-macos-ipv6-localhost branch 2 times, most recently from 8904ae0 to 23ea230 Compare October 7, 2026 12:30
@solace-aross
solace-aross requested a review from sgamelin October 7, 2026 12:31
solace-aross and others added 4 commits October 7, 2026 10:05
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>
@solace-aross
solace-aross force-pushed the fix/sol-155095-macos-ipv6-localhost branch from 23ea230 to 8494ff8 Compare October 7, 2026 14:05

@sgamelin sgamelin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_fallback falls back only for [::] with EAFNOSUPPORT, which is what socket(AF_INET6) returns on a kernel booted with ipv6.disable=1 or built without IPv6, and an explicit IPv6 address still fails. A container with the disable_ipv6 sysctl 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 if set_only_v6(false) goes.
  • Backlog, DEFAULT_BROKER and 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."

Comment thread nisshi-broker/src/broker.rs
@solace-aross
solace-aross added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit 3428323 Oct 7, 2026
24 checks passed
@solace-aross
solace-aross deleted the fix/sol-155095-macos-ipv6-localhost branch October 7, 2026 16:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants