Skip to content

PyTCP 3.0.9 — Phase-2 router track - #48

Merged
ccie18643 merged 28 commits into
masterfrom
PyTCP_3_0_9
Aug 10, 2026
Merged

PyTCP 3.0.9 — Phase-2 router track#48
ccie18643 merged 28 commits into
masterfrom
PyTCP_3_0_9

Conversation

@ccie18643

Copy link
Copy Markdown
Owner

Release PyTCP 3.0.9 — the Phase-2 router track. Turns the multi-homed host into a router.

Highlights

  • Unicast forwarding plane (M1–M4) — IPv4/IPv6 transit forwarding (TTL/Hop decrement, transit ICMP Time Exceeded / Destination Unreachable), transit PMTU + forwarded fragmentation, ICMP Redirect generation + host RX-accept, RFC 1812 forward-path conformance sweep.
  • Multicast router (M5a–M5f) — IGMPv3/MLDv2 querier (election, General/specific Queries, membership table, fast-leave) + last-hop multicast forwarding/replication.
  • Fixed — IGMP/MLD membership-lock cross-thread TX deadlock; stack.timer test isolation.
  • Tests/Tooling — root-gated real-TAP end-to-end suite (make test-realtap, 11 tests: 7 host + 4 two-tap router) + a CI real-tap job.

CHANGELOGs finalized to 2026-08-09. net_addr/net_proto/pytcp 3.0.9 published to PyPI. CI green (validate + real-tap).

🤖 Generated with Claude Code

https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N

ccie18643 and others added 28 commits July 23, 2026 20:47
Opens the 3.0.9 development line on the PyTCP_3_0_9 branch, cut from
the 3.0.8 release commit (now on master via PR #47).

- __version__ "3.0.8" -> "3.0.9" in net_addr / net_proto / pytcp.
- Module-docstring `ver 3.0.8` -> `ver 3.0.9` across all 1241 source
  files (lockstep with the version per source_files.md §4).
- Lockstep dependency pins bumped: PyTCP-net_proto pyproject ->
  PyTCP-net_addr==3.0.9; PyTCP pyproject -> both ==3.0.9.
- Opened a `## 3.0.9 — Unreleased` stanza in all three package
  CHANGELOGs.

READMEs left untouched — the `Current state (3.0.8)` headings describe
the shipped release, not the dev version. Changes are mechanical
(docstring / version / pin / changelog only; no code touched).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the detailed M0-M4 plan for turning PyTCP from a multi-homed host
into a router in 3.0.9. Grounded in the recon of what already ships
(per-interface handlers, FIB with Route.oif, the _forward_or_deliver
seam, the egress selector, the ICMP-emit template, the _add_interface
harness affordance) so it fills named host-mode stubs rather than
rebuilding the interface model.

Milestones: M0 RouterTestCase 3-interface harness; M1 ip_forward knob +
forward branch + TTL/Hop-Limit decrement + Time-Exceeded + no-route
Unreachable; M2 transit PMTU + forwarded-packet fragmentation; M3 ICMP
Redirect generation (incl. the missing ICMPv4 Redirect codec); M4 RFC
1812 conformance sweep. M5 (multicast/IGMP+MLD querier + FIB
ECMP/policy routing) documented as an optional post-3.0.9 follow-up.

Reference: RFC 1812 (router requirements); North Star Phase 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e (M0)

Milestone M0 of the Phase-2 router forwarding plane: the enabling test
infrastructure every later milestone asserts against, with no
production behavior change.

Add 'RouterTestCase(IcmpTestCase)' with the canonical three-interface
"router-under-test" topology — the boot interface as LAN-A plus a LAN-B
and an upstream interface via the existing '_add_interface' affordance,
with a default route via the upstream gateway. Three interfaces is the
smallest topology that exercises egress *selection* (the FIB choosing
between LAN-B and the upstream egress) rather than egress inevitability,
which a two-interface topology cannot test. Helpers: '_build_transit_ip4
/ip6' (valid Ethernet/IP/UDP transit datagrams), '_drive_forward'
(per-interface emitted frames), '_assert_no_forward'.

Baseline tests pin: three interfaces register at distinct ifindexes;
the FIB selects the correct egress (LAN-B connected route vs. upstream
default) for both families; and — the host-mode baseline M1 will flip —
an inbound transit datagram is dropped with no frame emitted while
forwarding is disabled (the current 'ip{4,6}__dst_unknown__drop' path).

The per-forward stat counters and the forwarding-enable helper land
with M1, when the 'ip_forward' knob and the forward branch first bump /
consume them (tests-first: added alongside the code that exercises
them).

Reference: RFC 1812 §5.2.1 (forward-or-deliver decision); §5.2.4
(next-hop determination); North Star Phase 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Milestone M1 of the Phase-2 router forwarding plane (part 1 of 2):
the operator policy surface and the FIB seam the forward path
consumes. No RX/TX behaviour change on its own — the forward
branch that reads these lands in the companion commit.

Register four runtime-tunable sysctls via the per-interface
framework: 'ip4.ip_forward' / 'ip6.all.forwarding' (global
masters, flat bool) and 'ip4.forwarding' / 'ip6.forwarding'
(per-interface bool dicts). All default False, so a stack with no
configuration keeps exact host behaviour. Effective forwarding on
an ingress interface is the read-time OR of the master and the
per-interface switch (Linux 'net.ipv4.ip_forward' /
'net.ipv4.conf.<iface>.forwarding' model) — evaluated at the RX
gate rather than eager-stamping every slot on a master write, an
observably identical choice that avoids a runtime write-hook the
framework does not have (marked '# Phase 2:' for the introspection
fidelity gap). The flat 'ip6.all.forwarding' key coexists with the
interface-scope 'ip6.forwarding' because the registry's exact-key
lookup shadows the interface-split.

Add 'stack.forward_next_hop_ip{4,6}(dst)' returning the '(egress
handler, next-hop address)' the transit forward path re-emits
toward — next hop is the matched route's gateway (off-link) or the
destination itself (on-link connected route); egress resolves
through the same '_egress_handler_via_fib' seam socket-originated
traffic uses, so the forwarding and origination planes pick the
same interface for a destination.

Tests-first: 'test__stack__forwarding_sysctl.py' pins knob
registration, defaults, flat-vs-interface-scope, the non-bool
validator, per-interface override scoping, and the flat-master /
interface-split coexistence.

Reference: RFC 1812 §5.2.1 (forward-or-deliver decision).
Reference: RFC 1812 §5.2.4 (next-hop determination).
Reference: Linux net.ipv4.ip_forward / net.ipv6.conf.all.forwarding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Milestone M1 of the Phase-2 router forwarding plane (part 2 of 2):
the IPv4 and IPv6 unicast transit-forward path. With forwarding
enabled ('ip4.ip_forward' / 'ip6.all.forwarding', default off) an
inbound datagram not addressed to the stack is forwarded toward
its next hop out the correct egress interface; every ICMP error a
forwarder must originate on this path is emitted.

Forward path (new 'packet_handler__ip{4,6}__forward.py', dispatched
from '_forward_or_deliver_ip{4,6}' when the destination is not
local): forwarding-enabled gate -> martian/scope filter -> FIB
next-hop lookup -> lifetime check -> egress MTU check -> rebuild.
The datagram is re-emitted by decrementing the TTL / Hop-Limit
byte in a bytearray copy (recomputing the IPv4 header checksum;
IPv6 has none), wrapping it in a RawAssembler + Ethernet frame,
resolving the next-hop MAC from the egress interface's own ARP/ND
cache, and calling the egress handler's '_phtx_ethernet'. The
payload is preserved byte-for-byte (no source selection, no
upper-layer re-assembly); a neighbor-cache miss queues the frame
via 'enqueue_pending' (RFC 1122 §2.3.2.2).

Transit ICMP errors, sourced from the ingress interface's address
toward the original sender (RFC 1812 §4.3.2.5) and gated by the
existing per-version ICMP error rate limiter (§4.3.2.8):
- TTL / Hop-Limit <= 1  -> Time Exceeded (ICMPv4 11/0, ICMPv6 3/0).
- No route              -> Destination Unreachable (ICMPv4 3/0
                           network unreachable, ICMPv6 1/0 no route).
The ICMPv4 / ICMPv6 TX handlers gained the matching dispatch arms
(+ 'icmp{4,6}__time_exceeded__send' /
'icmp{4,6}__destination_unreachable__{network,no_route}__send').

Design decisions (see docs/refactor/router_forwarding_plane.md
§10): the forwarding-disabled drop keeps the host-parity
'ip{4,6}__dst_unknown__drop' counter (§3.1 byte-for-byte host
behaviour won over the proposed 'forward_disabled__drop'), so the
M0 baseline disabled tests stay green unchanged; oversize transit
traffic is dropped in M1 ('ip{4,6}__forward_too_big__drop') pending
the M2 PMTU / fragmentation response; IPv6 never fragments on
forward (RFC 8200 §5) and never forwards a link-local
source/destination off-link ('ip6__forward_scope__drop', RFC 4007).

Tests-first on the M0 'RouterTestCase' 3-interface topology
(test__router__ip{4,6}__forwarding.py): happy-path forward out a
connected LAN and via the default gateway (lifetime decrement,
correct egress + next-hop MAC, byte-identical payload, valid
checksum, ingress isolation); TTL/Hop=1 -> Time Exceeded; no route
-> Destination Unreachable; martian/scope, oversize, and
unresolved-next-hop drops; forwarding-disabled host-parity drop.
Harness helpers ('_enable_forwarding', '_assert_forwarded_ip{4,6}',
'_assert_icmp{4,6}_error', sysctl-reset tearDown) added to
'RouterTestCase'. Existing ICMP-TX "unsupported type/code" tests
repointed to still-unsupported codes; PacketStatsRx/Tx field-count
pins updated (207 / 120); the ip6-rx unit stub gained
'_interface_name' now that the forward path reads it.

RFC 1812 adherence flipped in lockstep (ip4 + icmp4 records):
§4.2.2.5 (checksum recompute), §4.2.2.9 / §5.3.1 (TTL decrement),
§4.3.3.1 (no-route Destination Unreachable), §4.3.3.5 (Time
Exceeded), and §5.2.1/§5.2.4 (unicast forward-or-deliver +
next-hop) are now met (M1); PMTU/frag (M2), Redirect (M3), and
IP-options-on-forward (M4) remain deferred.

Reference: RFC 1812 §5.2.1 (forward-or-deliver decision).
Reference: RFC 1812 §5.3.1 (TTL decrement on forward).
Reference: RFC 1812 §4.3.3.1 (Destination Unreachable, no route).
Reference: RFC 1812 §4.3.3.5 (Time Exceeded on lifetime expiry).
Reference: RFC 8200 §3 (IPv6 Hop-Limit decrement) / §5 (no router fragmentation).
Reference: RFC 4443 §3.1 / §3.3 (ICMPv6 Destination Unreachable / Time Exceeded).
Reference: RFC 4007 §9 (link-local scope not forwarded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Milestone M2 of the Phase-2 router forwarding plane: stop
blackholing oversized transit traffic. Replaces the M1
'ip{4,6}__forward_too_big__drop' placeholder with the RFC 1812
§4.3.3.3 / §5.2.6 behaviour.

IPv4 ('Ip4ForwardHandler.try_forward_ip4') — when a forwarded
datagram exceeds the egress MTU, branch on the DF flag:
- DF=1 -> discard + ICMPv4 Destination Unreachable /
  Fragmentation Needed (Type 3, Code 4) carrying the egress MTU
  in the next-hop-MTU field (transit PMTU; RFC 1191 §3), counted
  in 'ip4__forward_too_big__drop'.
- DF=0 -> '_forward_fragmented_ip4' fragments to the egress MTU
  and forwards each fragment, reusing the origination-path
  'iter_fragment_chunks' + 'Ip4FragAssembler' machinery. Every
  fragment inherits the original DSCP / ECN / Identification
  (preserved so the far end reassembles) / protocol and carries
  the TTL decremented by one; first fragment keeps the full
  options, later fragments only the copy-flag=1 subset (RFC 791
  §3.1). Counted in the new 'ip4__forward_fragmented'.

IPv6 ('Ip6ForwardHandler.try_forward_ip6') — routers never
fragment IPv6 (RFC 8200 §5), so an oversize transit datagram is
discarded and '_emit_packet_too_big' sends ICMPv6 Packet Too Big
(Type 2) carrying the egress MTU (RFC 4443 §3.2 / RFC 8201 §3),
counted in 'ip6__forward_too_big__drop'.

The ICMPv4 / ICMPv6 TX handlers gained the matching dispatch arms
(+ 'icmp4__destination_unreachable__frag_needed__send' /
'icmp6__packet_too_big__send'). Both transit errors reuse the
ingress-source-selection (RFC 1812 §4.3.2.5) + rate-limiter
(§4.3.2.8) path from M1.

Host Unreachable (Code 1) on next-hop resolution hard-failure is
deferred: there is no probe-exhaustion signal from the neighbor
cache to the forward path yet, so an unresolved next hop stays
queued ('ip{4,6}__forward_no_neighbor__drop', RFC 1122 §2.3.2.2
soft queue) exactly as in M1. Tracked as a Phase-2 refinement in
the RFC 1812 adherence records.

Tests on the M0 'RouterTestCase' topology: the M1 oversize
placeholder tests are replaced by DF=1 -> Frag-Needed (egress MTU
asserted), DF=0 -> multiple fragments on the egress interface
(each fits the MTU, preserves src/dst/Identification, carries the
decremented TTL, contiguous offsets + MF flags, reassembles to
the original UDP datagram byte-for-byte), and IPv6 oversize ->
Packet Too Big (egress MTU asserted). '_build_transit_ip4' gained
a 'df' kwarg; new '_assert_forwarded_fragments_ip4' /
'_assert_icmp6_packet_too_big' harness helpers; '_assert_icmp4_error'
gained an optional 'mtu' check. PacketStatsRx/Tx field-count pins
updated (208 / 122).

RFC 1812 adherence flipped in lockstep (ip4 + icmp4 records):
§4.2.2.7 (fragmentation on forward), §4.2.3.3 (router-side PMTUD),
§4.3.3.4 (Fragmentation Needed emission) are now met (M2).
Transit-emission notes added to the RFC 1191 (IPv4) and RFC 8201
(IPv6) PMTU records.

Reference: RFC 1812 §4.3.3.3 (Fragmentation Needed on DF=1 oversize).
Reference: RFC 1812 §5.2.6 (fragmentation on forward).
Reference: RFC 791 §3.2 (IPv4 fragmentation).
Reference: RFC 1191 §3 (next-hop MTU in the ICMP error).
Reference: RFC 8200 §5 (routers never fragment IPv6).
Reference: RFC 4443 §3.2 (ICMPv6 Packet Too Big).
Reference: RFC 8201 §3 (IPv6 transit PMTU).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Add the ICMPv4 Redirect (Type 5) wire codec — the last ICMPv4
message type PyTCP lacked, and the prerequisite for the Phase-2
router ICMP-Redirect generation (M3) and the host RX-accept path.

New 'icmp4__message__redirect.py' (`Icmp4MessageRedirect`,
`Icmp4RedirectCode` with the four RFC 792 codes 0 net / 1 host /
2 tos+net / 3 tos+host) carrying the 4-byte Gateway Internet
Address plus the embedded offending datagram, following the
established ICMPv4 message pattern (frozen dataclass +
`__post_init__` asserts, `_pack_header` / `__buffer__`,
`validate_integrity` / `validate_sanity`, `from_buffer`,
`assemble`). `Icmp4Type.REDIRECT = 5` is added and the ICMPv4
parser dispatches type 5 to the new message; both are exported
from 'net_proto'.

Full net_proto unit-test matrix: constructor + 'from_buffer'
asserts, the assembler operation matrix (len / str / repr / bytes
/ per-field / assemble), the parser operation + sanity (unknown
code rejected) matrix, and the parser integrity-check + boundary
matrix. The two ICMPv4 unknown-type tests are updated for the new
'[0, 3, 5, 8, 11, 12]' known-type set.

Reference: RFC 792 (Redirect Message, Type 5, codes 0-3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Milestone M3 of the Phase-2 router forwarding plane: a router that
forwards a datagram back out the interface it arrived on advises
the source of the better first hop (RFC 1812 §5.2.7.2 / RFC 4861
§8), and a host acts on an inbound ICMPv4 Redirect (RFC 1122
§3.3.1.2).

Knobs (per-interface, default on — Linux parity): 'ip4.send_redirects'
/ 'ip6.send_redirects' gate emission; 'ip4.accept_redirects' gates
RX-accept ('icmp6.accept_redirects' already existed).

IPv4 generation ('Ip4ForwardHandler._maybe_emit_redirect'): on a
hairpin forward (egress == ingress) emit an ICMPv4 Redirect (Type
5, Code 1 host redirect; Linux 'ICMP_REDIR_HOST') advising the
next hop as gateway, sourced from the ingress interface toward the
sender, suppressed for source-routed datagrams. The triggering
datagram is still forwarded ('ip4__forward_redirect').

IPv6 generation ('Ip6ForwardHandler._maybe_emit_redirect'): build
an ND Redirect (RFC 4861 §4.5) with the Target Link-Layer Address
option (when the target MAC is known) and a Redirected Header
option carrying the (8-byte-aligned) embedded datagram. RFC 4861
§4.5 mandates a link-local source, but the origination TX path
('_phtx_ip6') enforces an RFC 4007 §6 source-scope check that
(correctly for general traffic) rejects a link-local source toward
a global on-link destination; the redirect is therefore assembled
as a full Ip6Assembler and emitted via '_phtx_ethernet' with the
sender's MAC as the explicit L2 destination — bypassing the scope
check while keeping the RFC-mandated link-local source
('ip6__forward_redirect').

Host RX-accept ('Icmp4RxHandler.__phrx_icmp4__redirect'): an
inbound ICMPv4 Redirect whose advertised gateway is on-link
installs a per-destination host route ('RouteProtocol.REDIRECT',
the FIB enum value already reserved) toward the better first hop,
replacing any prior redirect route for that destination; ignored
when off-link or 'accept_redirects' is disabled. Counters
'icmp4__redirect' / '__accept' / '__ignore'.

Tests on the M0 'RouterTestCase' topology (a resolvable LAN-B
gateway + an if-2 link-local were added to the fixture): hairpin
forward emits the Redirect + still forwards (v4 + v6);
cross-interface forward emits none; 'send_redirects=0' suppresses;
inbound Redirect installs / ignores the host route. New harness
helpers '_assert_redirect_and_forward_ip4/ip6',
'_assert_iface_packet_stats_rx'. PacketStatsRx/Tx field-count pins
updated (213 / 123).

RFC 1812 §4.3.3.2 flipped to met (M3) in both the ip4 and icmp4
adherence records (emission + RX-accept).

Reference: RFC 1812 §5.2.7.2 (ICMP Redirect on same-interface forward).
Reference: RFC 792 (ICMPv4 Redirect Type 5).
Reference: RFC 4861 §8 / §4.5 (ICMPv6 ND Redirect, link-local source).
Reference: RFC 1122 §3.3.1.2 (host processing of Redirects).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Milestone M4 — the RFC 1812 conformance sweep that closes the IPv4
unicast forwarding plane (M1–M4 complete).

New forward-path filter (§5.3.5.2 / RFC 2644): a router MUST NOT
forward toward the directed broadcast of a directly-connected
subnet by default. The IPv4 forward martian filter now also drops
'stack.is_ip4_broadcast(dst)' (which spans every interface's
broadcast set) alongside the existing loopback / unspecified /
limited-broadcast / link-local / multicast destinations — closing
the smurf-amplification vector.

The remaining §5 requirements were already satisfied and are now
pinned by conformance tests
('test__router__rfc1812_conformance.py' + additions to the ip6
forwarding suite):
- §5.2.1 deliver-before-forward: a locally-destined low-TTL
  datagram is delivered (never forward-path Time Exceeded).
- §5.3.7 source validation: a martian-source datagram (source ==
  a locally-connected directed broadcast) is dropped at the RX
  gate before the forward branch.
- §5.2.4 options preservation: a forwarded datagram carrying IPv4
  header options preserves the option bytes and header length
  byte-for-byte.
- RFC 4007 scope: an IPv6 datagram with a link-local source (as
  well as destination) is dropped by the forward scope filter.

Adherence (both rfc1812 records) flipped: §4.2.2.1 (options
preserved), §5.3.5.2 / §5.3.7 (forward-path filtering), §5.2.4
(options preservation) are met (M4). Deferred with rationale:
active Record-Route / Timestamp option append and LSRR/SSRR
source-route forwarding (a §5.2.4 SHOULD refinement — options are
preserved but not mutated, and source-routed datagrams are dropped
at the RX gate by default), Host Unreachable on next-hop
hard-failure, and the policy-routing / RPF / multipath features.

The IPv4 unicast forwarding plane (M1–M4) is complete for the
3.0.9 cut; the multicast querier / FIB ECMP work (M5) is an
optional post-3.0.9 follow-up.

Reference: RFC 1812 §5.3.5.2 (directed broadcasts not forwarded).
Reference: RFC 2644 (default off for directed-broadcast forwarding).
Reference: RFC 1812 §5.3.7 (martian source/destination filtering).
Reference: RFC 1812 §5.2.4 (IP options on forwarded datagrams).
Reference: RFC 4007 §9 (link-local scope not forwarded off-link).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Decompose the parent forwarding-plane plan's §8 (M5, previously a
scoping sketch) into an eight-milestone tests-first plan at
docs/refactor/multicast_router_querier.md, grounded in the existing
IGMP/MLD host machinery, the FIB/Timer/Subsystem, and the M0
RouterTestCase harness.

Milestones: M5a fills the three deliberate Query-assemble
NotImplementedError stubs (IgmpMessageQuery / Icmp6Mld2MessageQuery /
Icmp6Mld1MessageQuery) + per-interface querier sysctls + querier
harness scaffolding; M5b-M5c the IGMPv3 querier (election, General
Query, RFC 3376 §6.4 router membership table, specific queries, v1/v2
interop); M5d-M5e the MLDv2 querier (IPv6 mirror, replacing the
counter-only __phrx_icmp6__mld2_report); M5f the multicast forwarding
data plane (MFIB + RPF-against-unicast-FIB + last-hop replication, no
PIM/MRT); M5g/M5h the two independent fib.py "# Phase 2:" extensions
(ECMP, policy routing).

Core design call recorded: the querier group-membership table is
SEPARATE state from the host _ip{4,6}_multicast_refs (populated only
from inbound Reports, never our own host joins). Querier gate is a new
per-interface ip{4,6}.mc_forwarding knob (Linux parity, default off).

Cross-linked from router_forwarding_plane.md §8. No code change; M5 is
optional/post-3.0.9 and targets a new PyTCP_3_1_x branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Fill the three deliberate NotImplementedError Query-assemble stubs so
the querier-side emission path has its wire codec. This is the net_proto
prerequisite for the Phase-2 M5 multicast querier
(docs/refactor/multicast_router_querier.md M5a); the querier state
machine that drives emission lands in M5b-M5e.

- IgmpMessageQuery.assemble / __buffer__ / _pack_header / _pack_sources:
  serialise the 8-octet v1/v2 simple form and the 12+4N-octet v3 form
  (Resv|S|QRV, QQIC, source list). Checksum injected by the Igmp base.
- Icmp6Mld2MessageQuery: serialise the 28-octet MLDv2 Query header +
  N x 16-byte source vector.
- Icmp6Mld1MessageQuery: serialise the fixed 24-octet MLDv1 Query form
  (for the older-version-querier interop in M5e).

The dataclasses store raw code bytes (max_resp_code / qqic / mrc), so
assemble packs them directly; the interval->float-code encoder is a
TX-handler concern that lands in M5b with its consumer.

Tests-first: new assembler-operation matrices pin byte-exact wire form,
buffer layout, checksum injection, and the assemble->parse round-trip
for all three codecs (48 tests). The stale MLDv1
"assemble_is_rx_only" NotImplementedError test is removed. RX-only
docstrings flipped; the igmp/mld adherence records updated in lockstep
(the codec now assembles; the querier role stays deferred to M5b+).

Reference: RFC 3376 §4.1 (IGMP Membership Query wire format).
Reference: RFC 3810 §5.1 (MLDv2 Query wire format).
Reference: RFC 2710 §3.1 (MLDv1 Query wire format).

Lint clean. net_proto suite 6118 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
First slice of the Phase-2 M5b IGMPv3 querier
(docs/refactor/multicast_router_querier.md): an interface configured as
a multicast router takes the querier role and emits General Queries.
Querier election and the router-side group-membership table are later
M5b slices; in the single-router test topology emission is fully
testable on its own.

- net_proto: add encode_igmp_float_code (inverse of decode) — the RFC
  3376 §4.1.1 / §4.1.7 Max Resp Code / QQIC float encoder; exported
  from net_proto. Tests-first: linear form, representable round-trip,
  floor + saturate.
- sysctls: igmp.query_response_interval (§8.3), igmp.startup_query_interval
  (§8.6), igmp.startup_query_count (§8.7), and the per-interface
  igmp.mc_forwarding gate (Linux net.ipv4.conf.<if>.mc_forwarding,
  default off — a host is not a querier). Add a public is_bool validator
  to the sysctl framework.
- IgmpTxHandler: refresh_querier / _start_querier / _stop_querier /
  _fire_general_query / _send_general_query_and_rearm /
  _send_igmp_general_query. On activation it sends the Startup Query
  Count burst spaced at the Startup Query Interval, then settles to the
  steady-state Query Interval via a self-re-arming, lock-guarded,
  stop-safe General-Query ticket. New counter igmp__general_query__send
  (PacketStatsTx field count 123 -> 124).
- PacketHandler: refresh_igmp_querier / stop_igmp_querier public surface;
  bring-up (_start) refreshes the querier; shutdown (lifecycle) stops it.
- RouterTestCase: _enable_igmp_querier / _advance_frames /
  _assert_igmp_general_query harness helpers.

Tests-first: 6 integration tests pin the mc_forwarding gate, the startup
burst spacing, the steady-state periodic Query, the advertised
timers/QRV, and querier teardown. Adherence records (RFC 3376 §6, RFC
2236 §3) updated in lockstep — General Query emission met; election +
membership table marked pending.

Reference: RFC 3376 §6 (multicast router / querier behaviour).
Reference: RFC 3376 §8.2 (Query Interval).
Reference: RFC 3376 §8.6 (Startup Query Interval).
Reference: RFC 3376 §8.7 (Startup Query Count).

Lint clean. 262 passing in the router/igmp/sysctl slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Second slice of the Phase-2 M5b IGMPv3 querier: querier election so two
multicast routers on one link do not both query. A router with the
numerically lowest interface address on the link is the Querier (RFC
3376 §6.6.2); a Query from a lower source makes us step down.

- IgmpTxHandler.observe_query: on an inbound Query from a source address
  below our own, step down to Non-Querier — cancel the General-Query
  ticket and (re)arm the Other Querier Present timer, whose interval is
  computed per RFC 3376 §8.5 from the electing Query's advertised
  QRV / QQIC / Max Resp Code (falling back to configured defaults for a
  v1/v2 Query). On timer expiry the router resumes the Querier role and
  its startup General-Query burst. General-Query emission is now gated on
  is_querier as well as active.
- IGMP RX: __phrx_igmp__membership_query calls observe_query before the
  host Report-on-Query scheduling.
- New PacketStatsRx.igmp__querier__election_lost (RX field count
  213 -> 214).
- RouterTestCase: _build_igmp_general_query builds an inbound competing
  Query; _enable_igmp_querier now admits the all-systems multicast MAC so
  the RX path accepts election Queries (the mock harness skips the real
  bring-up that assigns 224.0.0.1).

Tests-first: 3 integration tests pin lower-address step-down (stops
emitting), higher-address ignore (stays Querier), and Other Querier
Present resume. Adherence (RFC 3376 §6.6.2 / §8.5, RFC 2236 §3) updated
in lockstep — election met; the router membership table stays pending.

Reference: RFC 3376 §6.6.2 (querier election — lowest address wins).
Reference: RFC 3376 §8.5 (Other Querier Present Interval).

Lint clean. 195 passing in the igmp/router slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Third and final slice of the Phase-2 M5b IGMPv3 querier: a querier
interface learns downstream multicast reception state from inbound
Membership Reports — the state the M5f multicast forwarder will consume.

- IgmpTxHandler.observe_report + _process_group_record: the RFC 3376
  §6.4 action-on-reception maps each group record (IS_EX / TO_EX /
  IS_IN / TO_IN / ALLOW / BLOCK) to a per-group _QuerierGroupState
  (filter mode + source list), refreshed on every Report and pruned by
  its §8.4 Group Membership Interval group timer (§6.5). INCLUDE{} is a
  leave. The table is populated ONLY from inbound Reports, never from
  this stack's own host joins (which stay in the separate
  _ip4_multicast_refs host table).
- Receive-only admission of the all-IGMPv3-routers group 224.0.0.22 in
  _start_querier (via _assign_ip4_multicast) so the querier receives v3
  Reports; withdrawn in _stop_querier. New IGMP__CONTROL_GROUPS set
  generalises the never-reported all-systems guard to the router
  control groups, so admitting 224.0.0.22 emits no Membership Report.
- Introspection: PacketHandler.igmp_querier_memberships() returns an
  immutable IgmpQuerierMembership snapshot (the /proc/net/igmp router
  read surface).
- IGMP RX: __phrx_igmp__report calls observe_report. New
  PacketStatsRx.igmp__querier__member_report (RX field count 214 -> 215).

PyTCP tracks membership at group granularity; the §6.2.1 per-source
timers and the §6.4 fast-leave Group-and-Source-Specific Query
generation are M5c refinements.

Tests-first: 5 integration tests pin EXCLUDE / INCLUDE learning, the
INCLUDE{} leave, Group-Membership-Interval expiry, and the
host-interface-learns-nothing gate. Adherence (RFC 3376 §6.4/§6.5/§8.4,
RFC 2236 §3) updated in lockstep — membership table met.

Reference: RFC 3376 §6.4 (router action on reception of a Report).
Reference: RFC 3376 §6.5 (group timer expiry).
Reference: RFC 3376 §8.4 (Group Membership Interval).

Lint clean. 200 passing in the igmp/router slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Mirror the Phase-2 IGMPv3 querier (M5b) onto MLDv2/ICMPv6: an interface
with mld.mc_forwarding set takes the MLDv2 querier role — emission,
election, and the router membership table the M5f forwarder will consume.

- Icmp6TxHandler querier state machine (IPv6 analogue of IgmpTxHandler):
  refresh_querier / _start_querier / _stop_querier / _fire_general_query /
  _send_mld_general_query (General Query to ff02::1, Hop Limit 1, via the
  existing HBH Router-Alert carrier); observe_query + _become_non_querier
  + Other Querier Present timer (RFC 3810 §7.6.2 / §9.5 election, honouring
  the electing Query's advertised QRV/QQIC/MRC); observe_report +
  _process_multicast_address_record building a per-group _MldQuerierGroupState
  from inbound Reports (§7.4), pruned by the §9.4 Multicast Address
  Listening Interval group timer. MRC codec (_mld_mrd_to_mrc /
  _mld_mrc_to_mrd_ms, RFC 3810 §5.1.3); QQIC reuses the IGMP float encoder.
- Receive-only admission of ff02::16 in _start_querier; new
  MLD__CONTROL_GROUPS generalises the never-reported all-nodes guard to
  the router control groups so admitting ff02::16 emits no Report.
- sysctls: mld.query_response_interval / startup_query_interval /
  startup_query_count + the per-interface mld.mc_forwarding gate.
- ICMPv6 RX: __phrx_icmp6__mld_query calls observe_query for MLDv2
  Queries; __phrx_icmp6__mld2_report calls observe_report (replacing the
  counter-only Phase-2 marker).
- PacketHandler: refresh_mld_querier / stop_mld_querier /
  mld_querier_memberships (introspection). Bring-up + lifecycle wired.
  New counters icmp6__mld_general_query__send (TX),
  icmp6__mld_query__election_lost + icmp6__mld2_report__querier_learn (RX):
  field counts RX 215->217, TX 124->125.
- RouterTestCase: _enable_mld_querier / _build_mld2_general_query /
  _build_mld2_report / _assert_mld_general_query.

Tests-first: 9 integration tests pin the enable gate, startup burst,
steady-state Query, election (step-down / ignore / resume), and
membership learning + expiry. Adherence (RFC 3810 §5/§7/§8) updated in
lockstep — MLDv2 querier met; MLDv1 querier interop is M5e.

Reference: RFC 3810 §7.4 (router action on reception of a Report).
Reference: RFC 3810 §7.6.2 (querier election).
Reference: RFC 3810 §9.4 (Multicast Address Listening Interval).

Lint clean. 453 passing in the icmp6/router slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
…(M5f)

The M5 data-plane milestone: a transit multicast datagram that passes
the RPF check is replicated out every interface with a downstream
listener, for both IPv4 (IGMP) and IPv6 (MLD). This consumes the
querier membership tables built in M5b / M5d.

- Ip4MulticastForwardHandler / Ip6MulticastForwardHandler
  (packet_handler__ip{4,6}__mforward.py), dispatched from the
  _forward_or_deliver_ip{4,6} seam when the destination is a multicast
  group we have not joined:
  1. mc_forwarding gate on the ingress interface (host parity drop
     otherwise);
  2. scope filter — IPv4 224.0.0.0/24 + TTL<=1; IPv6 scope<=2 + Hop<=1
     (mforward_scope__drop);
  3. RPF check against the unicast FIB (forward_next_hop_ip{4,6}(src) →
     egress ifindex must equal ingress; mforward_rpf__drop);
  4. egress set computed on-demand from each interface's querier
     membership table (no separate MFIB to keep in sync), honouring the
     INCLUDE/EXCLUDE source filter; empty → mforward_no_listeners__drop;
  5. byte-identical re-emit (IPv4 TTL decrement + checksum recompute;
     IPv6 Hop decrement, no checksum) out each egress toward the group's
     Ethernet multicast MAC — one mforward bump per replica.
- Ethernet RX: a multicast-router interface
  (PacketHandler.is_multicast_router = igmp/mld.mc_forwarding) receives
  multicast promiscuously so a transit group's frame is not dropped at
  the MAC filter.
- 8 new PacketStatsRx counters (ip{4,6}__mforward[_rpf/_no_listeners/
  _scope__drop]); RX field count 217 -> 225.
- RouterTestCase: _build_transit_multicast_ip{4,6}.

Tests-first: 8 integration tests (4 per family) pin replication +
TTL/Hop decrement + no-loopback-to-ingress, the no-listener drop, the
RPF failure drop, and link-scoped-group non-forwarding. Adherence
(RFC 1812 §5.2.1 last-hop multicast forwarding) updated in lockstep.
No PIM/DVMRP/MRT_* (CLAUDE.md non-goals); last-hop only, MFIB from
local querier state.

Reference: RFC 1812 §5.2.4 (multicast forwarding — last hop).
Reference: RFC 5771 (224.0.0.0/24 link-local control block).
Reference: RFC 4291 §2.7 (IPv6 multicast scope).

Lint clean. 253 passing in the router/ip4/ip6 slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
The querier "fast leave" for both families: a leave triggers a
Group-Specific (IGMP) / Multicast-Address-Specific (MLD) Query burst
that lowers the group timer, so a group with no re-asserting listener
is pruned in the Last Member/Listener Query Time instead of the full
Group Membership / Listening Interval.

- IgmpTxHandler / Icmp6TxHandler: _start_group_fast_leave +
  _fire_group_fast_leave + _send_igmp_group_specific_query /
  _send_mld_address_query. A leave (CHANGE_TO_INCLUDE{} record, a BLOCK
  that empties an INCLUDE set, or an IGMPv2 Leave Group) lowers the
  group timer to LMQI x LMQC (RFC 3376 §6.4.2 / RFC 3810 §7.6.3), sends
  the first Query to the group address, and arms LMQC-1 more at LMQI
  spacing. A refreshing Report cancels the train and restores the full
  group timer. _QuerierGroupState / _MldQuerierGroupState gain
  fast_leave_handle + fast_leave_remaining; every teardown path cancels
  them.
- IGMP: the querier now admits the all-routers group 224.0.0.2
  receive-only (alongside 224.0.0.22) so it receives IGMPv2 Leaves;
  observe_report dispatches IgmpMessageV2Leave to fast-leave.
- sysctls: igmp.last_member_query_{interval,count} (§8.8/§8.9),
  mld.last_listener_query_{interval,count} (§9.8/§9.9).
- New counters igmp__group_query__send / icmp6__mld_address_query__send;
  TX field count 125 -> 127. The M5b test that asserted immediate prune
  on TO_INCLUDE{} is updated to the fast-leave behaviour.

Tests-first: 9 integration tests (5 IGMP + 4 MLD) pin the Query on
leave, the LMQC burst, fast-prune after the Last Member/Listener Query
Time, re-assertion cancelling the train, and (IGMP) the v2 Leave path.
Adherence (RFC 3376 §6.4.2, RFC 2236 §3, RFC 3810 §7.6.3) updated in
lockstep — fast-leave met. Per-source timers + IGMPv1/v2 & MLDv1
querier-emit interop deferred with rationale (niche for a last-hop
router).

Reference: RFC 3376 §6.4.2 (IGMP fast leave — Group-Specific Query).
Reference: RFC 3810 §7.6.3 (MLD fast leave — Address-Specific Query).
Reference: RFC 2236 §3 (v2 Leave triggers Group-Specific Queries).

Lint clean. 252 passing in the igmp/mld/router slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Populate the 3.0.9 CHANGELOG with the multicast-router work: the
IGMPv3 / MLDv2 querier (General Query emission, election, membership
table, fast-leave), the last-hop multicast forwarding data plane
(RPF + replication, both families), and the querier sysctls (pytcp);
plus the IGMP / MLD Query wire assemblers + float-code encoder that
back the querier's emission (net_proto).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Refresh the IGMPv3 (RFC 3376) and MLDv2 (RFC 3810) adherence records
with a method-level Test coverage audit for the querier, per the
rfc_adherence_audit skill: each met querier requirement now cites the
specific integration test (path::TestClass::test_method) that locks it
in, across General-Query emission (§8.2/§8.6/§8.7), election (§6.6.2 /
§7.6.2 + Other Querier Present §8.5/§9.5), the membership table from
Reports (§6.4/§6.5/§8.4 / §7.4/§9.4), fast-leave Group-/Address-
Specific Queries (§6.4.2/§8.8/§8.9 / §7.6.3/§9.8/§9.9), the float-code
encoder, and last-hop multicast forwarding. Test-coverage and
overall-assessment summary tables extended to match; deferred
refinements (per-source timers, v1/v2 & MLDv1 querier-emit interop)
marked n/a. All test references verified to resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Design doc for a small opt-in test suite that boots the real daemon on
an actual TAP and exercises the OS edges the wire-level integration
tests mock (real /dev/net/tun I/O, the real Tx/Rx rings, real threads +
wall-clock timers). Covers the peer mechanism (AF_PACKET-on-tap, with a
two-tap-bridge fallback), the RealTapTestCase harness (no mocks, real
clock, subprocess daemon, addCleanup teardown), a ~6-test smoke
inventory, root/env gating via a separate make test-realtap target, the
must-verify AF_PACKET-direction risk, and a phased build (spike ->
harness+tests -> optional router/multicast-on-a-real-wire stretch).
Not yet implemented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
…ase 1)

Ship the IPv4 real-TAP smoke suite that boots the actual daemon on a real
tap and drives it over the wire — exercising the OS edges the wire-level
integration tests mock (/dev/net/tun I/O, the real Tx/Rx rings, real
threads + wall-clock timers).

- RealTapTestCase (tests/lib/real_tap_testcase.py): a persistent tap
  (ip tuntap add, like `make tap7`), the real daemon in a subprocess via
  run_daemon(..., on_ready=<ready-file>) with a static IPv4 address, an
  AF_PACKET peer bound to the tap (the test is the wire), real-clock
  bounded _peer_expect / select waits, and addCleanup teardown so a
  mid-setUp failure cannot leak a tap. Deliberately inverts the
  wire-level harness rules (no mocks, real time).
- 5 smoke tests (tests/integration/real_tap/test__real_tap__smoke.py):
  daemon boots + serves the drop-in over IPC; ARP request -> real reply;
  ICMPv4 Echo -> reply; a drop-in UDP send resolves the peer via a real
  ARP exchange then delivers; a full UDP echo round trip through the
  drop-in recv path.
- `make test-realtap` runs it (PYTCP_REAL_TAP=1); it needs root /
  CAP_NET_ADMIN + /dev/net/tun and SKIPS cleanly under the normal
  `make test` (OK (skipped=5)), so it never breaks the default gate.
- docs/refactor/real_tap_smoke_suite.md updated: Phase 0 spike + Phase 1
  build recorded, incl. the deferred IPv6 finding (a static ip6_host on a
  router-less tap does not reach daemon readiness — exactly the class of
  bug this real layer exists to surface; IPv4 suite unaffected).

Validated green over a real tap (root); lint clean; §7.2 audit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
…orget)

A daemon booting with a static IPv6 address on a router-less tap never
reached readiness: 'stack.start()' wedged. A faulthandler all-thread
dump pinpointed a cross-thread lock-ordering deadlock, not slowness.

The DAD worker, assigning the solicited-node group inside
'assign_ip6_multicast', held the interface multicast RLock and emitted
the RFC 3810 §6.1 state-change Report through the BLOCKING TX dispatch
('_marshal_tx' -> 'TxRing.dispatch' -> 'wait'), so it blocked on the TX
worker thread WHILE holding '_lock__multicast'. The TX worker,
dispatching that same Report, re-entered '_lock__multicast' (via
'__validate_src_ip6_address' -> '_ip6_multicast') held by the blocked
DAD worker. Neither released; the boot thread then piled onto the same
lock in '_log_stack_address_info', so 'on_ready' never fired.

All ten '_lock__multicast' critical sections that emit a state-change
Report funnel through the two family send helpers, so route both
through the fire-and-forget '_marshal_tx_async' instead: IGMP/MLD
control messages are best-effort and Linux queues them on send, so the
mutating thread queues the Report and returns without waiting on the
TX worker. The worker later re-acquires the lock once the mutator has
released it. After the fix the same router-less tap boots to readiness
in ~11 s (normal DAD serialization). This is precisely the deadlock
class the wire-level tests cannot see: they mock the TX ring (no worker
thread) and run every dispatch inline on one thread, where the RLock
re-entry is harmless.

Tests-first: the new integration pin asserts each join dispatches its
state-change Report through the fire-and-forget path and never the
blocking one; it failed pre-fix (blocking 'dispatch' used, 1 != 0) and
passes post-fix. Adds the '_marshal_tx_async' method to the ICMPv6 TX
unit-test stub interface.

Reference: RFC 3376 §5.1 (IGMP unsolicited state-change report on join).
Reference: RFC 3810 §6.1 (MLD unsolicited state-change report on join).

Lint clean. 5087 passing, 0 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
With the IGMP/MLD membership-lock TX deadlock fixed (commit d876753),
the daemon boots a static IPv6 address on a router-less tap to readiness
in ~11 s, so the real-TAP smoke suite goes dual-stack.

Harness ('RealTapTestCase'):
- Boot with 'ip6_host=fd00:99::7/64' + 'ip6_support=True' alongside the
  static IPv4; raise '_wait_ready' to 30 s to cover dual-stack DAD
  serialization (link-local -> solicited-node -> static global) + IPv4
  ACD.
- '_build_eth' accepts an 'Ip6Assembler' payload.
- '_ns_to_stack_frame' / '_prime_peer_neighbor6' — a peer Neighbor
  Solicitation carrying the peer's SLLA option, which both elicits the
  stack's NA and populates the stack Neighbor Cache with a STALE entry
  for the peer (RFC 4861 §7.2.3), so a stack-originated IPv6 send
  resolves without a round trip (the IPv6 parallel to gratuitous ARP).

Two IPv6 smoke tests (restoring the §3 inventory's dropped test 3 plus
an ICMPv6 Echo parallel to the IPv4 one):
- ICMPv6 Neighbor Solicitation for the stack's address -> real Neighbor
  Advertisement on the wire.
- ICMPv6 Echo Request -> real Echo Reply, peer neighbor primed first.

Validated on a real tap (root + PYTCP_REAL_TAP=1): all 7 green in ~70 s
(per-test daemon boot dominates). Skips cleanly off the default gate
('OK (skipped=7)'). Lint clean; §7.2 audit clean.

Reference: RFC 4861 §7.2.4 (Neighbor Advertisement in response to a solicitation).
Reference: RFC 4443 §4.2 (ICMPv6 Echo Reply).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Phase 2 of the real-TAP suite: the only end-to-end proof of the 3.0.9
router forwarding plane on a real wire. One real daemon boots as a
router between two persistent taps (two directly-connected subnets,
dual-stack), and one AF_PACKET peer per tap acts as a host on each side.

Harness ('RealTapRouterTestCase'):
- 'run_daemon' cannot express per-NIC static addresses for a
  multi-interface daemon (it autoconfigures every NIC), so the runner
  drives 'stack.add_interface(...)' directly per tap and sets the
  'ip4.ip_forward' / 'ip6.all.forwarding' / 'igmp.default.mc_forwarding'
  / 'mld.default.mc_forwarding' sysctls before 'stack.start()'.
- Egress next-hop resolution is answered on demand
  ('_answer_arp_request' / '_answer_nd_solicitation'), so the router's
  queued transit datagram flushes deterministically — no timing race.
- '_igmp_v3_join_frame' drives a downstream IGMPv3 EXCLUDE{} join so the
  router's querier learns a listener for the multicast test.

Four router tests (all validated on real taps, root + PYTCP_REAL_TAP=1):
- IPv4 unicast transit forwarded, TTL 64 -> 63.
- IPv6 unicast transit forwarded, Hop Limit 64 -> 63.
- IPv4 TTL=1 transit dropped, ICMPv4 Time Exceeded (code 0) returned to
  the source out the ingress interface.
- IPv4 multicast datagram replicated to a learned IGMPv3 listener,
  TTL 10 -> 9.

Auto-included in 'make test-realtap' (the target globs the real_tap
dir); the combined suite is 11 tests, all green in ~147 s, and still
skips cleanly off the default gate ('OK (skipped=11)'). This exercises
the transit path over real /dev/net/tun I/O, real Tx/Rx rings, real
egress neighbor resolution, per-interface FIB connected routes, and the
real multicast-router promiscuous-reception + membership-learning +
replication path across two interfaces on live wall-clock timers —
everything the mocked 'RouterTestCase' cannot.

Reference: RFC 1812 §5.2.1 (forwarding decremented-TTL datagrams between interfaces).
Reference: RFC 1812 §5.2.4 (multicast forwarding to interested interfaces).
Reference: RFC 1812 §5.3.1 (TTL reaching zero in transit yields ICMP Time Exceeded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Add a dedicated 'real-tap' job to the CI workflow that runs the root-
gated end-to-end suite ('make test-realtap') on every PR / push. It
boots the real daemon on real TAP interfaces and drives forwarding /
host traffic over /dev/net/tun — the only coverage of the actual OS
boundary (real Tx/Rx rings, real threads + wall-clock timers, real
multi-interface FIB + neighbor resolution) that the mocked suites
cannot reach.

Kept as its OWN job, deliberately out of the unprivileged 'validate'
gate:
- It needs CAP_NET_ADMIN (create taps, open /dev/net/tun), so the test
  step runs under 'sudo'; 'validate' stays hermetic and root-free.
- It runs real wall-clock timers + subprocess daemons, a higher flake
  surface than the deterministic mocked suite — a transient real-timer
  hiccup must never redden the main gate.

The venv is built as the runner user (so setup-python is on PATH); only
the test step is elevated. By then the 'venv' file prerequisite
('$(VENV)/bin/activate', mtime-guarded — not phony) is already
satisfied, so 'sudo make test-realtap' skips the rebuild and never needs
python on sudo's reduced PATH. A 'modprobe tun' step ensures the device
is present; a 15-minute job timeout backstops a hung daemon.

Whether this job is a *required* check is a branch-protection decision,
left to the maintainer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
…ks make test

The full 'make test' (all three packages, one process) failed in CI with
64 errors — 'AttributeError: module pytcp.stack has no attribute timer' —
from three TCP unit fixtures that patch 'stack.timer' via
'patch("...tcp__session.stack.timer", ...)'. 'stack.timer' is a bare
module annotation ('timer: Timer', no value) created only by
'stack.init()' / 'mock__init()', so it does not exist at import; without
'create=True', 'mock.patch' raises when the attribute is absent. The
fixtures only passed when some earlier test in the run happened to leave
'stack.timer' set — order-dependent, and they fail in isolation (proven:
each of the three errored 32 / 17 / 15 in a fresh process).

Add 'create=True' to the 'stack.timer' patch in the three fixtures,
matching the already-correct sibling
'test__tcp__session__timers.py' which uses exactly this form. Each file
now passes in isolation and in any order.

Verified: full 'make test' is green — 13855 examples, 0 failed, 11
skipped (the root-gated real-TAP suite). Lint clean; no autoformat drift.

Affected fixtures:
- tests/unit/protocols/tcp/fsm/test__tcp__fsm.py (32)
- tests/unit/protocols/tcp/test__tcp__session__lifecycle.py (17)
- tests/unit/protocols/tcp/test__tcp__session__syscalls.py (15)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
…3.0.9 router work

Full docs audit after the Phase-2 router work landed on PyTCP_3_0_9
(M0–M4 unicast forwarding plane + M5a–M5f multicast querier/forwarding
+ the deadlock fix, real-TAP suites, CI job). Every doc that framed this
work as future/deferred is corrected; genuinely-deferred items
(M5g/M5h FIB ECMP/policy, M2 next-hop resolution-failure policy, RAO
router-side interception, LSRR/SSRR, MLDv1 querier-emit interop) are
left as-is.

Router ledgers:
- router_forwarding_plane.md — Status/Target/Follow-up + §0 cut line +
  §8 heading/§8.1 + §9 row: M5a–M5f marked SHIPPED, only M5g/M5h deferred.
- multicast_router_querier.md — was "PLANNED — not started" on a wrong
  PyTCP_3_1_x branch; now M5a–M5f SHIPPED, Query-assembler "stub" claims
  dropped, gap list + cut line + both milestone tables + stat baseline
  (RX=225/TX=127) updated.

CHANGELOGs:
- pytcp: add the whole M1–M4 unicast forwarding plane (Added), plus new
  Fixed (deadlock, stack.timer), Tests (real-TAP 11), Tooling (CI job)
  sections — previously only the M5 multicast work was recorded.
- net_proto: add the M3 ICMPv4 Redirect codec.

RFC adherence records (all "now met" claims verified against code):
- ip4/rfc791 — forward-path TTL decrement + Time Exceeded shipped (M1),
  7 passages corrected from "Phase 2 / not implemented".
- ip4/rfc1812 + icmp4/rfc792 — intro/§5 row/§4.2.2.8 prose/test-summary
  + Redirect row synced to the already-met M1–M4 tables.
- ip4/rfc919, ip4/rfc922 — broadcast forwarding now "met by refusal"
  (forward path drops limited/directed broadcasts, RFC 1812 §5.3.5.2).
- ip4/rfc6398, ip6/rfc2711 — "PyTCP does not forward" rationale updated
  (forwards now; RAO router-side interception is a Phase-2 refinement).
- ip4/rfc2236, ip4/rfc3376, icmp6/rfc3810 — querier role marked met
  (M5b–M5f), removing self-contradictions vs the already-updated tables.

Refactor docs: igmp_refinements, igmp_host_membership, igmp_version_fallback,
mld_version_fallback — router/querier role + MLDv1 Query assemble marked
shipped. real_tap_smoke_suite.md — Kind row "not yet implemented" → shipped.

Docs-only; no code changes. Not in the lint gate (LINT_FILES is Python
source); codespell-clean on the edited files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
Flip the 3.0.9 CHANGELOG headers from "Unreleased" / "Development in
progress" to the release date across all three packages (pytcp,
net_proto, net_addr). net_addr had no functional changes this cycle
and is released in lockstep. Version is already 3.0.9 in every
'__version__'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oxnE4iKGyGVLKd7WmcY8N
@ccie18643
ccie18643 merged commit e24ab3e into master Aug 10, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant