Skip to content

Commit 69a52e2

Browse files
ccie18643claude
andcommitted
feat(mld): MCAST_JOIN_SOURCE_GROUP family for IPv6 SSM (R4 P2, part 2)
Adds the protocol-independent IPv6 multicast source-filter socket options — the second half of R4 P2, completing the socket surface for IPv6 source-specific multicast. New 'McastOption' IntEnum + bare aliases (enums.md §2.2 stdlib-parity form, re-exported from 'pytcp.socket'): MCAST_JOIN_SOURCE_GROUP (46) / MCAST_LEAVE_SOURCE_GROUP (47) / MCAST_BLOCK_SOURCE (43) / MCAST_UNBLOCK_SOURCE (44), wired at IPPROTO_IPV6 level. There is NO 'IPV6_ADD_SOURCE_MEMBERSHIP' — Linux drives IPv6 SSM through the protocol-independent 'MCAST_*' family with a 'group_source_req' struct, not the IPv4-style 'ip_mreq_source'. A module-level '_parse_group_source_req' decodes the 264-byte glibc 'struct group_source_req' (uint32 gsr_interface at 0; sockaddr_storage gsr_group at 8 → group addr bytes[16:32]; gsr_source at 136 → source addr bytes[144:160]), validating the total length and that each embedded sockaddr's ss_family is AF_INET6 (the OS-level value 10, the same 'error_queue' packs — distinct from PyTCP's internal 'AddressFamily.INET6' enum). '_apply_ip6_source_op' computes the new per-socket filter with the Linux 'ip6_mc_source' errno surface (EINVAL on an INCLUDE/EXCLUDE filter-mode conflict or BLOCK/UNBLOCK without a prior any-source join, EADDRNOTAVAIL on dropping/unblocking a source not held) — the IPv6 analogue of the v4 '_apply_source_op'. '_ipproto_ipv6_source_membership' performs the get/compute/store as one atomic RMW under '_lock__ip6_source_filters' and pushes the result to the interface merge via 'stack.membership6'. Tests-first: 'test__socket__ipv6_source_membership.py' drives all four options end-to-end through the socket (INCLUDE join materializes INCLUDE{src}, source accumulation, leave-last-source leaves the group, BLOCK/UNBLOCK on an any-source membership, the mode-conflict + stale-source errno paths, short / wrong-family struct rejection, and close-time release). The MLDv2 source records on the wire (P4) and the RX source-delivery gate (P5) remain before the RFC 3810 §4.2/§5.2 adherence flip. Lint clean, 13527 passing, 0 skipped. Reference: RFC 3810 §4.1 (per-socket filter mode + source list). Reference: RFC 3678 §4 (MCAST_JOIN_SOURCE_GROUP / group_source_req). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 88315c7 commit 69a52e2

4 files changed

Lines changed: 547 additions & 13 deletions

File tree

docs/refactor/host_refinements_backlog.md

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,21 @@ until the user says "push".
9090
branch is a `# P4:` placeholder). Tests-first:
9191
`test__icmp6__mld__source_filter_model.py` (7 wire-driven model tests) +
9292
harness / thread-safety / ND-seed updates. RFC 3810 §4.2/§5.2 adherence
93-
flip waits for P4/P5. See the R4 section for the remaining P2/P4/P5 map.
93+
flip waits for P4/P5. See the R4 section for the remaining P4/P5 map.
94+
- [x] **IPv6 SSM socket surface (R4 P2)** — the socket-side source-filter
95+
API, in two commits. `stack/membership6.py` (`Membership6Api`, the MLDv2
96+
analogue of `MembershipApi`) registered as `stack.membership6`;
97+
`IPV6_JOIN_GROUP` / `IPV6_LEAVE_GROUP` migrated onto it with per-socket
98+
`_ip6_source_filters` refcounting (fixes the leave-removes-for-all bug)
99+
+ close-time release; and the protocol-independent
100+
`MCAST_JOIN_SOURCE_GROUP` / `MCAST_LEAVE_SOURCE_GROUP` /
101+
`MCAST_BLOCK_SOURCE` / `MCAST_UNBLOCK_SOURCE` family (`McastOption` enum +
102+
bare aliases, `group_source_req` parser, `_apply_ip6_source_op`). There
103+
is NO `IPV6_ADD_SOURCE_MEMBERSHIP` — Linux uses the `MCAST_*` family for
104+
IPv6 SSM. Tests: `test__socket__ipv6_source_membership.py` (11) +
105+
`test__icmp6__mld__membership6_api.py` (8) + the join_group refcount /
106+
close tests. P4 (MLDv2 source records on the wire) + P5 (RX source-
107+
delivery gate) remain before the §4.2/§5.2 adherence flip.
94108

95109
**The canonical SO_RCVBUF guard pattern** (mirror for any new datagram socket):
96110

@@ -187,10 +201,28 @@ self._signal_readable()
187201
v4/v6 multicast are already separate paths in the handler and the
188202
MLD/IGMP TX). P3's `_ip6_multicast_filters` dict + merge live on the
189203
handler next to the v4 ones.
190-
- P2: new opts `IPV6_ADD_SOURCE_MEMBERSHIP` / `IPV6_DROP_SOURCE_MEMBERSHIP`
191-
(and/or the protocol-independent `MCAST_JOIN_SOURCE_GROUP` family) as
192-
`IpV6Option` enum members + bare aliases (see `.claude/rules/enums.md`
193-
§2.2). Wire the setsockopt cases.
204+
- **P2 — SHIPPED:** the socket-side source-filter surface + IPv6
205+
membership API, in two commits. (1) `stack/membership6.py`
206+
(`Membership6Api`, the MLDv2 analogue of `MembershipApi`: join / leave
207+
/ set_socket_filter / clear_socket_filter / list_memberships →
208+
`mc6_*`; ff02::1 leave-refusal; no group-count cap — Linux has no IPv6
209+
`igmp_max_memberships`), registered as `stack.membership6`. The
210+
`IPV6_JOIN_GROUP` / `IPV6_LEAVE_GROUP` path migrated onto it with
211+
per-socket `_ip6_source_filters` refcounting (EXCLUDE{} any-source),
212+
fixing the documented leave-removes-for-all bug; close/GC releases
213+
held memberships (`_release_ip6_memberships`). (2) the
214+
protocol-independent `MCAST_JOIN_SOURCE_GROUP` /
215+
`MCAST_LEAVE_SOURCE_GROUP` / `MCAST_BLOCK_SOURCE` /
216+
`MCAST_UNBLOCK_SOURCE` family as a `McastOption` IntEnum + bare aliases
217+
(enums.md §2.2; re-exported from `pytcp.socket`), with a
218+
`group_source_req` parser (`_parse_group_source_req`, ss_family ==
219+
AF_INET6 = 10) and `_apply_ip6_source_op` (the INCLUDE/EXCLUDE
220+
mode-conflict + EADDRNOTAVAIL errno surface mirroring the v4
221+
`_apply_source_op`). There is NO `IPV6_ADD_SOURCE_MEMBERSHIP` — Linux
222+
uses the protocol-independent `MCAST_*` family for IPv6 SSM.
223+
Tests-first: `test__socket__ipv6_source_membership.py` (11) +
224+
`test__icmp6__mld__membership6_api.py` (8) + the refcount / close tests
225+
in `test__socket__ipv6_join_group.py`.
194226
- **P3 — SHIPPED:** the handler `mc6_*` reception-state core, a
195227
faithful mirror of the fully-evolved v4 machinery. Added
196228
`_Ip6GroupMembership`, the `_ip6_multicast_filters` dict as the
@@ -225,14 +257,17 @@ self._signal_readable()
225257
#### R4 — resumable implementation map (derived 2026-07-19 by reading the v4 machinery)
226258

227259
The v4 source-filter machinery to mirror, with exact locations, so P2-P5
228-
can resume without re-deriving. **Status: P3 (the handler core below) is
229-
SHIPPED** — the `_Ip6GroupMembership` / `_ip6_multicast_filters` /
230-
`_ip6_multicast_refs` / `mc6_*` / `_ip6_multicast_filter_for` machinery all
231-
landed under `_lock__multicast`, and `assign/remove_ip6_multicast` now
232-
materialize the filter map. **Remaining: P2** (Membership API + Socket
233-
`MCAST_*` setsockopt + `group_source_req`), **P4** (`_send_mld_state_change`
234-
MLDv2 source records, replacing the `# P4:` placeholder in `_mc6_recompute`),
235-
**P5** (RX `Ip6MulticastFilter.allows` gate for UDP + RAW).
260+
can resume without re-deriving. **Status: P2 + P3 SHIPPED** — the handler
261+
core (`_Ip6GroupMembership` / `_ip6_multicast_filters` / `_ip6_multicast_refs`
262+
/ `mc6_*` / `_ip6_multicast_filter_for`, all under `_lock__multicast`) AND
263+
the socket surface (`stack.membership6`, refcounted `IPV6_JOIN_GROUP`, the
264+
`MCAST_*_SOURCE_*` family with `group_source_req`, per-socket
265+
`_ip6_source_filters`, close-time release) are done. **Remaining: P4**
266+
(`_send_mld_state_change` MLDv2 source records — ALLOW / BLOCK / CHANGE_TO_*
267+
— replacing the `# P4:` current-state-Report placeholder in `_mc6_recompute`
268+
and the join/leave edges), **P5** (RX `Ip6MulticastFilter.allows` source-
269+
delivery gate for UDP + RAW). After P4/P5 flip the RFC 3810 §4.2 / §5.2
270+
adherence rows.
236271

237272
- **Handler (`runtime/packet_handler/__init__.py`) — the deep, no-GIL-locked
238273
core, duplicated across the L2 and L3 handler classes — SHIPPED (P3):**

packages/pytcp/pytcp/runtime/socket/__init__.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,29 @@ class IpV6Option(IntEnum):
256256
IPV6_TCLASS = IpV6Option.IPV6_TCLASS
257257

258258

259+
class McastOption(IntEnum):
260+
"""
261+
Protocol-independent multicast source-filter setsockopt 'optname'
262+
values (RFC 3678 / RFC 3810 §4.1), usable at both IPPROTO_IP and
263+
IPPROTO_IPV6 level with a 'group_source_req' struct. Linux numbers
264+
from <linux/in.h>; matches Python stdlib 'socket.MCAST_*' on
265+
platforms that expose them. PyTCP wires these at IPPROTO_IPV6 for
266+
IPv6 source-specific multicast; IPv4 uses the older 'IP_*_SOURCE_*'
267+
'ip_mreq_source' options.
268+
"""
269+
270+
MCAST_BLOCK_SOURCE = 43 # group_source_req: block a source on an EXCLUDE-mode group
271+
MCAST_UNBLOCK_SOURCE = 44 # group_source_req: unblock a source on an EXCLUDE-mode group
272+
MCAST_JOIN_SOURCE_GROUP = 46 # group_source_req: join a group in INCLUDE mode for a source
273+
MCAST_LEAVE_SOURCE_GROUP = 47 # group_source_req: drop a source from an INCLUDE-mode group
274+
275+
276+
MCAST_BLOCK_SOURCE = McastOption.MCAST_BLOCK_SOURCE
277+
MCAST_UNBLOCK_SOURCE = McastOption.MCAST_UNBLOCK_SOURCE
278+
MCAST_JOIN_SOURCE_GROUP = McastOption.MCAST_JOIN_SOURCE_GROUP
279+
MCAST_LEAVE_SOURCE_GROUP = McastOption.MCAST_LEAVE_SOURCE_GROUP
280+
281+
259282
class UdpOption(IntEnum):
260283
"""
261284
SOL_UDP-level setsockopt 'optname' values (Linux numbers
@@ -366,6 +389,42 @@ def _resolve_membership_ifindex(interface_address: Ip4Address, /) -> int | None:
366389
return None
367390

368391

392+
# 'struct group_source_req' (glibc, native layout): uint32 gsr_interface
393+
# at offset 0; sockaddr_storage gsr_group at offset 8 (sockaddr_in6 ->
394+
# ss_family at +0, sin6_addr at +8, so the group address is bytes[16:32]);
395+
# sockaddr_storage gsr_source at offset 136 (source address bytes[144:160]).
396+
GROUP_SOURCE_REQ__LEN: int = 264
397+
# Linux OS-level AF_INET6 as it appears in a sockaddr wire struct — the
398+
# same value 'error_queue._AF_INET6' packs, distinct from PyTCP's
399+
# internal 'AddressFamily.INET6' socket-family enum value.
400+
_AF_INET6__SOCKADDR: int = 10
401+
402+
403+
def _parse_group_source_req(gsr: bytes, /) -> tuple[int, Ip6Address, Ip6Address]:
404+
"""
405+
Parse a Linux 'struct group_source_req' (RFC 3678) into
406+
(ifindex, group, source). Validates the total length and that each
407+
embedded sockaddr's ss_family is AF_INET6. Raises 'OSError(EINVAL)'
408+
on a short buffer or a non-AF_INET6 sockaddr family.
409+
"""
410+
411+
if len(gsr) < GROUP_SOURCE_REQ__LEN:
412+
raise OSError(
413+
errno.EINVAL,
414+
f"group_source_req must be at least {GROUP_SOURCE_REQ__LEN} bytes, got {len(gsr)}",
415+
)
416+
417+
ifindex = int.from_bytes(gsr[0:4], sys.byteorder)
418+
group_family = int.from_bytes(gsr[8:10], sys.byteorder)
419+
source_family = int.from_bytes(gsr[136:138], sys.byteorder)
420+
if group_family != _AF_INET6__SOCKADDR or source_family != _AF_INET6__SOCKADDR:
421+
raise OSError(errno.EINVAL, "group_source_req sockaddr family must be AF_INET6")
422+
423+
group = Ip6Address(gsr[16:32])
424+
source = Ip6Address(gsr[144:160])
425+
return ifindex, group, source
426+
427+
369428
class ShutdownHow(IntEnum):
370429
"""
371430
BSD-socket 'shutdown(how)' values per POSIX (Linux-
@@ -1044,6 +1103,20 @@ def _ipproto_ipv6_setsockopt(self, optname: int, value: int | bytes, /) -> bool:
10441103
)
10451104
self._ipproto_ipv6_membership(optname, bytes(value))
10461105
return True
1106+
case _ if optname in (
1107+
MCAST_JOIN_SOURCE_GROUP,
1108+
MCAST_LEAVE_SOURCE_GROUP,
1109+
MCAST_BLOCK_SOURCE,
1110+
MCAST_UNBLOCK_SOURCE,
1111+
):
1112+
if not isinstance(value, (bytes, bytearray, memoryview)):
1113+
raise OSError(
1114+
errno.EINVAL,
1115+
f"MCAST source-membership value must be a group_source_req bytes object, "
1116+
f"got {type(value).__name__}",
1117+
)
1118+
self._ipproto_ipv6_source_membership(optname, bytes(value))
1119+
return True
10471120
return False
10481121

10491122
def _resolve_ipv6_membership_interface(self, mreq_ifindex: int, /) -> int:
@@ -1114,6 +1187,100 @@ def _ipproto_ipv6_membership(self, optname: int, mreq: bytes, /) -> None:
11141187
except ValueError as error:
11151188
raise OSError(errno.EINVAL, str(error)) from error
11161189

1190+
def _ipproto_ipv6_source_membership(self, optname: int, gsr: bytes, /) -> None:
1191+
"""
1192+
Apply the IPv6 protocol-independent source-filter socket options
1193+
(RFC 3678 / RFC 3810 §4.1) by parsing the 'group_source_req'
1194+
structure and mutating this socket's per-(ifindex, group) filter,
1195+
then pushing the result to the interface merge via the IPv6
1196+
membership API:
1197+
1198+
- MCAST_JOIN_SOURCE_GROUP: INCLUDE-mode; add the source.
1199+
- MCAST_LEAVE_SOURCE_GROUP: remove the source from the INCLUDE
1200+
list (leave the group when it empties).
1201+
- MCAST_BLOCK_SOURCE: EXCLUDE-mode; add the blocked source.
1202+
- MCAST_UNBLOCK_SOURCE: remove a blocked source from the EXCLUDE
1203+
list.
1204+
1205+
A mode conflict (an INCLUDE op on an EXCLUDE membership or vice
1206+
versa, and BLOCK / UNBLOCK with no prior any-source join) raises
1207+
EINVAL, mirroring Linux 'ip6_mc_source'. The IPv6 analogue of
1208+
'_ipproto_ip_source_membership'.
1209+
"""
1210+
1211+
import pytcp.stack as _stack
1212+
1213+
mreq_ifindex, group, source = _parse_group_source_req(gsr)
1214+
if not group.is_multicast:
1215+
raise OSError(errno.EINVAL, f"{group} is not a multicast group address")
1216+
ifindex = self._resolve_ipv6_membership_interface(mreq_ifindex)
1217+
1218+
key = (ifindex, group)
1219+
api = _stack.membership6.interface(ifindex)
1220+
try:
1221+
with self._lock__ip6_source_filters:
1222+
# Compute the new socket filter (or None to leave the
1223+
# group) inside the lock so the get/compute/store is one
1224+
# atomic RMW and a mode-conflict OSError aborts cleanly.
1225+
new_filter = self._apply_ip6_source_op(optname, self._ip6_source_filters.get(key), source)
1226+
if new_filter is None:
1227+
api.clear_socket_filter(group=group, token=id(self))
1228+
self._ip6_source_filters.pop(key, None)
1229+
else:
1230+
api.set_socket_filter(group=group, token=id(self), source_filter=new_filter)
1231+
self._ip6_source_filters[key] = new_filter
1232+
except ValueError as error:
1233+
raise OSError(errno.EINVAL, str(error)) from error
1234+
1235+
@staticmethod
1236+
def _apply_ip6_source_op(
1237+
optname: int,
1238+
current: Ip6MulticastFilter | None,
1239+
source: Ip6Address,
1240+
/,
1241+
) -> Ip6MulticastFilter | None:
1242+
"""
1243+
Compute the new per-socket IPv6 source filter for a source-filter
1244+
socket option (RFC 3810 §4.1 / RFC 3678), or 'None' when the
1245+
operation leaves the group. Raises 'OSError' with the Linux
1246+
'ip6_mc_source' errno for an invalid transition: EINVAL on a
1247+
filter-mode conflict, EADDRNOTAVAIL on dropping / unblocking a
1248+
source the socket does not hold. Adding a source already present
1249+
is idempotent. The IPv6 analogue of '_apply_source_op'.
1250+
"""
1251+
1252+
include = Ip6MulticastFilterMode.INCLUDE
1253+
exclude = Ip6MulticastFilterMode.EXCLUDE
1254+
1255+
match optname:
1256+
case _ if optname == MCAST_JOIN_SOURCE_GROUP:
1257+
if current is None:
1258+
return Ip6MulticastFilter(include, frozenset({source}))
1259+
if current.mode is exclude:
1260+
raise OSError(errno.EINVAL, "MCAST_JOIN_SOURCE_GROUP on an EXCLUDE-mode group")
1261+
return Ip6MulticastFilter(include, current.sources | {source})
1262+
case _ if optname == MCAST_LEAVE_SOURCE_GROUP:
1263+
if current is None:
1264+
raise OSError(errno.EADDRNOTAVAIL, "Socket is not a member of the group")
1265+
if current.mode is exclude:
1266+
raise OSError(errno.EINVAL, "MCAST_LEAVE_SOURCE_GROUP on an EXCLUDE-mode group")
1267+
if source not in current.sources:
1268+
raise OSError(errno.EADDRNOTAVAIL, f"Source {source} is not in the include list")
1269+
remaining = current.sources - {source}
1270+
return Ip6MulticastFilter(include, remaining) if remaining else None
1271+
case _ if optname == MCAST_BLOCK_SOURCE:
1272+
if current is None or current.mode is include:
1273+
raise OSError(errno.EINVAL, "MCAST_BLOCK_SOURCE requires an EXCLUDE-mode (any-source) membership")
1274+
return Ip6MulticastFilter(exclude, current.sources | {source})
1275+
case _ if optname == MCAST_UNBLOCK_SOURCE:
1276+
if current is None or current.mode is include:
1277+
raise OSError(errno.EINVAL, "MCAST_UNBLOCK_SOURCE requires an EXCLUDE-mode (any-source) membership")
1278+
if source not in current.sources:
1279+
raise OSError(errno.EADDRNOTAVAIL, f"Source {source} is not blocked")
1280+
return Ip6MulticastFilter(exclude, current.sources - {source})
1281+
1282+
raise OSError(errno.EINVAL, f"Unsupported source-membership option {optname}")
1283+
11171284
def _ipproto_ipv6_getsockopt(self, optname: int, /) -> int | None:
11181285
"""
11191286
Get an IPPROTO_IPV6-level option's stored value, or 'None' if

packages/pytcp/pytcp/socket/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@
9191
IPV6_TCLASS,
9292
IPV6_UNICAST_HOPS,
9393
IPV6_V6ONLY,
94+
MCAST_BLOCK_SOURCE,
95+
MCAST_JOIN_SOURCE_GROUP,
96+
MCAST_LEAVE_SOURCE_GROUP,
97+
MCAST_UNBLOCK_SOURCE,
9498
MSG_ERRQUEUE,
9599
MSG_OOB,
96100
PACKET_BROADCAST,
@@ -208,6 +212,10 @@
208212
"IP_TOS",
209213
"IP_TTL",
210214
"IP_UNBLOCK_SOURCE",
215+
"MCAST_BLOCK_SOURCE",
216+
"MCAST_JOIN_SOURCE_GROUP",
217+
"MCAST_LEAVE_SOURCE_GROUP",
218+
"MCAST_UNBLOCK_SOURCE",
211219
"MSG_ERRQUEUE",
212220
"MSG_OOB",
213221
"PACKET_BROADCAST",

0 commit comments

Comments
 (0)