Skip to content

fix(macos): select the address slot by insi_vflag, not soi_family - #57

Merged
GyulyVGC merged 1 commit into
GyulyVGC:mapped-fixfrom
juspay:fix/darwin-v4-mapped-addresses
Jul 25, 2026
Merged

fix(macos): select the address slot by insi_vflag, not soi_family#57
GyulyVGC merged 1 commit into
GyulyVGC:mapped-fixfrom
juspay:fix/darwin-v4-mapped-addresses

Conversation

@srid

@srid srid commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #56.

On macOS, get_local_addr selects the address slot from soi_family alone. That does not answer which slot of the insi_laddr union holds the address — insi_vflag does. For an IPv4-mapped bind, macOS reports soi_family = AF_INET6 while keeping the address in the 4-byte i46a_addr4 slot; the 16-byte ina_6 slot then holds 12 zero pad bytes followed by the v4 address, which read as IPv6 is ::127.0.0.1 — the deprecated IPv4-compatible address, not the mapped one that was bound.

Evidence

Each row measured by binding the address, then reading the kernel directly — getsockname() plus the same proc_pidfdinfo(PROC_PIDFDSOCKETINFO) view the crate uses. macOS 27.0 (build 26A5388g), reproduced with three independent binders (Node, Python 3, a raw C bind()).

bind soi_family insi_vflag getsockname i46a_addr4 ina_6
::ffff:127.0.0.1 AF_INET6 0x01 — INI_IPV4 only …ffff7f000001 (mapped) 7f000001 0000000000000000000000007f000001
:: (dual-stack) AF_INET6 0x03both :: 00000000 ::
::1 AF_INET6 0x02 — INI_IPV6 only ::1 00000001 — not an address ::1
127.0.0.1 AF_INET 0x01 7f000001 7f000001

Why this ordering

The table rules out the two simpler patches:

  • INI_IPV6 is checked first, because a dual-stack socket sets both flags. Checking INI_IPV4 first would report a :: bind as 0.0.0.0 and lose its IPv6 reach.
  • The v4 slot is never read for a socket without INI_IPV4. For ::1 that slot holds 00000001, which would surface as 0.0.0.1.

soi_family remains the fallback when neither flag is set.

Reported representation

A mapped bind now reports ::ffff:127.0.0.1, which matches what the Linux implementation already returns for the same bind (it reads the mapped form literally from /proc/net/tcp6), so the two platforms agree. Returning a bare IpAddr::V4 would have been indistinguishable from an AF_INET socket; callers wanting that view can use Ipv6Addr::to_ipv4_mapped().

Tests

Two tests in tests/integration.rs:

  • test_tcp_ipv4_mapped_address_is_not_ipv4_compatible — checked against the unfixed code: it fails before this change and passes after.
  • test_tcp_dual_stack_wildcard_reports_ipv6_unspecified — passes either way by design. It exists to pin the flag ordering, which the first test cannot.

Full suite: macOS 15 passed / 1 failed, Linux 15 passed / 1 failed. Both failures are test_consistency, and neither is introduced here:

  • On macOS it fails identically with and without this change, with different pid pairs on consecutive runs (ssh 95174 vs 94936, then sshd-session 14448 vs 11527).
  • On Linux this PR changes nothing at all — the diff is confined to platform/macos — and it still fails there, with a different symptom: get_process_by_port returning Err("No listener found on port") for a port that get_all() had just reported.

Both look like #36, and the macOS run suggests a mechanism: on that host lsof showed several ports held by more than one process (*:5000, *:64673, *:7000), and get_process_by_port(port) == a_specific_listener.process is not well-defined when a port has multiple holders — while the Linux symptom is the same assertion losing a race against a socket closing. Not addressed in this PR.

Not tested on Windows — no machine available.

An IPv4-mapped bind was reported as a different address. `get_local_addr` branched on
`soi_family` alone, so any AF_INET6 socket had its 16-byte `insi_laddr.ina_6` slot
read — but macOS keeps a v4 address in the 4-byte `i46a_addr4` slot and leaves `ina_6`
holding 12 zero pad bytes followed by that address. Read as IPv6 those bytes are the
deprecated IPv4-COMPATIBLE address, not the IPv4-MAPPED one that was bound.

`soi_family` does not answer "which slot"; `insi_vflag` does, and the struct already
carried it.

## Kernel evidence (macOS 27.0, build 26A5388g)

Bind, then ask the kernel directly — `getsockname` plus the same `proc_pidfdinfo` view
the crate reads. Every row measured, three independent binders (node, python3, a raw C
`bind()`) agreeing:

| bind | soi_family | insi_vflag | getsockname | v4 slot | v6 slot |
|---|---|---|---|---|---|
| `::ffff:127.0.0.1` | AF_INET6 | `0x01` (v4 only) | `…ffff7f000001` MAPPED | `7f000001` | `0000…00007f000001` |
| `::` dual-stack | AF_INET6 | `0x03` (BOTH) | `::` | `00000000` | `::` |
| `::1` | AF_INET6 | `0x02` (v6 only) | `::1` | `00000001` ← garbage | `::1` |
| `127.0.0.1` | AF_INET | `0x01` | `7f000001` | `7f000001` | — |

Before: `::ffff:127.0.0.1` → `::127.0.0.1`. `lsof` and a libproc C reader both said
`127.0.0.1` for the same socket.

## Why the flag order is v6-first

`::1` is why this cannot simply prefer the v4 slot: its v4 slot holds `00000001`, which
would surface as `0.0.0.1`. And a DUAL-STACK `::` sets BOTH flags, so v6 must win there
or the wildcard would be reported as `0.0.0.0` and the socket's IPv6 reach lost. Hence
`INI_IPV6` first, `INI_IPV4` second, family as the last resort.

## Why the mapped form rather than a bare V4

A mapped bind now reports `::ffff:127.0.0.1`, matching what the Linux implementation
already returns for the same bind (it reads the mapped form literally out of
`/proc/net/tcp6`). A bare `IpAddr::V4` would be indistinguishable from an AF_INET
socket; callers wanting that view have `Ipv6Addr::to_ipv4_mapped()`.

## Tests

Two regression tests, and they were checked against the UNFIXED code rather than
assumed: `test_tcp_ipv4_mapped_address_is_not_ipv4_compatible` FAILS before this change
and passes after. `test_tcp_dual_stack_wildcard_reports_ipv6_unspecified` passes either
way by design — it exists to pin the flag ORDER, which the first test cannot.

Full suite on macOS: 15 passed, 1 failed — `test_consistency`, which fails IDENTICALLY
with and without this change (different pids each run: `ssh` 95174 vs 94936, then
`sshd-session` 14448 vs 11527). It looks like the flake tracked upstream as GyulyVGC#36, and the
cause is visible here: `lsof` shows several ports on this box held by MORE THAN ONE
process (`*:5000`, `*:64673`, `*:7000` — fork-inherited or SO_REUSEPORT listeners), so
asserting `get_process_by_port(port) == a_specific_listener.process` is unsound when a
port has multiple holders. Not addressed here — out of scope for an address-decoding
fix, and noted only because it is adjacent.
@GyulyVGC GyulyVGC added bug Something isn't working macOS labels Jul 25, 2026
@GyulyVGC GyulyVGC added this to the v0.6.1 milestone Jul 25, 2026
@GyulyVGC
GyulyVGC changed the base branch from main to mapped-fix July 25, 2026 20:12
@GyulyVGC
GyulyVGC merged commit 226273e into GyulyVGC:mapped-fix Jul 25, 2026
2 of 6 checks passed
@GyulyVGC

Copy link
Copy Markdown
Owner

I had to change the base because I wasn't allowed to make edits to your branch since it's owned by an org.
I'll get back once I've made some fixes and this is merged to main.

@GyulyVGC

Copy link
Copy Markdown
Owner

I merged.
I just had to disable your new test execution on Windows and BSDs since they're apparently not allowed to bind mapped addresses and were panicking on the unwrap.

@srid please let me know if you guys are planning for other fixes / enhancements or otherwise I'll soon publish version 0.6.1 so you can use the library with the fix right away

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

Labels

bug Something isn't working macOS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS: IPv4-mapped bind reported as IPv4-compatible address (::ffff:127.0.0.1 → ::127.0.0.1)

2 participants