Skip to content

Commit e3acb6a

Browse files
rrobinettclaude
andcommitted
fix(multi_stream): join multicast group on EVERY local IPv4 interface
Replaces the single INADDR_ANY join in `_create_socket()` with an explicit join on every UP IPv4 interface, via SIOCGIFADDR enumeration. With INADDR_ANY the kernel selects one interface based on the routing table (typically the default-route eth/ens0). That works for the common case where radiod is remote and packets arrive over the network — but on a co-located radiod (TTL=0) the packets emerge on `lo` and never get delivered to a socket joined only on ens0. The 3.14.0 per-(client, radiod) multicast destinations exposed this because the legacy "everyone shares one group" path happens to be joined on multiple interfaces by other listeners on the same host. Symptom that motivated this fix (B4-100, 2026-05-13): * wspr-recorder, after upgrading to ka9q-python 3.14.0 and passing client_id="wspr-recorder", saw zero RTP packets. * `ip maddr show` showed the per-client group joined only on ens18. * `tcpdump -i lo` confirmed radiod was sending the packets, but they never reached the recorder. * Joining on lo + ens18 (both interfaces) immediately restored sample flow; all 13 WSPR bands resumed cleanly (1,440,000 samples per cycle, 0 gaps). The same pattern matters for multi-homed stations: a single MultiStream consumer should receive radiod output regardless of which interface the producer's packets arrive on. Joining on every local IPv4 interface makes that transparent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cc8c136 commit e3acb6a

1 file changed

Lines changed: 88 additions & 5 deletions

File tree

ka9q/multi_stream.py

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
multi.stop()
4040
"""
4141

42+
import fcntl
4243
import logging
4344
import socket
4445
import struct
@@ -59,6 +60,75 @@
5960
logger = logging.getLogger(__name__)
6061

6162

63+
# Linux SIOCGIFADDR — fetch IPv4 of an interface by name. Used to enumerate
64+
# every UP IPv4 interface so the multicast group join can be made on each
65+
# of them. Without this, joining with INADDR_ANY lets the kernel pick a
66+
# single interface (typically the default-route one), which misses:
67+
#
68+
# * Loopback-only multicast emitted by a co-located radiod with TTL=0
69+
# (packets sit on `lo`; the kernel won't deliver them to a socket
70+
# joined on `ens0`).
71+
# * Multi-homed stations where one radiod streams on lo and another
72+
# on eth: a single MultiStream should consume both.
73+
#
74+
# Joining on EVERY local IPv4 interface lets one socket receive from any
75+
# radiod source on any path.
76+
_SIOCGIFADDR = 0x8915
77+
78+
79+
def _iter_local_ipv4_interfaces():
80+
"""Yield (ifname, ipv4_addr_str) for every local interface with an IPv4.
81+
82+
Order is `socket.if_nameindex()` order — typically 'lo' first, then
83+
ens0/eth0/wlan0/etc. Interfaces without an IPv4 (IPv6-only, or
84+
freshly-created with no addr) are skipped silently. Stays
85+
stdlib-only on Linux (no netifaces/psutil dependency).
86+
"""
87+
try:
88+
names = socket.if_nameindex()
89+
except OSError:
90+
return
91+
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
92+
try:
93+
for _idx, ifname in names:
94+
try:
95+
raw = fcntl.ioctl(
96+
probe.fileno(),
97+
_SIOCGIFADDR,
98+
struct.pack("256s", ifname.encode()[:15]),
99+
)
100+
addr = socket.inet_ntoa(raw[20:24])
101+
except OSError:
102+
continue
103+
yield ifname, addr
104+
finally:
105+
probe.close()
106+
107+
108+
def _join_multicast_all_interfaces(sock: socket.socket,
109+
multicast_address: str) -> List[str]:
110+
"""Join `multicast_address` on every local IPv4 interface.
111+
112+
Returns the list of interface names where the join succeeded. Empty
113+
list means no interface was usable (extremely rare — even a freshly-
114+
booted box has `lo`). Per-interface failures are logged at DEBUG
115+
and skipped (e.g., a virtual interface without an IPv4).
116+
"""
117+
joined: List[str] = []
118+
group = socket.inet_aton(multicast_address)
119+
for ifname, ifaddr in _iter_local_ipv4_interfaces():
120+
mreq = struct.pack("=4s4s", group, socket.inet_aton(ifaddr))
121+
try:
122+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
123+
joined.append(ifname)
124+
except OSError as exc:
125+
logger.debug(
126+
"multicast join on %s (%s) failed: %s",
127+
ifname, ifaddr, exc,
128+
)
129+
return joined
130+
131+
62132
@dataclass
63133
class _ChannelSlot:
64134
"""Per-SSRC state within a MultiStream."""
@@ -322,12 +392,25 @@ def _create_socket(self) -> socket.socket:
322392

323393
sock.bind(("0.0.0.0", self._port))
324394

325-
mreq = struct.pack(
326-
"=4s4s",
327-
socket.inet_aton(self._multicast_address),
328-
socket.inet_aton("0.0.0.0"),
395+
# Join the multicast group on EVERY local IPv4 interface, not
396+
# via INADDR_ANY (which lets the kernel pick a single interface
397+
# — typically the default route — and silently misses radiod
398+
# outputs on other paths, e.g. TTL=0 loopback packets from a
399+
# co-located radiod).
400+
joined = _join_multicast_all_interfaces(
401+
sock, self._multicast_address,
329402
)
330-
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
403+
if not joined:
404+
logger.warning(
405+
"MultiStream: no interface accepted the multicast "
406+
"join for %s — recvfrom() will return nothing",
407+
self._multicast_address,
408+
)
409+
else:
410+
logger.debug(
411+
"MultiStream: joined %s on interfaces: %s",
412+
self._multicast_address, ", ".join(joined),
413+
)
331414
sock.settimeout(1.0)
332415
return sock
333416

0 commit comments

Comments
 (0)