|
| 1 | +# Host-stack refinements backlog (post-3.0.8 optional items) |
| 2 | + |
| 3 | +| Field | Value | |
| 4 | +|------------|-----------------------------------------------------------------------| |
| 5 | +| Status | **IN PROGRESS** — 2 of N shipped. Opened 2026-07-19 on `PyTCP_3_0_8`. | |
| 6 | +| Branch | `PyTCP_3_0_8` | |
| 7 | +| Scope | Optional host-scope refinements deferred out of the 3.0.8 cut. **None are host-conformance gaps** — 3.0.8 is host-feature-complete. These are polish / Linux-parity completeness. | |
| 8 | +| Rule | Every item is **tests-first (red tests before implementation)**, `make lint` clean, adherence + docs in lockstep. See `.claude/rules/feature_implementation.md`. | |
| 9 | + |
| 10 | +This is the working backlog for the "do refinements one by one" track. |
| 11 | +Items are ordered by ascending prerequisite-weight, not value. Pick the |
| 12 | +next unchecked item, write the failing test(s) first, implement the |
| 13 | +minimal change, verify, commit (one concern per commit), hold pushes |
| 14 | +until the user says "push". |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## Shipped (this track) |
| 19 | + |
| 20 | +- [x] **UDP `SO_RCVBUF` enforcement** — commit `d51abda9`. `process_udp_packet` |
| 21 | + drops an inbound datagram whose payload would push the queued receive |
| 22 | + bytes past the cap (Linux `sk_rcvqueues_full`), enforced only when |
| 23 | + `SO_RCVBUF` is set; unset stays unbounded. 3 unit tests in |
| 24 | + `test__runtime__socket__udp__socket.py::TestUdpSocketReceive`. |
| 25 | +- [x] **RAW `SO_RCVBUF` enforcement** — commit `a0140806`. Same guard in |
| 26 | + `process_raw_packet` (`raw__data`). 2 unit tests in |
| 27 | + `test__runtime__socket__raw__socket.py::TestRawSocketRcvbuf`. |
| 28 | + |
| 29 | +**The canonical SO_RCVBUF guard pattern** (mirror for any new datagram socket): |
| 30 | + |
| 31 | +```python |
| 32 | +with self._lock__io: |
| 33 | + if self._closed: |
| 34 | + return |
| 35 | + if self._so_rcvbuf is not None: |
| 36 | + queued = sum(len(md.<data_attr>) for md in self._packet_rx_md) |
| 37 | + if queued + len(packet_rx_md.<data_attr>) > self._so_rcvbuf: |
| 38 | + __debug__ and log("socket", f"...Dropped: SO_RCVBUF {self._so_rcvbuf} exceeded") |
| 39 | + return |
| 40 | + self._packet_rx_md.append(packet_rx_md) |
| 41 | + self._packet_rx_md_ready.release() |
| 42 | +self._signal_readable() |
| 43 | +``` |
| 44 | + |
| 45 | +`_so_rcvbuf` lives on the base `socket` (runtime/socket/__init__.py, set by |
| 46 | +`_sol_socket_setsockopt`). Enforce-only-when-set = zero regression risk. |
| 47 | + |
| 48 | +--- |
| 49 | + |
| 50 | +## Remaining items |
| 51 | + |
| 52 | +### R1 — Ping socket `SO_RCVBUF` (small, has a prerequisite) |
| 53 | + |
| 54 | +- **Why deferred:** `PingSocket.setsockopt` (runtime/socket/ping__socket.py |
| 55 | + ~222) only handles `IP_RECVTTL` / `IPV6_RECVHOPLIMIT` — it does **not** |
| 56 | + route `SO_RCVBUF` to the base `_sol_socket_setsockopt`, so enforcing the |
| 57 | + guard in `process_echo_reply` would be dead code (`_so_rcvbuf` stays |
| 58 | + `None`). |
| 59 | +- **Scope:** (1) add SOL_SOCKET routing to `PingSocket.setsockopt` (mirror |
| 60 | + udp/raw: `if isinstance(value, int) and level == SOL_SOCKET and |
| 61 | + self._sol_socket_setsockopt(optname, value): return`); (2) add the |
| 62 | + SO_RCVBUF guard to `process_echo_reply` (~381, data attr `icmp__data`). |
| 63 | +- **Tests-first:** there is **no** ping-socket unit test today. Create |
| 64 | + `packages/pytcp/pytcp/tests/unit/runtime/socket/test__runtime__socket__ping__socket.py` |
| 65 | + with a lean fixture: patch `pytcp.runtime.socket.ping__socket.log` and |
| 66 | + `...ping__socket.stack.icmp_echo_sockets` to a fresh `dict` (the only two |
| 67 | + module deps — `_allocate_echo_id` reads the dict). Construct |
| 68 | + `PingSocket(family=AddressFamily.INET4, protocol=IpProto.ICMP4)`, |
| 69 | + `addCleanup(s.close)`. `PingMetadata` fields: `ip__ver`, |
| 70 | + `ip__remote_address`, `ip__ttl`, `icmp__data`. Red tests: over-cap drop |
| 71 | + + unset-unbounded (mirror the RAW tests). Also a test that |
| 72 | + `setsockopt(SOL_SOCKET, SO_RCVBUF, n)` now actually sets `_so_rcvbuf`. |
| 73 | +- **Effort:** ~1 hr. **Risk:** low. **Value:** low (ping is one reply per |
| 74 | + request), but closes the SO_RCVBUF symmetry. |
| 75 | + |
| 76 | +### R2 — `setsockopt`-honored + errno-exactness sweep (medium, incremental) |
| 77 | + |
| 78 | +- **Why:** several options are accepted-and-stored but not enforced, and |
| 79 | + some error paths don't match Linux errno exactly. This is the "make every |
| 80 | + setsockopt actually bite" item. |
| 81 | +- **Approach:** audit each `case` in the setsockopt handlers in |
| 82 | + `runtime/socket/__init__.py` (`_sol_socket_setsockopt`, |
| 83 | + `_ipproto_ip_setsockopt`, `_ipproto_ipv6_setsockopt`) plus the per-flavour |
| 84 | + overrides (udp/raw/ping). For each option ask: is it read anywhere? If |
| 85 | + stored-only, either wire it or document why it's inert. Also verify |
| 86 | + `ENOPROTOOPT` / `EINVAL` / `ENOTCONN` are raised where Linux raises them. |
| 87 | +- **Tests-first:** one red test per option-that-should-bite-but-doesn't, |
| 88 | + asserting the behavioural effect (not just the stored value). This is a |
| 89 | + series of small red-tests-first commits — good "one by one" cadence. |
| 90 | +- **Known members of this bucket:** `SO_SNDBUF` (see R3), `SO_SNDTIMEO` |
| 91 | + (see R3), `X3` listen()-on-unbound → `EINVAL` (breaks examples; land as |
| 92 | + an explicit breaking-change commit + update `examples/`). |
| 93 | +- **Effort:** open-ended (do a few per session). **Risk:** low per fix. |
| 94 | + |
| 95 | +### R3 — `SO_SNDBUF` accounting + `SO_SNDTIMEO` (medium-large, coupled) |
| 96 | + |
| 97 | +- **Why deferred:** UDP `send` hands the datagram straight to the shared TX |
| 98 | + ring (`send_udp_packet`), with **no per-socket send buffer**. Linux |
| 99 | + `SO_SNDBUF` bounds `sk_wmem_alloc`; PyTCP has no such accounting. |
| 100 | +- **Scope:** build a per-socket outstanding-send-bytes counter |
| 101 | + (increment on enqueue-to-TX, decrement on TX completion — needs a |
| 102 | + completion signal from the TX ring the socket can observe), bound it by |
| 103 | + `_so_sndbuf`, and on overflow either `EAGAIN` (non-blocking) or |
| 104 | + block up to `SO_SNDTIMEO`. `SO_SNDTIMEO` is only meaningful once this |
| 105 | + exists — do them together. |
| 106 | +- **Tests-first:** red tests for over-SO_SNDBUF → EAGAIN, and blocking → |
| 107 | + timeout after SO_SNDTIMEO. |
| 108 | +- **Effort:** medium-large (the TX-completion signal is the hard part). |
| 109 | + **Risk:** medium (touches the TX path). **Prereq:** understand how the |
| 110 | + TX ring signals completion; there may be no per-datagram completion today. |
| 111 | + |
| 112 | +### R4 — IPv6 per-socket source filters = MLDv2 SSM track (large, highest value) |
| 113 | + |
| 114 | +- **Why:** IPv6 has only any-source `IPV6_JOIN_GROUP` / `IPV6_LEAVE_GROUP` |
| 115 | + (handled at runtime/socket/__init__.py ~1029). IPv4 has the full |
| 116 | + source-specific set. This is the **IPv6 analogue of the shipped IGMPv3 |
| 117 | + SSM feature** — see `docs/refactor/igmp_source_specific_multicast.md` |
| 118 | + (Phases 1–5) as the exact template. |
| 119 | +- **The IPv4 machinery to mirror:** |
| 120 | + - `packages/pytcp/pytcp/lib/ip4_multicast_filter.py` — |
| 121 | + `Ip4MulticastFilter` (INCLUDE/EXCLUDE mode + source set + `merge`). |
| 122 | + - `_ip4_multicast_filters: dict[Ip4Address, Ip4MulticastFilter]` on the |
| 123 | + packet handler (runtime/packet_handler/__init__.py ~324); the merged |
| 124 | + §3.2 reception filter via `_ip4_multicast_filter_for`. |
| 125 | + - setsockopt opts at runtime/socket/__init__.py ~783–957: |
| 126 | + `IP_ADD_SOURCE_MEMBERSHIP` / `IP_DROP_SOURCE_MEMBERSHIP` / |
| 127 | + `IP_BLOCK_SOURCE` / `IP_UNBLOCK_SOURCE`. |
| 128 | + - IGMPv3 state-change source records (ALLOW_NEW_SOURCES / |
| 129 | + BLOCK_OLD_SOURCES / the CHANGE_TO_* forms) in the IGMP TX handler. |
| 130 | + - RX source-delivery filter `ip_mc_sf_allow` (`Ip4MulticastFilter.allows`) |
| 131 | + gating per-socket delivery for **UDP and RAW** (RAW needed the |
| 132 | + `RawMetadata.socket_ids` wildcard-combo enumeration — mirror for v6). |
| 133 | +- **The IPv6 side to build (Phases mirroring the IGMP track):** |
| 134 | + - P1: `packages/pytcp/pytcp/lib/ip6_multicast_filter.py` (`Ip6MulticastFilter`, |
| 135 | + a straight `Ip6Address` copy of the v4 value type). Unit tests. |
| 136 | + - P2: new opts `IPV6_ADD_SOURCE_MEMBERSHIP` / `IPV6_DROP_SOURCE_MEMBERSHIP` |
| 137 | + (and/or the protocol-independent `MCAST_JOIN_SOURCE_GROUP` family) as |
| 138 | + `IpV6Option` enum members + bare aliases (see `.claude/rules/enums.md` |
| 139 | + §2.2). Wire the setsockopt cases. |
| 140 | + - P3: `_ip6_multicast_filters` dict on the handler + `_ip6_multicast_filter_for`. |
| 141 | + - P4: MLDv2 source records on TX — `Icmp6Mld2MulticastAddressRecordType` |
| 142 | + already has `ALLOW_NEW_SOURCES` (5) / `BLOCK_OLD_SOURCES` (6) / |
| 143 | + `CHANGE_TO_INCLUDE` (3) / `CHANGE_TO_EXCLUDE` (4). Emit the state-change |
| 144 | + source records on filter transitions (mirror `_send_igmp_state_change`; |
| 145 | + the MLDv2 leave path added in `de3d3213` is the sibling to extend). |
| 146 | + - P5: RX source-delivery filter for IPv6 UDP + RAW (`Ip6MulticastFilter.allows`). |
| 147 | + - Adherence: update `docs/rfc/icmp6/rfc3810__mld2/adherence.md` (§4.2.12 / |
| 148 | + §5.1 / §5.2 source records) + a socket-parity note, in lockstep. |
| 149 | +- **Effort:** large (multi-phase, mirrors a whole shipped track). **Risk:** |
| 150 | + medium. **Value:** highest — real v4/v6 parity. |
| 151 | + |
| 152 | +### R5 — CLI polish: `pytcp address -j` JSON output (small, self-contained) |
| 153 | + |
| 154 | +- **Scope:** add `-j`/`--json` to the `pytcp address` subcommand (and |
| 155 | + optionally `ss`/`route`/`neighbor` for parity), emitting machine-readable |
| 156 | + JSON alongside the human table. CLI lives under `packages/pytcp/pytcp/cli/`. |
| 157 | +- **Tests-first:** unit tests under |
| 158 | + `packages/pytcp/pytcp/tests/unit/cli/` asserting the JSON shape. |
| 159 | +- **Effort:** ~1–2 hrs. **Risk:** low. **Value:** low-ish (nice for scripting). |
| 160 | + |
| 161 | +### R6 — HyStart++ (RFC 9406) remaining work |
| 162 | + |
| 163 | +- **Scope:** the deferred pieces noted in the rfc9406 adherence record |
| 164 | + (~6–8 hrs estimated). Read `docs/rfc/tcp/rfc9406*/adherence.md` for the |
| 165 | + exact deferred rows before starting. |
| 166 | +- **Effort:** medium. **Risk:** medium (CC path). **Value:** medium. |
| 167 | + |
| 168 | +### R7 — DF-guarded TCP PLPMTUD probe (small, closes a "Phase 3c-min" residual) |
| 169 | + |
| 170 | +- **Why:** the TCP probe-emit path (`session/tcp__session__tx.py`) ships |
| 171 | + probe-**sized** segments but does not set DF, so RFC 8899 §3 #2 (DF=1 on |
| 172 | + the probe) is still "Phase 3c" (honest note left in |
| 173 | + `docs/rfc/tcp/rfc8899__dplpmtud/adherence.md` and rfc4821). Set DF on the |
| 174 | + emitted probe segment so a black-hole is detected by loss rather than |
| 175 | + relying only on ack-feedback sizing. |
| 176 | +- **Tests-first:** extend |
| 177 | + `test__tcp__session__plpmtud_probe_emit.py` to assert the probe segment |
| 178 | + carries DF=1 (IPv4) / is size-capped without fragmentation (IPv6). |
| 179 | +- **Effort:** small–medium. **Risk:** medium (probe-loss interaction with |
| 180 | + RTO). **Value:** completes RFC 8899 §3 #2 / §4.1 fully. |
| 181 | + |
| 182 | +### R8 — `IP_RECVERR` / `IPV6_RECVERR` error queue over the daemon boundary (medium) |
| 183 | + |
| 184 | +- **Why:** the per-socket ICMP error queue + `recvmsg(MSG_ERRQUEUE)` works |
| 185 | + **in-process**, but the daemon data bridge does not pump the error queue |
| 186 | + across the AF_UNIX boundary, so a daemon-backed drop-in client cannot read |
| 187 | + ICMP errors via `MSG_ERRQUEUE`. Source: `kernel_userspace_separation.md` |
| 188 | + deferred list. |
| 189 | +- **Scope:** extend the daemon IPC protocol so an `MSG_ERRQUEUE` `recvmsg` |
| 190 | + is serviced across the boundary (the in-process path already builds the |
| 191 | + `sock_extended_err` cmsg via `runtime/socket/error_queue.py`). Wire the |
| 192 | + error-queue drain into the daemon's per-socket bridge. |
| 193 | +- **Tests-first:** integration test under `tests/integration/ipc/` driving |
| 194 | + an ICMP error to a daemon-backed UDP socket and asserting the client's |
| 195 | + `recvmsg(MSG_ERRQUEUE)` returns the `IP_RECVERR` cmsg. |
| 196 | +- **Effort:** medium. **Risk:** medium (IPC protocol surface). **Value:** |
| 197 | + medium (completes the drop-in's error-reporting parity). |
| 198 | + |
| 199 | +### R9 — Selectable / cancelable `accept` over the daemon (medium) |
| 200 | + |
| 201 | +- **Why:** a client disconnecting mid-`accept` leaves the daemon dispatch |
| 202 | + thread polling until server stop (Phase-4 daemon limitation noted in |
| 203 | + `kernel_userspace_separation.md`). |
| 204 | +- **Scope:** make the daemon-side accept wait cancelable (wake on client |
| 205 | + disconnect / a cancellation signal) so the dispatch thread doesn't spin. |
| 206 | +- **Tests-first:** integration test — connect a client, issue accept, drop |
| 207 | + the client, assert the dispatch thread returns/cleans up promptly. |
| 208 | +- **Effort:** medium. **Risk:** medium (threading/lifecycle). **Value:** |
| 209 | + medium (daemon robustness). |
| 210 | + |
| 211 | +### R10 — MLDv1 Report suppression + `mld.version` force knob (small) |
| 212 | + |
| 213 | +- **Why:** two deferred-with-rationale items from `mld_version_fallback.md`: |
| 214 | + RFC 2710 §4 Report suppression (a host that hears another host's Report for |
| 215 | + a group suppresses its own — an optimization, not done for MLDv2 either), |
| 216 | + and an `mld.version` force sysctl (Linux extension, the IPv6 analogue of |
| 217 | + the shipped `igmp.version` knob). |
| 218 | +- **Scope:** (a) suppression: on RX of a peer MLDv1 Report for a group in |
| 219 | + v1 compat mode, cancel this host's pending Report for that group; (b) the |
| 220 | + knob: add `mld.version` via the `sysctl_knob` skill workflow, consumed by |
| 221 | + `_mld_host_compatibility_mode` (mirror `igmp.version` / |
| 222 | + `IGMP__FORCE_VERSION`). |
| 223 | +- **Tests-first:** integration tests in `tests/integration/protocols/icmp6/`. |
| 224 | +- **Effort:** small each. **Risk:** low. **Value:** low (optimization + |
| 225 | + operator knob). The knob has clear parity value; suppression is marginal. |
| 226 | + |
| 227 | +### R11 — RFC 6724 `ip.policy_table` sysctl override (small-medium) |
| 228 | + |
| 229 | +- **Why:** the RFC 6724 §2.1 policy table (source/dest address selection |
| 230 | + precedence/label) is hard-coded; Linux exposes it as a writable table |
| 231 | + (`/proc/sys/net/ipv6/...` / `ip addrlabel`). Source: |
| 232 | + `rfc6724_source_selection.md` non-blocking arc extension. |
| 233 | +- **Scope:** make the policy table operator-overridable (a sysctl entry or a |
| 234 | + small dedicated API), re-resolved live per the qualified-module-access |
| 235 | + pattern. |
| 236 | +- **Tests-first:** unit tests asserting source-selection changes when the |
| 237 | + policy table is overridden. |
| 238 | +- **Effort:** small-medium. **Risk:** low. **Value:** low-medium (rarely |
| 239 | + tuned on a host). |
| 240 | + |
| 241 | +### Ongoing hygiene (not a discrete scheduled item) |
| 242 | + |
| 243 | +- **On-touch enum migrations** — bare `FOO: int = N` constants that represent |
| 244 | + one-of-a-set should become enum members on touch (see `.claude/rules/enums.md` |
| 245 | + §5). Not a dedicated commit; fix opportunistically when editing a file. |
| 246 | + |
| 247 | +--- |
| 248 | + |
| 249 | +## Out of scope for this backlog (deliberately excluded) |
| 250 | + |
| 251 | +These are **not** host-scope refinements and are tracked elsewhere; listed |
| 252 | +so this backlog's boundary is explicit. |
| 253 | + |
| 254 | +- **Consumer-blocked** (need a DNS resolver / DDNS / HTTP agent PyTCP does |
| 255 | + not have): RFC 4702 Client FQDN, RFC 3203 FORCERENEW, RFC 8910 Captive |
| 256 | + Portal (DHCPv4 Phase 9); RFC 6724 §6 destination-address selection; |
| 257 | + the RDNSS / DNSSL runtime consumer (wire codec is parse-ready). |
| 258 | +- **Deliberate won't-do (scope decisions):** DCTCP (RFC 8257), L4S |
| 259 | + (RFC 9331 / 8311), TCP-AO (RFC 5925), Eifel (RFC 4015), CWV (RFC 7661), |
| 260 | + F-RTO (RFC 5682); `dup` / `dup2`, `socketpair`, hostname-in-`bind`/ |
| 261 | + `connect`; RFC 4884 extended ICMP. |
| 262 | +- **Phase-2 router track** (its own future major version): IP forwarding |
| 263 | + data path, ICMP Redirect emit, forward-path TTL-decrement + Time |
| 264 | + Exceeded, IGMPv3 / MLDv2 querier role, Proxy ARP, RH0 disable-knob, |
| 265 | + Router-Alert interception, PMTU-on-transit, RFC 1812 requirements, Link |
| 266 | + API `up()` / `down()` (needs multi-interface). |
| 267 | + |
| 268 | +--- |
| 269 | + |
| 270 | +## Recommended ordering |
| 271 | + |
| 272 | +1. **R1** (ping SO_RCVBUF) — finishes the SO_RCVBUF symmetry cleanly. |
| 273 | +2. Small self-contained wins, any order: **R5** (CLI JSON), **R10** (mld |
| 274 | + knob + suppression), **R11** (RFC 6724 policy table), or dip into **R2** |
| 275 | + (setsockopt sweep). |
| 276 | +3. **R4** (IPv6 SSM) — the big-value track; do it as its own phased effort. |
| 277 | +4. Medium items as appetite allows: **R3** (SO_SNDBUF/SNDTIMEO), **R6** |
| 278 | + (HyStart++), **R7** (DF-guarded probe), **R8** (IP_RECVERR over daemon), |
| 279 | + **R9** (cancelable accept). |
| 280 | + |
| 281 | +None block a 3.0.8 release. Everything here can equally slip to 3.0.9. |
| 282 | + |
| 283 | +--- |
| 284 | + |
| 285 | +## Git state at handoff (2026-07-19) |
| 286 | + |
| 287 | +- Branch `PyTCP_3_0_8`. Pushed through `af2719ea` (MLDv2 leave + doc |
| 288 | + reconciliations). |
| 289 | +- **Unpushed:** `d51abda9` (UDP SO_RCVBUF) + `a0140806` (RAW SO_RCVBUF). |
| 290 | + Hold until the user says "push". |
0 commit comments