Skip to content

Commit e0f5de6

Browse files
ccie18643claude
andcommitted
feat(mld): MLDv2 source-bearing state-change Reports (R4 P4a)
Emits the RFC 3810 §6.1 source-bearing MLDv2 state-change records on multicast filter transitions — the IPv6 analogue of the shipped IGMPv3 source-state-change track, and the point where the source filters set via the P2 MCAST_* socket options actually reach the wire. New in the ICMPv6 TX handler: - '_mld_state_change_records(group, old, new)' — the §6.1 difference table (the "non-listener" state is INCLUDE{}): a filter-mode change yields one CHANGE_TO_INCLUDE / CHANGE_TO_EXCLUDE record carrying the new source list; a within-mode source change yields ALLOW_NEW_SOURCES and/or BLOCK_OLD_SOURCES records (empty ones omitted). The IPv6 analogue of the IGMPv3 '_state_change_records'. - '_send_mld_state_change(group, old, new)' — emits the aggregated MLDv2 Report (type 143) to ff02::16, degrading to the coarse MLDv1 Report / Done keyed off the reception edge while in MLDv1 Host Compatibility Mode. The all-nodes group ff02::1 is never reported. - '_emit_mld2_report(records)' — the shared assemble+send helper (a report with no records is not emitted). The three edges of the multicast reception machinery now route through it: the 'assign_ip6_multicast' join edge (old=INCLUDE{} nonmember, new=merged filter), the 'remove_ip6_multicast' leave edge (old=filter, new=INCLUDE{}), and the '_mc6_recompute' mid-membership delta (replacing the '# P4:' placeholder). A source-specific join therefore now announces ALLOW_NEW_SOURCES{src} / a mode flip announces CHANGE_TO_EXCLUDE with the real source list, instead of the coarse all-groups current-state report the join edge emitted before. The now-dead coarse '_send_icmp6_mld_leave' (single-group) was removed — 'remove_ip6_multicast' supersedes it, and '_send_mld_state_change' carries the MLDv1-Done fallback it held. This is single immediate emission; the RFC 3810 §6.1 robustness retransmit train (Robustness-Variable copies at random intervals) is P4b, mirroring the v4 '_igmp_state_change__pending' / arm / fire / cancel machinery. The §4.2 / §5.2 adherence flip waits for P4b + the P5 RX gate. Tests-first: 'test__icmp6__mld__source_state_change.py' drives the MCAST_* options through the socket and asserts the emitted MLDv2 records carry the right type + source set (fresh join → ALLOW_NEW_SOURCES, second source → ALLOW for the delta only, drop → BLOCK_OLD_SOURCES, block / unblock on an any-source membership, INCLUDE→EXCLUDE mode flip → CHANGE_TO_EXCLUDE), mirroring the IGMPv3 source-state-change tests. The packet-handler / stack-init unit tests that mocked the old emit methods now mock '_send_mld_state_change'. Lint clean, 13534 passing, 0 skipped. Reference: RFC 3810 §6.1 (source-bearing state-change Report difference records). Reference: RFC 3810 §5.2.12 (ALLOW / BLOCK / CHANGE_TO record types). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 69a52e2 commit e0f5de6

6 files changed

Lines changed: 460 additions & 76 deletions

File tree

docs/refactor/host_refinements_backlog.md

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ until the user says "push".
105105
`test__icmp6__mld__membership6_api.py` (8) + the join_group refcount /
106106
close tests. P4 (MLDv2 source records on the wire) + P5 (RX source-
107107
delivery gate) remain before the §4.2/§5.2 adherence flip.
108+
- [x] **IPv6 SSM state-change records (R4 P4a)** — MLDv2 source-bearing
109+
state-change Reports on TX. New `_send_mld_state_change` /
110+
`_mld_state_change_records` / `_emit_mld2_report` (the RFC 3810 §6.1
111+
difference table + MLDv1 coarse fallback), wired to the join / leave /
112+
mid-delta edges (a source-specific join now emits ALLOW_NEW_SOURCES /
113+
BLOCK_OLD_SOURCES / CHANGE_TO_* with the real source list instead of the
114+
coarse all-groups report); the dead coarse `_send_icmp6_mld_leave` was
115+
removed. Single immediate emission — the §6.1 robustness retransmit train
116+
is P4b. Tests: `test__icmp6__mld__source_state_change.py` (7). RFC 3810
117+
§4.2/§5.2 adherence flip waits for P4b + P5.
108118

109119
**The canonical SO_RCVBUF guard pattern** (mirror for any new datagram socket):
110120

@@ -243,11 +253,23 @@ self._signal_readable()
243253
+ the harness / thread-safety / ND-seed test updates. The RFC 3810
244254
§4.2 / §5.2 source-record adherence flip waits for P4/P5 (as the v4
245255
§3.2 flip waited for Phase 5).
246-
- P4: MLDv2 source records on TX — `Icmp6Mld2MulticastAddressRecordType`
247-
already has `ALLOW_NEW_SOURCES` (5) / `BLOCK_OLD_SOURCES` (6) /
248-
`CHANGE_TO_INCLUDE` (3) / `CHANGE_TO_EXCLUDE` (4). Emit the state-change
249-
source records on filter transitions (mirror `_send_igmp_state_change`;
250-
the MLDv2 leave path added in `de3d3213` is the sibling to extend).
256+
- **P4a — SHIPPED:** MLDv2 source-bearing state-change records on TX.
257+
Added `_send_mld_state_change(group, old, new)` + `_mld_state_change_records`
258+
(the §6.1 difference table: mode change → CHANGE_TO_INCLUDE /
259+
CHANGE_TO_EXCLUDE with the new source list; within-mode source change →
260+
ALLOW_NEW_SOURCES / BLOCK_OLD_SOURCES) + `_emit_mld2_report`, with the
261+
MLDv1 coarse Report/Done fallback. Wired the three edges: the
262+
`assign_ip6_multicast` join edge (`old=NONMEMBER, new`), the
263+
`remove_ip6_multicast` leave edge (`old, new=NONMEMBER`), and the
264+
`_mc6_recompute` mid-delta (replacing the `# P4:` placeholder). The now-
265+
dead coarse `_send_icmp6_mld_leave` (single) was removed. Single
266+
immediate emission — the RFC 3810 §6.1 robustness retransmit train is
267+
**P4b** (mirror the v4 `_igmp_state_change__pending` /
268+
`_arm`/`_fire`/`_cancel` machinery). Tests-first:
269+
`test__icmp6__mld__source_state_change.py` (7) — ALLOW / BLOCK / TO_EX /
270+
TO_IN records with source lists, mirroring the v4 track.
271+
- P4b: the §6.1 robustness retransmit-train state machine
272+
(`_mld_state_change__pending` + `_arm`/`_fire`/`_cancel`), mirror v4.
251273
- P5: RX source-delivery filter for IPv6 UDP + RAW (`Ip6MulticastFilter.allows`).
252274
- Adherence: update `docs/rfc/icmp6/rfc3810__mld2/adherence.md` (§4.2.12 /
253275
§5.1 / §5.2 source records) + a socket-parity note, in lockstep.
@@ -262,11 +284,13 @@ core (`_Ip6GroupMembership` / `_ip6_multicast_filters` / `_ip6_multicast_refs`
262284
/ `mc6_*` / `_ip6_multicast_filter_for`, all under `_lock__multicast`) AND
263285
the socket surface (`stack.membership6`, refcounted `IPV6_JOIN_GROUP`, the
264286
`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
287+
`_ip6_source_filters`, close-time release) are done. **P4a SHIPPED** — the
288+
`_send_mld_state_change` source-bearing state-change records (ALLOW / BLOCK /
289+
CHANGE_TO_*) now fire on all three edges (join / leave / mid-delta),
290+
single-emission. **Remaining: P4b** (the §6.1 robustness retransmit-train
291+
state machine — mirror the v4 `_igmp_state_change__pending` /
292+
`_arm`/`_fire`/`_cancel`), **P5** (RX `Ip6MulticastFilter.allows` source-
293+
delivery gate for UDP + RAW). After P4b/P5 flip the RFC 3810 §4.2 / §5.2
270294
adherence rows.
271295

272296
- **Handler (`runtime/packet_handler/__init__.py`) — the deep, no-GIL-locked

packages/pytcp/pytcp/runtime/packet_handler/__init__.py

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,12 +1094,11 @@ def _mc6_recompute(self, group: Ip6Address, /) -> None:
10941094
# §6.1 join Report.
10951095
self.assign_ip6_multicast(ip6_multicast=group)
10961096
elif merged != self._ip6_multicast_filters[group]:
1097-
# Still joined, filter changed: re-materialize and
1098-
# re-announce. P4: replace the current-state Report
1099-
# with an MLDv2 ALLOW / BLOCK / CHANGE_TO_* source
1100-
# delta ('_send_mld_state_change').
1097+
# Still joined, filter changed: re-materialize and emit
1098+
# the §6.1 source delta (ALLOW / BLOCK / CHANGE_TO_*).
1099+
old = self._ip6_multicast_filters[group]
11011100
self._ip6_multicast_filters[group] = merged
1102-
self._send_icmp6_multicast_listener_report()
1101+
self._send_mld_state_change(group, old=old, new=merged)
11031102
elif joined:
11041103
self.remove_ip6_multicast(ip6_multicast=group)
11051104

@@ -2754,13 +2753,20 @@ def _send_icmp6_multicast_listener_report(self) -> None:
27542753

27552754
self._icmp6_tx._send_icmp6_multicast_listener_report()
27562755

2757-
def _send_icmp6_mld_leave(self, ip6_multicast: Ip6Address, /) -> None:
2756+
def _send_mld_state_change(
2757+
self,
2758+
group: Ip6Address,
2759+
/,
2760+
*,
2761+
old: Ip6MulticastFilter,
2762+
new: Ip6MulticastFilter,
2763+
) -> None:
27582764
"""
2759-
Announce departure from an IPv6 multicast group (delegates to the
2760-
ICMPv6 TX sub-handler).
2765+
Emit an MLDv2 source-bearing state-change Report for 'group'
2766+
(RFC 3810 §6.1; delegates to the ICMPv6 TX sub-handler).
27612767
"""
27622768

2763-
self._icmp6_tx._send_icmp6_mld_leave(ip6_multicast)
2769+
self._icmp6_tx._send_mld_state_change(group, old=old, new=new)
27642770

27652771
def _send_igmp_v3_report(self) -> None:
27662772
"""
@@ -3977,13 +3983,16 @@ def assign_ip6_multicast(self, /, ip6_multicast: Ip6Address) -> None:
39773983
# Materialize the merged §4.2 reception filter (EXCLUDE{} for a
39783984
# directly-assigned / any-source group, the merged contributors'
39793985
# filter for a source-specific join).
3980-
self._ip6_multicast_filters[ip6_multicast] = self._ip6_multicast_filter_for(ip6_multicast)
3986+
new = self._ip6_multicast_filter_for(ip6_multicast)
3987+
self._ip6_multicast_filters[ip6_multicast] = new
39813988

39823989
__debug__ and log("stack", f"Assigned IPv6 multicast {ip6_multicast}")
39833990

39843991
self._assign_mac_multicast(ip6_multicast.multicast_mac)
39853992

3986-
self._send_icmp6_multicast_listener_report()
3993+
# RFC 3810 §6.1 — announce the new membership with a state-change
3994+
# Report describing the INCLUDE{}→'new' transition.
3995+
self._send_mld_state_change(ip6_multicast, old=_IP6_MULTICAST__NONMEMBER, new=new)
39873996

39883997
@override
39893998
def remove_ip6_multicast(self, /, ip6_multicast: Ip6Address) -> None:
@@ -3992,16 +4001,17 @@ def remove_ip6_multicast(self, /, ip6_multicast: Ip6Address) -> None:
39924001
"""
39934002

39944003
with self._lock__multicast:
4004+
old = self._ip6_multicast_filters[ip6_multicast]
39954005
del self._ip6_multicast_filters[ip6_multicast]
39964006

39974007
__debug__ and log("stack", f"Removed IPv6 multicast {ip6_multicast}")
39984008

39994009
self._remove_mac_multicast(ip6_multicast.multicast_mac)
40004010

4001-
# RFC 3810 §6.1 — announce the departure with a State Change
4002-
# Report (CHANGE_TO_INCLUDE, empty source list), the IPv6
4003-
# analogue of the IGMP leave above.
4004-
self._send_icmp6_mld_leave(ip6_multicast)
4011+
# RFC 3810 §6.1 — announce the departure with a state-change
4012+
# Report describing the 'old'→INCLUDE{} transition (no longer a
4013+
# listener).
4014+
self._send_mld_state_change(ip6_multicast, old=old, new=_IP6_MULTICAST__NONMEMBER)
40054015

40064016
@override
40074017
def _assign_ip4_multicast(self, /, ip4_multicast: Ip4Address) -> None:
@@ -4205,11 +4215,14 @@ def assign_ip6_multicast(self, /, ip6_multicast: Ip6Address) -> None:
42054215
with self._lock__multicast:
42064216
# Materialize the merged §4.2 reception filter (EXCLUDE{} for an
42074217
# any-source group, the merged contributors' filter otherwise).
4208-
self._ip6_multicast_filters[ip6_multicast] = self._ip6_multicast_filter_for(ip6_multicast)
4218+
new = self._ip6_multicast_filter_for(ip6_multicast)
4219+
self._ip6_multicast_filters[ip6_multicast] = new
42094220

42104221
__debug__ and log("stack", f"Assigned IPv6 multicast {ip6_multicast}")
42114222

4212-
self._send_icmp6_multicast_listener_report()
4223+
# RFC 3810 §6.1 — announce the new membership with a state-change
4224+
# Report describing the INCLUDE{}→'new' transition.
4225+
self._send_mld_state_change(ip6_multicast, old=_IP6_MULTICAST__NONMEMBER, new=new)
42134226

42144227
@override
42154228
def remove_ip6_multicast(self, /, ip6_multicast: Ip6Address) -> None:
@@ -4218,13 +4231,14 @@ def remove_ip6_multicast(self, /, ip6_multicast: Ip6Address) -> None:
42184231
"""
42194232

42204233
with self._lock__multicast:
4234+
old = self._ip6_multicast_filters[ip6_multicast]
42214235
del self._ip6_multicast_filters[ip6_multicast]
42224236

42234237
__debug__ and log("stack", f"Removed IPv6 multicast {ip6_multicast}")
42244238

4225-
# RFC 3810 §6.1 — announce the departure with a State Change
4226-
# Report (CHANGE_TO_INCLUDE, empty source list).
4227-
self._send_icmp6_mld_leave(ip6_multicast)
4239+
# RFC 3810 §6.1 — announce the departure with a state-change
4240+
# Report describing the 'old'→INCLUDE{} transition.
4241+
self._send_mld_state_change(ip6_multicast, old=old, new=_IP6_MULTICAST__NONMEMBER)
42284242

42294243
@override
42304244
def _assign_ip4_multicast(self, /, ip4_multicast: Ip4Address) -> None:

packages/pytcp/pytcp/runtime/packet_handler/packet_handler__icmp6__tx.py

Lines changed: 115 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,23 @@
7171
Ip6HbhOptionRouterAlert,
7272
)
7373
from net_proto.protocols.ip6_hbh.options.ip6_hbh__options import Ip6HbhOptions
74+
from pytcp.lib.ip6_multicast_filter import (
75+
Ip6MulticastFilter,
76+
Ip6MulticastFilterMode,
77+
)
7478
from pytcp.lib.logger import log
7579
from pytcp.lib.tx_status import TxStatus
7680
from pytcp.stack import sysctl_iface
7781

7882
if TYPE_CHECKING:
7983
from pytcp.runtime.packet_handler import PacketHandler
8084

85+
# The IPv6 MLD destinations (RFC 3810 §5.2.14 / RFC 2710 §3): an MLDv2
86+
# Report goes to the all-MLDv2-routers group; the all-nodes group is
87+
# never reported (RFC 3810 §6).
88+
MLD__ALL_MLDV2_ROUTERS = Ip6Address("ff02::16")
89+
MLD__ALL_NODES = Ip6Address("ff02::1")
90+
8191

8292
class Icmp6TxHandler:
8393
"""
@@ -263,41 +273,6 @@ def _send_icmp6_mld1_report(self, group: Ip6Address, /) -> None:
263273
self._if._packet_stats_tx.icmp6__mld1__report__send += 1
264274
self.__send_icmp6_mld_via_hbh_ra(icmp6_packet_tx, ip6__dst=group)
265275

266-
def _send_icmp6_mld_leave(self, group: Ip6Address, /) -> None:
267-
"""
268-
Announce departure from a single IPv6 multicast group.
269-
270-
Reference: RFC 3810 §5.2.12 (CHANGE_TO_INCLUDE record).
271-
Reference: RFC 3810 §6.1 (leaving a group is a state-change Report).
272-
273-
The all-nodes group (ff02::1) is never reported (RFC 3810 §6).
274-
While in MLDv1 Host Compatibility Mode the departure is announced
275-
with an MLDv1 Done so an MLDv1-only querier can parse it
276-
(RFC 3810 §8.3.2); otherwise an MLDv2 State Change Report with a
277-
CHANGE_TO_INCLUDE record and an empty source list is the leave
278-
signal.
279-
"""
280-
281-
if group == Ip6Address("ff02::1"):
282-
return
283-
284-
if self._if._mld_host_compatibility_mode() is MldVersion.V1:
285-
self._send_icmp6_mld1_done(group)
286-
return
287-
288-
icmp6_packet_tx = Icmp6Assembler(
289-
icmp6__message=Icmp6Mld2MessageReport(
290-
records=[
291-
Icmp6Mld2MulticastAddressRecord(
292-
type=Icmp6Mld2MulticastAddressRecordType.CHANGE_TO_INCLUDE,
293-
multicast_address=group,
294-
)
295-
],
296-
),
297-
)
298-
self._if._packet_stats_tx.icmp6__mld2__report__send += 1
299-
self.__send_icmp6_mld_via_hbh_ra(icmp6_packet_tx, ip6__dst=Ip6Address("ff02::16"))
300-
301276
def _send_icmp6_mld1_done(self, group: Ip6Address, /) -> None:
302277
"""
303278
Send an MLDv1 Multicast Listener Done (type 132) for 'group'.
@@ -348,6 +323,111 @@ def _send_icmp6_mld_leave_all(self) -> None:
348323
self._if._packet_stats_tx.icmp6__mld2__report__send += 1
349324
self.__send_icmp6_mld_via_hbh_ra(icmp6_packet_tx, ip6__dst=Ip6Address("ff02::16"))
350325

326+
def _emit_mld2_report(self, records: list[Icmp6Mld2MulticastAddressRecord], /) -> None:
327+
"""
328+
Assemble and emit a single aggregated MLDv2 State Change Report
329+
(type 143) carrying 'records' to the all-MLDv2-routers group
330+
ff02::16, wrapped in the Hop-by-Hop Router Alert carrier
331+
(RFC 3810 §5.2.14). A report with no records is not emitted.
332+
"""
333+
334+
if not records:
335+
return
336+
337+
icmp6_packet_tx = Icmp6Assembler(
338+
icmp6__message=Icmp6Mld2MessageReport(records=records),
339+
)
340+
self._if._packet_stats_tx.icmp6__mld2__report__send += 1
341+
self.__send_icmp6_mld_via_hbh_ra(icmp6_packet_tx, ip6__dst=MLD__ALL_MLDV2_ROUTERS)
342+
343+
@staticmethod
344+
def _mld_state_change_records(
345+
group: Ip6Address,
346+
old: Ip6MulticastFilter,
347+
new: Ip6MulticastFilter,
348+
/,
349+
) -> list[Icmp6Mld2MulticastAddressRecord]:
350+
"""
351+
Compute the MLDv2 difference records for a group's filter change
352+
per the RFC 3810 §6.1 table (the "non-listener" state is
353+
INCLUDE{}): a filter-mode change yields one CHANGE_TO_INCLUDE /
354+
CHANGE_TO_EXCLUDE record carrying the new source list; a
355+
within-mode source change yields ALLOW_NEW_SOURCES and/or
356+
BLOCK_OLD_SOURCES records (empty ones are omitted). The IPv6
357+
analogue of the IGMPv3 '_state_change_records'.
358+
"""
359+
360+
if old.mode is new.mode:
361+
if old.mode is Ip6MulticastFilterMode.INCLUDE:
362+
allow, block = new.sources - old.sources, old.sources - new.sources
363+
else:
364+
allow, block = old.sources - new.sources, new.sources - old.sources
365+
records: list[Icmp6Mld2MulticastAddressRecord] = []
366+
if allow:
367+
records.append(
368+
Icmp6Mld2MulticastAddressRecord(
369+
type=Icmp6Mld2MulticastAddressRecordType.ALLOW_NEW_SOURCES,
370+
multicast_address=group,
371+
source_addresses=sorted(allow, key=int),
372+
)
373+
)
374+
if block:
375+
records.append(
376+
Icmp6Mld2MulticastAddressRecord(
377+
type=Icmp6Mld2MulticastAddressRecordType.BLOCK_OLD_SOURCES,
378+
multicast_address=group,
379+
source_addresses=sorted(block, key=int),
380+
)
381+
)
382+
return records
383+
384+
record_type = (
385+
Icmp6Mld2MulticastAddressRecordType.CHANGE_TO_EXCLUDE
386+
if new.mode is Ip6MulticastFilterMode.EXCLUDE
387+
else Icmp6Mld2MulticastAddressRecordType.CHANGE_TO_INCLUDE
388+
)
389+
return [
390+
Icmp6Mld2MulticastAddressRecord(
391+
type=record_type,
392+
multicast_address=group,
393+
source_addresses=sorted(new.sources, key=int),
394+
)
395+
]
396+
397+
def _send_mld_state_change(
398+
self,
399+
group: Ip6Address,
400+
/,
401+
*,
402+
old: Ip6MulticastFilter,
403+
new: Ip6MulticastFilter,
404+
) -> None:
405+
"""
406+
Emit an unsolicited state-change Report for 'group' describing the
407+
transition from filter 'old' to filter 'new' (RFC 3810 §6.1) in
408+
the form dictated by the interface's Host Compatibility Mode
409+
(§8.3). In MLDv2 mode the Report carries the source-bearing §6.1
410+
difference records; in MLDv1 mode it degrades to the coarse
411+
Report / Done (MLDv1 has no source concept), keyed only off the
412+
reception edge. The all-nodes group ff02::1 is never reported
413+
(RFC 3810 §6). The IPv6 analogue of '_send_igmp_state_change'.
414+
"""
415+
416+
if group == MLD__ALL_NODES:
417+
return
418+
419+
# The coarse MLDv1 form keys only off the reception edge — a
420+
# source-only change within a still-joined membership is invisible
421+
# to an older-version querier.
422+
if self._if._mld_host_compatibility_mode() is MldVersion.V1:
423+
if new.has_reception and not old.has_reception:
424+
self._send_icmp6_mld1_report(group)
425+
elif old.has_reception and not new.has_reception:
426+
self._send_icmp6_mld1_done(group)
427+
return
428+
429+
self._emit_mld2_report(self._mld_state_change_records(group, old, new))
430+
351431
def __send_icmp6_mld_via_hbh_ra(self, icmp6_packet_tx: Icmp6Assembler, /, *, ip6__dst: Ip6Address) -> None:
352432
"""
353433
Emit an MLD ICMPv6 message ('icmp6_packet_tx') wrapped in a

0 commit comments

Comments
 (0)