Skip to content

Commit fa3979b

Browse files
ccie18643claude
andcommitted
feat(mld): MLDv2 state-change robustness retransmit train (R4 P4b)
Adds the RFC 3810 §6.1 robustness retransmission of unsolicited MLDv2 state-change Reports — an unsolicited state-change is sent [Robustness Variable] total times so the membership change survives the loss of up to RV-1 packets, mirroring the shipped IGMPv3 retransmit train. New on the ICMPv6 TX handler (the IPv6 analogue of the IGMP '_igmp_state_change__pending' machinery): - '_MldPendingChange' (the source-bearing §6.1 difference records to re-send in MLDv2 mode, the reception-edge direction for the MLDv1 coarse Report/Done fallback, and the remaining-repeat count) + '_mld_state_change__pending' / '_mld_state_change__handle'. - '_send_mld_state_change' now, after the immediate emission, seeds a pending entry with RV-1 repeats and arms a retransmit ticket (a fresh change supersedes any train pending for the same group). - '_arm' schedules one ticket at random(1, mld.unsolicited_report_interval] ms via 'stack.timer'; '_fire' re-emits under '_lock__multicast' in the live compat mode (MLDv2 coalesces all pending groups into one Report, MLDv1 emits the coarse form per group), decrements each entry, drops the exhausted, and re-arms while any remain; '_cancel' drops the ticket + clears the map on a compat-mode change (RFC 3810 §8.2.1), wired into the RX 'ip_arm_v1_compatibility' MLDv2→MLDv1 transition (mirroring the v4 '_igmp_cancel_pending_timers' hook). Three MLD policy sysctls back the train: 'mld.robustness' (RV, default 2), 'mld.unsolicited_report_interval' (default 1000 ms), and 'mld.query_interval' (default 125 s). The on-touch migration folded the hardcoded 'MLD__ROBUSTNESS_VARIABLE' / 'MLD__QUERY_INTERVAL__MS' module locals out of 'packet_handler__icmp6__rx.py' into the sysctl-backed 'mld__constants', read via qualified module access so an operator override resolves live — removing the duplicate definition and completing the MLD constants file's policy-knob migration. Tests-first: three retransmit tests added to 'test__icmp6__mld__source_state_change.py' — the RV-1 retransmit carries the same source-bearing record as the immediate Report, a RV of 2 schedules exactly one retransmit (exhaustion), and an inbound MLDv1 Query cancels the pending train. The random spacing is patched to a fixed delay and the FakeTimer advanced to fire the ticket. Lint clean, 13537 passing, 0 skipped. Reference: RFC 3810 §6.1 (state-change Report retransmitted RV-1 times). Reference: RFC 3810 §9.1 (Robustness Variable). Reference: RFC 3810 §8.2.1 (compat-mode change cancels pending retransmits). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e0f5de6 commit fa3979b

5 files changed

Lines changed: 358 additions & 32 deletions

File tree

docs/refactor/host_refinements_backlog.md

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,18 @@ until the user says "push".
115115
removed. Single immediate emission — the §6.1 robustness retransmit train
116116
is P4b. Tests: `test__icmp6__mld__source_state_change.py` (7). RFC 3810
117117
§4.2/§5.2 adherence flip waits for P4b + P5.
118+
- [x] **IPv6 SSM robustness retransmit train (R4 P4b)** — the RFC 3810 §6.1
119+
state-change retransmit machine. `_send_mld_state_change` now schedules
120+
RV-1 retransmits (`_MldPendingChange` + `_mld_state_change__pending` +
121+
`_arm`/`_fire`/`_cancel_mld_state_change_retransmit(s)`, mirroring the v4
122+
IGMP train) at random(1, `mld.unsolicited_report_interval`] via
123+
`stack.timer`, re-arming per fire; a compat-mode change (MLDv1 Query)
124+
cancels the train. Added three MLD policy sysctls (`mld.robustness` /
125+
`mld.unsolicited_report_interval` / `mld.query_interval`), folding the
126+
hardcoded `MLD__ROBUSTNESS_VARIABLE` / `MLD__QUERY_INTERVAL__MS` locals
127+
in `packet_handler__icmp6__rx.py` into the sysctl-backed `mld__constants`
128+
(on-touch migration). Tests: 3 retransmit tests. P5 (RX source-delivery
129+
gate) is the last piece before the §4.2/§5.2 adherence flip.
118130

119131
**The canonical SO_RCVBUF guard pattern** (mirror for any new datagram socket):
120132

@@ -268,8 +280,20 @@ self._signal_readable()
268280
`_arm`/`_fire`/`_cancel` machinery). Tests-first:
269281
`test__icmp6__mld__source_state_change.py` (7) — ALLOW / BLOCK / TO_EX /
270282
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.
283+
- **P4b — SHIPPED:** the §6.1 robustness retransmit-train state machine.
284+
Added `_MldPendingChange` + `_mld_state_change__pending` /
285+
`_mld_state_change__handle` on the ICMPv6 TX handler and
286+
`_arm`/`_fire`/`_cancel_mld_state_change_retransmit(s)` mirroring the v4
287+
IGMP machinery: `_send_mld_state_change` now schedules RV-1 retransmits
288+
at random(1, `mld.unsolicited_report_interval`] via `stack.timer`, the
289+
ticket re-arms per fire, and a compat-mode change (MLDv1 Query) cancels
290+
the train (wired into `_mld_arm_v1_compatibility`). Three new MLD
291+
policy sysctls (`mld.robustness` / `mld.unsolicited_report_interval` /
292+
`mld.query_interval`); the on-touch migration folded the hardcoded
293+
`MLD__ROBUSTNESS_VARIABLE` / `MLD__QUERY_INTERVAL__MS` locals out of
294+
`packet_handler__icmp6__rx.py` into the sysctl-backed `mld__constants`
295+
(qualified module access). Tests: 3 retransmit tests added to
296+
`test__icmp6__mld__source_state_change.py`.
273297
- P5: RX source-delivery filter for IPv6 UDP + RAW (`Ip6MulticastFilter.allows`).
274298
- Adherence: update `docs/rfc/icmp6/rfc3810__mld2/adherence.md` (§4.2.12 /
275299
§5.1 / §5.2 source records) + a socket-parity note, in lockstep.
@@ -284,13 +308,13 @@ core (`_Ip6GroupMembership` / `_ip6_multicast_filters` / `_ip6_multicast_refs`
284308
/ `mc6_*` / `_ip6_multicast_filter_for`, all under `_lock__multicast`) AND
285309
the socket surface (`stack.membership6`, refcounted `IPV6_JOIN_GROUP`, the
286310
`MCAST_*_SOURCE_*` family with `group_source_req`, per-socket
287-
`_ip6_source_filters`, close-time release) are done. **P4a SHIPPED** — the
311+
`_ip6_source_filters`, close-time release) are done. **P4a + P4b SHIPPED** — the
288312
`_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
313+
CHANGE_TO_*) fire on all three edges (join / leave / mid-delta) AND
314+
retransmit RV-1 times per the §6.1 robustness train (`_MldPendingChange` +
315+
`_arm`/`_fire`/`_cancel`, cancelled on a compat-mode change; three new
316+
`mld.*` sysctls). **Remaining: P5** (RX `Ip6MulticastFilter.allows` source-
317+
delivery gate for UDP + RAW). After P5 flip the RFC 3810 §4.2 / §5.2
294318
adherence rows.
295319

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

packages/pytcp/pytcp/protocols/icmp6/mld__constants.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,30 @@
3838
# MLDv2 regardless of the queriers heard.
3939
MLD__FORCE_VERSION = 0
4040

41-
# Sysctl registration. The constant above is a policy knob,
41+
# RFC 3810 §9.1 Robustness Variable (default 2). A host transmits an
42+
# unsolicited state-change Report RV total times (the initial Report plus
43+
# RV-1 retransmits) so the membership change survives the loss of up to
44+
# RV-1 packets. The IPv6 analogue of 'IGMP__ROBUSTNESS_VARIABLE'.
45+
MLD__ROBUSTNESS_VARIABLE = 2
46+
47+
# RFC 3810 §9.11 Unsolicited Report Interval, in milliseconds (the RFC
48+
# default is 1 second). Each state-change-Report retransmit is spaced by
49+
# a value drawn uniformly at random from (0, this interval]. The IPv6
50+
# analogue of 'IGMP__UNSOLICITED_REPORT_INTERVAL__MS'.
51+
MLD__UNSOLICITED_REPORT_INTERVAL__MS = 1000
52+
53+
# RFC 3810 §9.2 Query Interval default (125 s), in milliseconds. An MLDv1
54+
# Query carries no QQIC, so this default is the [Query Interval] term of
55+
# the §9.12 Older Version Querier Present Timeout the host arms when it
56+
# hears such a Query. The IPv6 analogue of 'IGMP__QUERY_INTERVAL__MS'.
57+
MLD__QUERY_INTERVAL__MS = 125_000
58+
59+
# Sysctl registration. Every constant above is a policy knob,
4260
# operator-tunable at boot via 'stack.init(sysctls={...})' or at
43-
# runtime via 'pytcp.stack.sysctl["mld.version"] = N'.
61+
# runtime via 'pytcp.stack.sysctl["mld...."] = N'.
4462
from pytcp.stack.sysctl import ( # noqa: E402
4563
is_int_in_range,
64+
is_positive_int,
4665
register,
4766
)
4867

@@ -54,3 +73,27 @@
5473
validator=is_int_in_range("mld.version", low=0, high=2),
5574
description="RFC 3810 §8 forced MLD Host Compatibility Mode — 0 = auto fallback, 1/2 = pin MLDv1/MLDv2.",
5675
)
76+
register(
77+
key="mld.robustness",
78+
module_name=__name__,
79+
attr="MLD__ROBUSTNESS_VARIABLE",
80+
default=MLD__ROBUSTNESS_VARIABLE,
81+
validator=is_positive_int("mld.robustness"),
82+
description="RFC 3810 §9.1 Robustness Variable — unsolicited state-change Report transmission count.",
83+
)
84+
register(
85+
key="mld.unsolicited_report_interval",
86+
module_name=__name__,
87+
attr="MLD__UNSOLICITED_REPORT_INTERVAL__MS",
88+
default=MLD__UNSOLICITED_REPORT_INTERVAL__MS,
89+
validator=is_positive_int("mld.unsolicited_report_interval"),
90+
description="RFC 3810 §9.11 Unsolicited Report Interval — state-change Report retransmit spacing, ms.",
91+
)
92+
register(
93+
key="mld.query_interval",
94+
module_name=__name__,
95+
attr="MLD__QUERY_INTERVAL__MS",
96+
default=MLD__QUERY_INTERVAL__MS,
97+
validator=is_positive_int("mld.query_interval"),
98+
description="RFC 3810 §9.2 Query Interval — [Query Interval] term of the MLDv1 querier-present timeout, ms.",
99+
)

packages/pytcp/pytcp/runtime/packet_handler/packet_handler__icmp6__rx.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
from pytcp import stack
6767
from pytcp.lib.dad_slot_registry import DadSignalResult
6868
from pytcp.lib.logger import log
69+
from pytcp.protocols.icmp6 import mld__constants
6970
from pytcp.protocols.icmp6.icmp6__echo_gate import should_emit_echo_reply
7071
from pytcp.protocols.icmp.icmp__error_demux import EmbeddedL4, parse_embedded_l4
7172
from pytcp.protocols.tcp.tcp__icmp_metadata import IcmpCategory, IcmpMetadata
@@ -82,14 +83,6 @@
8283
from pytcp.runtime.packet_handler import PacketHandler
8384

8485

85-
# RFC 3810 §9.1 / §9.2 — MLD Robustness Variable and Query Interval
86-
# defaults, used to compute the §9.12 Older Version Querier Present
87-
# Timeout = [Robustness Variable] x [Query Interval] + [Query Response
88-
# Interval] when an MLDv1 Query arms the compatibility timer.
89-
MLD__ROBUSTNESS_VARIABLE = 2
90-
MLD__QUERY_INTERVAL__MS = 125_000
91-
92-
9386
def _mld2_mrc_to_mrd_ms(mrc: int) -> int:
9487
"""
9588
RFC 3810 §5.1.3 — convert a 16-bit Maximum Response
@@ -1233,9 +1226,16 @@ def _mld_arm_v1_compatibility(self, max_response_delay_ms: int, /) -> None:
12331226
arming.
12341227
"""
12351228

1236-
timeout_ms = MLD__ROBUSTNESS_VARIABLE * MLD__QUERY_INTERVAL__MS + max_response_delay_ms
1229+
timeout_ms = (
1230+
mld__constants.MLD__ROBUSTNESS_VARIABLE * mld__constants.MLD__QUERY_INTERVAL__MS + max_response_delay_ms
1231+
)
12371232
with self._if._lock__multicast:
1233+
old_mode = self._if._mld_host_compatibility_mode()
12381234
self._if._mld__v1_querier_present_until_ms = stack.timer.now_ms + timeout_ms
1235+
# RFC 3810 §8.2.1 — a compatibility-mode change (MLDv2→MLDv1)
1236+
# cancels every pending state-change retransmission.
1237+
if self._if._mld_host_compatibility_mode() is not old_mode:
1238+
self._if._icmp6_tx._cancel_mld_state_change_retransmits()
12391239

12401240
def _mld_query__schedule_response(self, mrd_ms: int, /) -> None:
12411241
"""

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

Lines changed: 149 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
ver 3.0.8
3333
"""
3434

35+
import random
3536
import struct
37+
from dataclasses import dataclass
3638
from typing import TYPE_CHECKING
3739

3840
from net_addr import Buffer, Ip6Address
@@ -71,12 +73,15 @@
7173
Ip6HbhOptionRouterAlert,
7274
)
7375
from net_proto.protocols.ip6_hbh.options.ip6_hbh__options import Ip6HbhOptions
76+
from pytcp import stack
7477
from pytcp.lib.ip6_multicast_filter import (
7578
Ip6MulticastFilter,
7679
Ip6MulticastFilterMode,
7780
)
7881
from pytcp.lib.logger import log
7982
from pytcp.lib.tx_status import TxStatus
83+
from pytcp.protocols.icmp6 import mld__constants
84+
from pytcp.runtime.timer import TimerHandle
8085
from pytcp.stack import sysctl_iface
8186

8287
if TYPE_CHECKING:
@@ -89,19 +94,40 @@
8994
MLD__ALL_NODES = Ip6Address("ff02::1")
9095

9196

97+
@dataclass(frozen=True, kw_only=True, slots=True)
98+
class _MldPendingChange:
99+
"""
100+
A pending MLDv2 state-change for one group awaiting robustness
101+
retransmission — the source-bearing §6.1 difference records to
102+
re-send in MLDv2 mode, the reception-edge direction to re-send in
103+
MLDv1 compatibility mode ('True' for a join → MLDv1 Report, 'False'
104+
for a leave → MLDv1 Done, 'None' for a source-only change an
105+
older-version host cannot express), and the number of
106+
retransmissions still owed. The IPv6 analogue of '_IgmpPendingChange'.
107+
"""
108+
109+
records: tuple[Icmp6Mld2MulticastAddressRecord, ...]
110+
coarse_join: bool | None
111+
remaining: int
112+
113+
92114
class Icmp6TxHandler:
93115
"""
94116
The outbound ICMPv6 packet handler for one interface.
95117
"""
96118

97119
_if: PacketHandler
120+
_mld_state_change__pending: dict[Ip6Address, _MldPendingChange]
121+
_mld_state_change__handle: TimerHandle | None
98122

99123
def __init__(self, *, interface: PacketHandler) -> None:
100124
"""
101125
Bind the handler to its owning interface.
102126
"""
103127

104128
self._if = interface
129+
self._mld_state_change__pending = {}
130+
self._mld_state_change__handle = None
105131

106132
def _phtx_icmp6(
107133
self,
@@ -394,6 +420,19 @@ def _mld_state_change_records(
394420
)
395421
]
396422

423+
def _emit_mld1_coarse(self, group: Ip6Address, coarse_join: bool | None, /) -> None:
424+
"""
425+
Emit the coarse MLDv1 form of a state-change for 'group' while in
426+
MLDv1 Host Compatibility Mode: a Report on a join edge, a Done on a
427+
leave edge, nothing for a source-only change (MLDv1 has no source
428+
concept). 'coarse_join' is True (join), False (leave) or None.
429+
"""
430+
431+
if coarse_join is True:
432+
self._send_icmp6_mld1_report(group)
433+
elif coarse_join is False:
434+
self._send_icmp6_mld1_done(group)
435+
397436
def _send_mld_state_change(
398437
self,
399438
group: Ip6Address,
@@ -406,27 +445,125 @@ def _send_mld_state_change(
406445
Emit an unsolicited state-change Report for 'group' describing the
407446
transition from filter 'old' to filter 'new' (RFC 3810 §6.1) in
408447
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'.
448+
(§8.3), and schedule its robustness retransmissions. In MLDv2 mode
449+
the Report carries the source-bearing §6.1 difference records; in
450+
MLDv1 mode it degrades to the coarse Report / Done keyed only off
451+
the reception edge (MLDv1 has no source concept). The all-nodes
452+
group ff02::1 is never reported (RFC 3810 §6).
453+
454+
A new change supersedes any retransmit train still pending for the
455+
same group (overwrite + re-seed). A change that produces no record
456+
(an idempotent re-add) schedules no retransmit. The IPv6 analogue
457+
of '_send_igmp_state_change'.
414458
"""
415459

416460
if group == MLD__ALL_NODES:
417461
return
418462

463+
records = self._mld_state_change_records(group, old, new)
419464
# 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.
465+
# source-only change within a still-joined membership (coarse_join
466+
# None) is invisible to an older-version querier.
467+
coarse_join = (
468+
True
469+
if new.has_reception and not old.has_reception
470+
else False if old.has_reception and not new.has_reception else None
471+
)
472+
422473
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)
474+
self._emit_mld1_coarse(group, coarse_join)
475+
else:
476+
self._emit_mld2_report(records)
477+
478+
# RFC 3810 §9.1 — read the Robustness Variable live so an operator
479+
# override resolves per change.
480+
repeats = mld__constants.MLD__ROBUSTNESS_VARIABLE - 1
481+
if not records or repeats <= 0:
482+
self._mld_state_change__pending.pop(group, None)
483+
return
484+
485+
self._mld_state_change__pending[group] = _MldPendingChange(
486+
records=tuple(records),
487+
coarse_join=coarse_join,
488+
remaining=repeats,
489+
)
490+
self._arm_mld_state_change_retransmit()
491+
492+
def _arm_mld_state_change_retransmit(self) -> None:
493+
"""
494+
Ensure a single retransmit ticket is scheduled for the pending
495+
state-change records. RFC 3810 §6.1 spaces the robustness
496+
retransmissions at intervals drawn uniformly at random from (0,
497+
'mld.unsolicited_report_interval' ms]; the ticket re-arms itself
498+
from each fire rather than scheduling the whole train up front, so
499+
a change arriving mid-train is picked up by the next fire. Reading
500+
the interval knob via qualified module access so an operator
501+
override resolves on each re-arm. The IPv6 analogue of
502+
'_arm_state_change_retransmit'.
503+
"""
504+
505+
if self._mld_state_change__handle is not None:
427506
return
428507

429-
self._emit_mld2_report(self._mld_state_change_records(group, old, new))
508+
delay_ms = random.randint(1, mld__constants.MLD__UNSOLICITED_REPORT_INTERVAL__MS)
509+
self._mld_state_change__handle = stack.timer.call_later(delay_ms, self._fire_mld_state_change_retransmit)
510+
511+
def _fire_mld_state_change_retransmit(self) -> None:
512+
"""
513+
Emit one robustness retransmission of the currently-pending
514+
state-change records in the current Host Compatibility Mode's form
515+
— MLDv2 coalesces them into a single Report, MLDv1 emits one
516+
coarse Report / Done per group — then decrement each entry's
517+
remaining-repeat count, drop the exhausted ones, and re-arm the
518+
ticket while any repeats remain (RFC 3810 §6.1 / §8.3). The IPv6
519+
analogue of '_fire_state_change_retransmit'.
520+
"""
521+
522+
# Runs on the Timer thread. The pending per-group change map and
523+
# the Host Compatibility Mode deadlines are mutated / written by
524+
# the RX and application threads under the interface multicast
525+
# lock, so this fire takes the same lock (the RLock is reentrant,
526+
# so the nested compat-mode / emit reads are fine).
527+
with self._if._lock__multicast:
528+
self._mld_state_change__handle = None
529+
530+
groups = list(self._mld_state_change__pending)
531+
532+
if self._if._mld_host_compatibility_mode() is MldVersion.V1:
533+
for group in groups:
534+
self._emit_mld1_coarse(group, self._mld_state_change__pending[group].coarse_join)
535+
else:
536+
records: list[Icmp6Mld2MulticastAddressRecord] = []
537+
for group in groups:
538+
records.extend(self._mld_state_change__pending[group].records)
539+
self._emit_mld2_report(records)
540+
541+
for group in groups:
542+
pending = self._mld_state_change__pending[group]
543+
if pending.remaining <= 1:
544+
del self._mld_state_change__pending[group]
545+
else:
546+
self._mld_state_change__pending[group] = _MldPendingChange(
547+
records=pending.records,
548+
coarse_join=pending.coarse_join,
549+
remaining=pending.remaining - 1,
550+
)
551+
552+
if self._mld_state_change__pending:
553+
self._arm_mld_state_change_retransmit()
554+
555+
def _cancel_mld_state_change_retransmits(self) -> None:
556+
"""
557+
Cancel the in-flight state-change retransmit ticket and drop every
558+
pending per-group change record (RFC 3810 §8.2.1 — a
559+
compatibility-mode change cancels all pending retransmissions). The
560+
IPv6 analogue of '_cancel_state_change_retransmits'.
561+
"""
562+
563+
if self._mld_state_change__handle is not None:
564+
stack.timer.cancel(self._mld_state_change__handle)
565+
self._mld_state_change__handle = None
566+
self._mld_state_change__pending.clear()
430567

431568
def __send_icmp6_mld_via_hbh_ra(self, icmp6_packet_tx: Icmp6Assembler, /, *, ip6__dst: Ip6Address) -> None:
432569
"""

0 commit comments

Comments
 (0)