@@ -188,6 +188,76 @@ self._signal_readable()
188188- ** Effort:** large (multi-phase, mirrors a whole shipped track). ** Risk:**
189189 medium. ** Value:** highest — real v4/v6 parity.
190190
191+ #### R4 — resumable implementation map (derived 2026-07-19 by reading the v4 machinery)
192+
193+ The v4 source-filter machinery to mirror, with exact locations, so P2-P5
194+ can resume without re-deriving:
195+
196+ - ** Handler (` runtime/packet_handler/__init__.py ` ) — the deep, no-GIL-locked
197+ core, duplicated across the L2 and L3 handler classes:**
198+ - ` _Ip4GroupMembership ` (line ~ 156): ` operator: bool ` + `socket_filters:
199+ dict[ int, Ip4MulticastFilter] ` + ` contributors()`. Mirror as
200+ ` _Ip6GroupMembership ` .
201+ - handler attrs (line ~ 325/451): `_ ip4_multicast_filters:
202+ dict[ Ip4Address, Ip4MulticastFilter] ` + ` _ ip4_multicast_refs:
203+ dict[ Ip4Address, _ Ip4GroupMembership] ` . Mirror ` _ ip6_ * `, init in the
204+ same ` __init__ ` , snapshot/restore in the harness.
205+ - ` mc_is_joined ` / ` _mc_recompute ` / ` mc_ref_acquire ` / ` mc_ref_release `
206+ / ` mc_set_socket_filter ` / ` mc_clear_socket_filter ` (lines ~ 863-980),
207+ all under ` self._lock__multicast ` . Mirror as ` mc6_* ` . ` _mc_recompute `
208+ is the heart: merge contributors → reception edge calls
209+ ` _assign_ip4_multicast ` (join), loss calls ` _remove_ip4_multicast `
210+ (leave), mid-membership filter delta calls ` _send_igmp_state_change ` .
211+ - ` _ip4_multicast_filter_for ` (line ~ 2586) + the filter-materializing
212+ ` _assign_ip4_multicast ` / ` _remove_ip4_multicast ` (L2 ~ 3785, L3 ~ 4014;
213+ note the v6 ` assign_ip6_multicast ` /` remove_ip6_multicast ` at L2 ~ 3752 /
214+ L3 ~ 3976 are the ANY-SOURCE public path — the filtered path is a new
215+ private sibling). ff02::1 is the v6 permanent-group exemption (v4:
216+ 224.0.0.1 ` IP4__MULTICAST__ALL_SYSTEMS ` ).
217+ - ** Membership API (` stack/membership.py ` , 232 lines, entirely v4):** build
218+ a parallel v6 surface (mirror, not genericize) — ` join ` / ` leave ` /
219+ ` set_socket_filter ` / ` clear_socket_filter ` / ` list_memberships `
220+ delegating to the ` mc6_* ` handler methods. Cap via
221+ ` igmp.max_memberships ` 's v6 analogue (no separate MLD cap today — reuse
222+ or add ` mld.max_memberships ` ).
223+ - ** Socket (` runtime/socket/__init__.py ` ):**
224+ - v4 opts at lines ~ 881-979 (` _ipproto_ip_source_membership ` +
225+ ` _apply_source_op ` ), 12-byte ` ip_mreq_source ` . For v6, Linux uses the
226+ ** protocol-independent ` MCAST_JOIN_SOURCE_GROUP ` (46) /
227+ ` MCAST_LEAVE_SOURCE_GROUP ` (47) / ` MCAST_BLOCK_SOURCE ` (43) /
228+ ` MCAST_UNBLOCK_SOURCE ` (44)** at IPPROTO_IPV6 level with a
229+ ` group_source_req ` struct — there is NO ` IPV6_ADD_SOURCE_MEMBERSHIP ` .
230+ Add these as a ` McastOption ` IntEnum + bare aliases (enums.md §2.2).
231+ - ` group_source_req ` glibc layout (native): ` gsr_interface ` uint32 at
232+ offset 0; 4 bytes pad; ` gsr_group ` sockaddr_storage at offset 8
233+ (sockaddr_in6 → sin6_addr at +8, so group addr = bytes[ 16:32] );
234+ ` gsr_source ` sockaddr_storage at offset 136 (source addr =
235+ bytes[ 144:160] ). Validate ` ss_family == AF_INET6 ` (10 on Linux) in
236+ each sockaddr. Total 264 bytes.
237+ - Per-socket `_ ip6_source_filters: dict[ (ifindex, Ip6Address),
238+ Ip6MulticastFilter] ` + a v6 ` _ apply_source_op` (mirror v4's INCLUDE/
239+ EXCLUDE mode-conflict + EADDRNOTAVAIL errno surface). Push to the v6
240+ membership API. Also migrate the existing simple ` IPV6_JOIN_GROUP ` /
241+ ` IPV6_LEAVE_GROUP ` path (line ~ 1029/1040, the non-refcounted
242+ ` _ip6_memberships ` set) onto the new API so per-socket refcounting is
243+ correct (the existing leave-removes-for-all bug the code comments flag).
244+ - Socket ` close() ` must ` clear_socket_filter ` every held v6 filter
245+ (mirror the v4 close path).
246+ - ** RX delivery (` Ip6MulticastFilter.allows ` ):** gate per-socket delivery
247+ in the IPv6 UDP + RAW RX paths (mirror the v4 ` ip_mc_sf_allow ` gate; RAW
248+ needed the ` RawMetadata.socket_ids ` wildcard-combo enumeration — check
249+ the v6 RAW metadata has the same).
250+ - ** Tests:** unit (value type — DONE P1; ` _apply_source_op ` v6 transitions;
251+ ` group_source_req ` parse) + integration (`tests/integration/protocols/
252+ icmp6/` — setsockopt drives the interface merge + emits the right MLDv2
253+ ALLOW/BLOCK/CHANGE_TO_ * record on the wire; RX source-delivery gating for
254+ UDP + RAW). Mirror the shipped IGMP SSM tests under
255+ ` tests/integration/protocols/igmp/ ` .
256+ - ** Safety note:** every ` mc6_* ` mutation runs under ` _lock__multicast `
257+ (the no-GIL standing invariant); the L2/L3 duplication must stay in
258+ sync. This is the stack's most safety-critical machinery — do each phase
259+ tests-first and run the full multicast integration suite before commit.
260+
191261### R5 — CLI polish: JSON output for ` address ` / ` ss ` / ` route ` / ` neighbor ` — SHIPPED (see "Shipped" above)
192262
193263Full ` -j ` /` --json ` parity across every observation command is now closed
@@ -335,5 +405,6 @@ None block a 3.0.8 release. Everything here can equally slip to 3.0.9.
335405- Branch ` PyTCP_3_0_8 ` . Pushed through ` b9794191 ` (UDP + RAW SO_RCVBUF +
336406 this backlog plan).
337407- Pushed through ` c2a76a60 ` (R1 / R5 / R5-followon / R10 knob / R11).
338- - ** Unpushed:** the R7 DF-probe test-lock commit + the R6 HyStart++
339- end-to-end test-strengthening commit. Hold until the user says "push".
408+ - ** Unpushed:** R7 DF-probe test-lock (` 1793fb45 ` ), R6 HyStart++ end-to-end
409+ tests (` 6cc2702a ` ), R4 P1 ` Ip6MulticastFilter ` (` d2054fe8 ` ) + its doc
410+ notes, and this resumable R4 P2-P5 map. Hold until the user says "push".
0 commit comments