diff --git a/ai/tpt-backends/01_tipc_backend.md b/ai/tpt-backends/01_tipc_backend.md new file mode 100644 index 00000000..8a0fa413 --- /dev/null +++ b/ai/tpt-backends/01_tipc_backend.md @@ -0,0 +1,701 @@ +# Plan 01 — `TIPC` transport backend (`tractor/ipc/_tipc.py`) + +Tracks gh [#378]. Prereq reading: +[`00_shared_backend_contract.md`](./00_shared_backend_contract.md). + +**Thesis**: TIPC is the *cheapest* new backend we can add and +simultaneously the only one that gives us cluster-wide service +discovery **for free, in the kernel**, replacing (for +TIPC-capable deployments) the whole `tractor.discovery` +registrar round-trip with a `bind()`/`connect()` on a +*service name*. It is stdlib-only: zero new dependencies. + +[#378]: https://github.com/goodboy/tractor/issues/378 + +--- + +## 1. Why this is small: three verified facts + +1. **CPython already speaks TIPC.** `socket.AF_TIPC` plus 23 + `TIPC_*` constants are present in the stdlib on Linux + (verified on the dev box, py3.13): + `AF_TIPC, SOL_TIPC, TIPC_ADDR_ID, TIPC_ADDR_NAME, + TIPC_ADDR_NAMESEQ, TIPC_CFG_SRV, TIPC_CLUSTER_SCOPE, + TIPC_CONN_TIMEOUT, TIPC_{CRITICAL,HIGH,MEDIUM,LOW}_IMPORTANCE, + TIPC_DEST_DROPPABLE, TIPC_IMPORTANCE, TIPC_NODE_SCOPE, + TIPC_PUBLISHED, TIPC_SRC_DROPPABLE, TIPC_SUBSCR_TIMEOUT, + TIPC_SUB_CANCEL, TIPC_SUB_PORTS, TIPC_SUB_SERVICE, + TIPC_TOP_SRV, TIPC_WAIT_FOREVER, TIPC_WITHDRAWN, + TIPC_ZONE_SCOPE`. + `sock.bind()/connect()/getsockname()` take/return the + 5-tuple `(addr_type, v1, v2, v3, scope)` — the last element + is optional on input and defaults to `0`. +2. **`trio` doesn't care about the address family.** Per + contract §1.5, `trio.SocketStream` and `trio.SocketListener` + only require a trio socket object of type `SOCK_STREAM`. + TIPC's `SOCK_STREAM` is a real connection-oriented reliable + byte stream. So we reuse `trio.SocketStream`, + `trio.SocketListener`, `trio.serve_listeners()`, + `MsgpackTransport`'s framing — *all of it*. +3. **It is not available by default.** On this box + `socket.socket(AF_TIPC, SOCK_STREAM)` → + `OSError(97, 'Address family not supported by protocol')` + with no `tipc` in `/proc/modules`. `modprobe tipc` is + required; cross-node needs a bearer + (`tipc bearer enable media eth device ` or + `media udp name localip `). Everything about this + plan's testability hinges on gating (§7). + +Non-goals: `SOCK_RDM`/`SOCK_DGRAM`/`SOCK_SEQPACKET` message +modes, multicast fan-out, and TIPC group messaging. They are +genuinely interesting for a future `tractor` broadcast/pubsub +transport but they do **not** fit `MsgTransport`'s +stream-of-length-prefixed-msgs shape. Note them in the +follow-up issue, do not build them here. + +--- + +## 2. `TIPCAddress` + +### 2.1 the three TIPC address flavours, and which we use + +| flavour | tuple | meaning | +| --- | --- | --- | +| `TIPC_ADDR_NAMESEQ` | `(type, lower, upper, scope)` | a *published range* — what a server `bind()`s | +| `TIPC_ADDR_NAME` | `(type, instance, domain, scope)` | a *lookup* — what a client `connect()`s | +| `TIPC_ADDR_ID` | `(node, ref, 0, scope)` | a concrete port id — the "physical" address | + +The design decision that makes this backend coherent: + +> **A `tractor` actor's TIPC address is a *service name* +> `(type, instance)`; `bind()` publishes the singleton range +> `(type, instance, instance)`; peers `connect()` by name and +> the kernel resolves + load-balances. `TIPC_ADDR_ID` is only +> ever an *observed* address (`getpeername()`), never a +> user-facing one.** + +This is exactly the "leverage the built-in discovery machinery" +ask in #378: publishing a bind *is* registration, and +`connect()` on a name *is* a lookup, with no registrar actor in +the loop. + +### 2.2 the struct + +```python +class TIPCAddress( + msgspec.Struct, + frozen=True, +): + _stype: int # TIPC "type" == service class + _instance: int # service instance within the type + _scope: int = TIPC_CLUSTER_SCOPE + # observed-only, never part of identity/equality-by-intent + maybe_node: int|None = None # from TIPC_ADDR_ID getpeername() + maybe_ref: int|None = None + + proto_key: ClassVar[str] = 'tipc' + unwrapped_type: ClassVar[type] = tuple[str, int] + def_bindspace: ClassVar[int] = TIPC_CLUSTER_SCOPE +``` + +**Unwrapped form** (the wire/`SpawnSpec` shape) — must be +uniquely matchable in `wrap_address()`: + +```python +def unwrap(self) -> tuple[str, int]: + # ('tipc::', instance) + return (f'tipc:{self._stype}:{self._scope}', self._instance) +``` + +i.e. `(str, int)` — *the same coarse shape as `TCPAddress`*, so +`wrap_address()`'s `case (str(), int())` would steal it. Two +options; **pick (a)**: + +- **(a) self-tagging prefix + a `case` ordered before the TCP + one**, guarded on `addr[0].startswith('tipc:')`: + ```python + case (str() as h, int()) if h.startswith('tipc:'): + cls = TIPCAddress + ``` + Cheap, string-y, but honest about the fact that + `UnwrappedAddress` is a deliberately-degenerate + `tuple[str, int|str]` (see the `_addr.py:43-65` TODO block). + It also keeps `TCPAddress.__post_init__`'s + `ipaddress.ip_address()` validation from being reached with a + non-IP host. +- (b) widen `UnwrappedAddress` to a 3-tuple for tipc. Rejected: + ripples into every `reg_addr` fixture, `_root_mailbox`, and + downstream (`piker`) config. + +Note `TIPCAddress` is the first backend where `.unwrap()` is +**not** a lossless view of the live socket — `maybe_node`/ +`maybe_ref` are observed metadata, exactly like +`UDSAddress.maybe_pid` (which is likewise excluded from +`.unwrap()`). Follow that precedent, including its `__repr__` +treatment (`_uds.py:242`). + +### 2.3 how to pick `_stype` and `_instance` + +- `_stype` = a `tractor`-reserved service class. TIPC reserves + 0..63 for internal use (`TIPC_TOP_SRV == 1`, + `TIPC_CFG_SRV == 0`). Use a module constant + `TRACTOR_STYPE: int = 0x74_72_00_00` ("tr\0\0") as the default + and make it overridable via `TIPCAddress._stype` so an app + can partition service classes. Document that two `tractor` + trees sharing a cluster **and** a `_stype` share a namespace. +- `_instance` for `get_root()`: `1616` — mirrors the + `TCPAddress.get_root()` port and the `registry@1616.sock` + UDS filename, so the "1616 is tractor's registrar" idiom + holds across all backends. +- `_instance` for `get_random()`: TIPC gives us no + kernel-assigned-instance analogue of `port=0`, so we must + choose. Use a *pure* fn of the actor identity so it is + reproducible and collision-free: + ```python + # 32-bit instance derived from the actor's uuid4 (+ pid when + # there's no live runtime, per the UDS precedent). + inst: int = int.from_bytes( + blake2b(seed.encode(), digest_size=4).digest(), + 'big', + ) + ``` + where `seed = f'{actor.aid.name}@{pid}'` if + `current_actor(err_on_no_runtime=False)` else + `f'{prefix}.{uuid4().hex[:8]}@{pid}'`. Must avoid the reserved + low range: `inst = 64 + (inst % (2**32 - 64))`. + ⚠️ *unlike* `port=0`, a collision here surfaces as a + successful-but-shared publication (TIPC allows multiple + binders on the same name and round-robins!) rather than + `EADDRINUSE`. That is a silent-crosstalk failure mode; §7 has + the test that proves the 4-byte digest is enough and §9 has + the mitigation if it isn't. +- `_scope`: `TIPC_NODE_SCOPE` for a same-host-only actor (the + UDS-equivalent), `TIPC_CLUSTER_SCOPE` (default) for + cluster-visible. **This is `.bindspace`**: + ```python + @property + def bindspace(self) -> int: + return self._scope + ``` + It is the honest analogue of "the set of hosts this bind is + reachable from", which is precisely the docstring in + `Address.bindspace`. (`TIPC_ZONE_SCOPE` is deprecated/aliased + to cluster in modern kernels — accept it on input, normalize + to cluster, log at `transport` level.) + +### 2.4 `is_valid` + +```python +@property +def is_valid(self) -> bool: + return ( + self._instance != 0 + and + self._stype not in _tipc_reserved_stypes # {0, 1, ...} + and + self._scope in (TIPC_NODE_SCOPE, TIPC_CLUSTER_SCOPE) + ) +``` + +--- + +## 3. Listener + stream + +### 3.1 `start_listener()` + +```python +async def start_listener( + addr: TIPCAddress, + backlog: int = 128, + **kwargs, +) -> SocketListener: + sock = trio.socket.socket( + socket.AF_TIPC, + socket.SOCK_STREAM, + ) + # publish the singleton name-range == "register the service" + await sock.bind(( + socket.TIPC_ADDR_NAMESEQ, + addr._stype, + addr._instance, + addr._instance, + addr._scope, + )) + sock.listen(backlog) + return SocketListener(sock) +``` + +Notes / hazards: + +- `bind()` on `AF_TIPC` is **not** a filesystem or port-table + operation and can't block on DNS, but keep it `await`ed + through `trio.socket` anyway for uniformity. +- `backlog=128` matching `_uds.start_listener()`'s hard-won + value (see its comment at `_uds.py:317-331` re: concurrent + deregistration storms). Do not use `1`. +- **no `close_listener()` needed** — nothing to unlink. Omit the + function entirely (contract §1.2: absence means implicit). + Withdrawal of the published name happens on socket close. +- ⚠️ `SocketListener.__init__` will try + `getsockopt(SOL_SOCKET, SO_ACCEPTCONN)`. If TIPC rejects it, + trio's `except OSError: pass` covers us. Assert this in a + unit test rather than assuming. +- Wrap the bind in a `_reraise_as_connerr()`-style `@cm` (copy + the `_uds.py:256` pattern) so `EADDRINUSE`-ish and + `EAFNOSUPPORT` become `ConnectionError` with the addr in the + message. `EAFNOSUPPORT` here means "kernel module not + loaded" and deserves a *specifically actionable* message: + `'TIPC unavailable — try `sudo modprobe tipc`\n'`. + +### 3.2 the `getsockname()` reconciliation + +`Endpoint.start_listener()` does +`if lstnr.socket.getsockname() != self.addr.unwrap(): self.addr = +self.addr.from_addr(unwrapped)`. + +For TIPC, `getsockname()` on a bound-but-listening socket +returns a `TIPC_ADDR_ID`-flavoured 5-tuple (the port id), *not* +the name-seq we bound. So the `!=` is **always true** and +`from_addr()` will be handed a 5-tuple. + +Handle it inside `TIPCAddress.from_addr()` — do **not** patch +`_server.py`: + +```python +@classmethod +def from_addr(cls, addr) -> TIPCAddress: + match addr: + # our own unwrapped form + case (str() as tag, int() as inst) if tag.startswith('tipc:'): + _, stype, scope = tag.split(':') + return TIPCAddress(int(stype), inst, int(scope)) + + # a kernel-observed TIPC_ADDR_ID 5-tuple: keep the + # *service* identity we already know and only annotate + # the observed port-id. + case (int() as atype, *rest) if atype == socket.TIPC_ADDR_ID: + ... +``` + +The `TIPC_ADDR_ID` case cannot reconstruct `(stype, instance)` +— that info isn't in a port id. So `from_addr()` alone is +insufficient for the reconciliation path. **Resolution**: make +`from_addr()` raise a clear `ValueError` for the bare +`TIPC_ADDR_ID` case, and instead prevent the reconciliation +from firing by having `start_listener()` return a listener +whose `getsockname()` we never need — i.e. land this two-line +upstream fix in `_server.py:664`: + +```python +if ( + (unwrapped := lstnr.socket.getsockname()) != self.addr.unwrap() + and + self.addr.rebind_from_sockname # ClassVar[bool] = True on tcp/uds +): +``` + +with `TIPCAddress.rebind_from_sockname: ClassVar[bool] = False` +(and `True` on `TCPAddress`/`UDSAddress`, preserving today's +behaviour exactly). Rationale: the reconciliation exists *only* +to learn the kernel-assigned port for `port=0` TCP binds (its +own comment says so, `_server.py:662`); TIPC has no such +late-binding, so opting out is semantically right rather than a +hack. **Land this as its own commit, ahead of the backend**, +with a test that `tcp`'s `port=0` behaviour is unchanged. + +Keep the observed port-id available anyway: annotate +`ep.addr = ep.addr.with_port_id(*getsockname()[1:3])` (a pure +`msgspec.structs.replace()` helper) purely for logging/repr. + +### 3.3 `MsgpackTIPCStream` + +```python +class MsgpackTIPCStream(MsgpackTransport): + address_type = TIPCAddress + layer_key: int = 4 + + @property + def maddr(self) -> Multiaddr|str: + return mk_maddr(self.raddr) + + def connected(self) -> bool: + return self.stream.socket.fileno() != -1 + + @classmethod + async def connect_to( + cls, + destaddr: TIPCAddress, + prefix_size: int = 4, + codec: MsgCodec|None = None, + **kwargs, + ) -> MsgpackTIPCStream: + sock = trio.socket.socket(AF_TIPC, SOCK_STREAM) + with close_on_error(sock): + # NOTE: connect by *name* -> kernel does the lookup, + # so this is our "discovery" call. + await sock.connect(( + socket.TIPC_ADDR_NAME, + destaddr._stype, + destaddr._instance, + 0, # domain: 0 == "anywhere in scope" + destaddr._scope, + )) + return cls( + trio.SocketStream(sock), + prefix_size=prefix_size, + codec=codec, + ) +``` + +- reuse `trio._highlevel_open_unix_stream.close_on_error` (the + UDS backend already imports it) or inline the equivalent + `try/except: sock.close(); raise`. +- `SO_/TIPC_` opts worth setting and documenting: + - `setsockopt(SOL_TIPC, TIPC_IMPORTANCE, TIPC_HIGH_IMPORTANCE)` + for the *parent<->child* lifetime channel — this is a real + win TIPC gives us that TCP can't: the runtime's + supervision channel can outrank bulk app traffic under + congestion. Wire it as a `connect_to(..., importance=...)` + kwarg defaulted from a module constant, and have + `_runtime.py`'s parent-chan path pass the high value **in a + follow-up** (don't couple it to this PR). + - `TIPC_CONN_TIMEOUT` — the kernel-side connect timeout; + leave at default, we have `trio` cancel scopes. + - `TIPC_DEST_DROPPABLE = 0` on the connection so undeliverable + msgs come back as errors rather than being silently dropped. +- **`connect_to()` on a name with no publisher**: TIPC returns + `ECONNREFUSED`/`EHOSTUNREACH` promptly (no SYN-timeout wait), + which is *better* discovery-ping behaviour than TCP. Confirm + the errno and make sure it surfaces as `ConnectionError` + (contract §4 — the registrar ping path depends on it). + +### 3.4 `get_stream_addrs()` + +```python +@classmethod +def get_stream_addrs(cls, stream) -> tuple[TIPCAddress, TIPCAddress]: + sock = stream.socket + # both return TIPC_ADDR_ID 5-tuples for a connected sock + l_id = sock.getsockname() + r_id = sock.getpeername() + ... +``` + +Problem: neither end's port-id tells us the *service name*. The +`laddr`/`raddr` are used for logging, `Channel.raddr`, +`Server._peers` keying-adjacent repr, and `maddr`. Design: + +- the **connecting** side knows the destaddr it dialled → + `connect_to()` overrides `_raddr` after construction with the + known-good `TIPCAddress`, exactly as + `MsgpackUDSStream.connect_to()` does for the peer-pid case + (`_uds.py:539-543`). +- the **accepting** side does not know the peer's service name + from the socket. Two honest options: + - **(a) accept it: `raddr` carries only `(node, ref)`** via + `maybe_node`/`maybe_ref`, `_stype/_instance` set to a + sentinel `-1`, and `__repr__` renders + `TIPCAddress[:]`. The `Aid` from the + handshake already gives us the peer's logical identity, so + nothing in the runtime actually *needs* the peer's service + name. **Recommended.** + - (b) piggyback the peer's own bound name in the handshake. + Rejected for this PR: touches `Aid`/msg-spec. +- `laddr` on the accepting side: the `Endpoint` knows its own + `addr`; but `get_stream_addrs()` is a `@classmethod` with only + the stream. Use `TIPC_ADDR_ID` for `laddr` too and let + `Endpoint.peer_tpts` keying (which is by *peer* addr) still + work. Verify nothing asserts `laddr == ep.addr` — grep for + `.laddr` uses before committing (`_server.py`'s + `con_status` logging, `Channel.pformat()`). + +--- + +## 4. Multiaddr representation + +There is no `/tipc` in the multiaddr protocol table. Interim +grammar, mirroring how `uds` maps to the spec-legal `/unix`: + +``` +/tipc// # scope implied = cluster +/tipc/// # explicit +``` + +- `_tpt_proto_to_maddr['tipc'] = 'tipc'` and a `mk_maddr()` + `case 'tipc':` building the above. +- `parse_maddr()` gets `case ['tipc']:` — but note + `py-multiaddr` will reject an unregistered protocol name + outright, so this **requires an upstream registration** (same + track as the `wg` work, gh #483 / + multiformats/py-multiaddr#107). Until that lands: + - `MsgpackTIPCStream.maddr` returns the **`str`** form (the + `MsgTransport.maddr` return type is already + `Multiaddr|str`, and `MsgpackUDSStream.maddr` already + exercises the `str` branch), and + - `parse_maddr()` special-cases the `/tipc/` prefix *before* + handing the string to `Multiaddr()`. + Document this as the reason gh #443's "standardize on + returning `Multiaddr` everywhere" item stays blocked. + +Propose `/tipc/` upstream as: name `tipc`, code TBD, size +variable, value `::` — or as three +composed protos. Prefer *one* proto with a structured value so +the maddr stays 2-segment like `/unix/...`. + +--- + +## 5. Discovery: the actually-interesting part + +Two independently-shippable layers. **Layer A is in scope for +the first PR; layer B is a fast-follow.** + +### 5.1 Layer A — "discovery by bind" (free) + +Because `bind(TIPC_ADDR_NAMESEQ)` publishes and +`connect(TIPC_ADDR_NAME)` resolves, a `tractor` tree whose +`registry_addrs` are TIPC service names needs **no registrar +liveness at all** for the connect path: `find_actor()`'s +"connect to the registrar and ask" becomes "connect to the +service name directly". Concretely: + +- `tractor.discovery._api.find_actor()` etc. keep working + unchanged (they go through the registrar), *and* +- a new, TIPC-only fast path becomes possible: derive an actor's + service name from its `(name, uuid)` and dial it without any + registrar hop. + +Do **not** build the fast path in PR 1. Instead, prove the +property with a test (§7.4) and file the follow-up: it changes +`discovery` semantics (name→instance derivation must be a +documented, stable, cross-language-able hash) and deserves its +own design. + +### 5.2 Layer B — the topology service (`TIPC_TOP_SRV`) + +This is what makes #378's "end game cluster proto" claim real: +a *subscription* to name-table events, i.e. push-based +`register`/`deregister` for free, replacing the registrar's +polled `find_actor()`. + +Mechanics (verify each field against +`linux/include/uapi/linux/tipc.h` + `net/tipc/topsrv.c` at +implementation time — the struct layout below is from the uapi +header and the byte-order caveat is real): + +```python +# SOCK_SEQPACKET connected to the topology server +sock = trio.socket.socket(AF_TIPC, SOCK_SEQPACKET) +await sock.connect(( + socket.TIPC_ADDR_NAME, + socket.TIPC_TOP_SRV, # == 1 + socket.TIPC_TOP_SRV, + 0, +)) + +# struct tipc_subscr { +# struct tipc_name_seq seq; /* 3 * __u32: type, lower, upper */ +# __u32 timeout; /* TIPC_WAIT_FOREVER == ~0 */ +# __u32 filter; /* TIPC_SUB_{PORTS,SERVICE,CANCEL} */ +# char usr_handle[8]; +# } /* == 28 bytes */ +_SUBSCR_FMT: str = '=IIIII8s' # ⚠ 5*I is 20 -> use '=5I8s' +``` + +- **byte order**: the topology server historically accepts both + host and swapped order and auto-detects; modern kernels are + strict-ish. Pack native (`'='`) first, and if the server + closes the connection immediately, retry with `'>'`. Encode + that as a one-time probe helper + `_detect_topsrv_endianness()` cached at module level — and + put a `# ?TODO` pointing at `net/tipc/topsrv.c` for someone + to make it deterministic. +- **events**: `struct tipc_event` is `event: u32`, + `found_lower: u32`, `found_upper: u32`, + `port: {ref: u32, node: u32}`, then the 28-byte subscription + echo → 40 bytes. `event ∈ {TIPC_PUBLISHED, TIPC_WITHDRAWN, + TIPC_SUBSCR_TIMEOUT}`. +- **trio shape** — this is where the "nearly-functional, + modern-async" style pays off; expose it as an `@acm` yielding + a `trio` receive-channel of typed events, *not* a class: + +```python +@acm +async def open_topology_events( + stype: int = TRACTOR_STYPE, + lower: int = 0, + upper: int = 0xFFFFFFFF, + filter: int = TIPC_SUB_SERVICE, + timeout: int = TIPC_WAIT_FOREVER, + buf_size: int = 64, +) -> AsyncGenerator[ + trio.MemoryReceiveChannel[TIPCNameEvent], + None, +]: + ... +``` + + with `TIPCNameEvent(msgspec.Struct, frozen=True)` fields + `kind: Literal['published','withdrawn','timeout']`, + `addr: TIPCAddress`, `node: int`, `ref: int`. One + `trio.lowlevel`-free implementation: a nursery-spawned reader + task doing `await sock.recv(40)` in a loop and + `send_nowait()`ing decoded events, with the `@acm` closing the + socket on exit → reader gets `ClosedResourceError` → cancel + scope collapses. Standard `tractor` `@acm` discipline. +- **consumer**: `tractor/discovery/_registry.py` gains an + optional "watch" mode so a registrar (or any actor) can keep + a live view of the actor set without polling. Sketch the + integration in the follow-up issue; do not wire it in PR 1. +- **`SOCK_SEQPACKET` is fine here** because this socket never + goes through `MsgpackTransport` — it's a plain trio socket + used with `recv()`. The contract's "`SOCK_STREAM` only" + constraint applies to `MsgTransport` streams, not to this. + +--- + +## 6. Commit sequencing (each independently reviewable + green) + +1. `_server.py`: add `Address.rebind_from_sockname: + ClassVar[bool]`, gate the `getsockname()` reconciliation on + it, `True` for tcp/uds. Test: tcp `port=0` unchanged. +2. `tractor/ipc/_tipc.py`: `TIPCAddress` + `is_tipc_available()` + predicate + `start_listener()`. No transport yet. + Tests: address round-trip (`unwrap`/`from_addr`/`wrap_address`), + `get_random()` uniqueness, bind/listen + `SO_ACCEPTCONN` + tolerance, `EAFNOSUPPORT` → actionable `ConnectionError`. +3. `MsgpackTIPCStream` + `connect_to()` + `get_stream_addrs()`. + Test: two `trio` tasks in one proc exchange a msg over + `Msgpack` framing (no `tractor` runtime). +4. registration tables (contract §2 items 1-6, 9) + + `pyproject.toml` mark/extra. Test: full suite under + `--tpt-proto tipc` (§7.3). +5. maddr support (`str` form + prefix special-case) + docs. +6. `open_topology_events()` @acm + its tests (layer B). +7. docs page + `docs/` example. + +Per project convention, a reproducing/guard test lands in its +own commit **before** the fix it guards. + +--- + +## 7. Testing + +### 7.1 the capability predicate (in `_tipc.py`, public) + +```python +def is_tipc_available() -> bool: + ''' + True iff this kernel can create an `AF_TIPC` socket, i.e. + the `tipc` module is loaded. + + ''' + try: + socket.socket(socket.AF_TIPC, socket.SOCK_STREAM).close() + return True + except OSError: + return False +``` + +Cache it in a module global (it can't change without a +`modprobe`, and a cold call costs a syscall). Pure predicate, no +side effects, no logging. + +### 7.2 gating + +- `pytest.mark.tipc` registered in `pyproject.toml`. +- module-level + `pytestmark = pytest.mark.skipif(not is_tipc_available(), + reason='`tipc` kernel module not loaded (`modprobe tipc`)')` + in `tests/ipc/test_tipc.py`. +- `--tpt-proto tipc` with no module must fail **loudly and + early** with the actionable message, not with 400 confusing + timeouts. Add the check to the `tpt_protos` fixture's existing + per-proto validation loop (`_testing/pytest.py:795`): if the + chosen `Address` type exposes an `is_available()`-style + classmethod, call it and `pytest.fail()` with its reason. + Generalize (don't special-case tipc) — plans 02/03 need the + same hook. + +### 7.3 CI + +- add a job matrix entry `--tpt-proto tipc` that runs + `sudo modprobe tipc` in a `before` step. GH's + `ubuntu-latest` runners do allow `modprobe tipc` (the module + ships with the standard Ubuntu kernel package); verify in a + throwaway workflow before wiring the matrix. If it turns out + to be unavailable, fall back to a container job with + `--privileged`/`--cap-add NET_ADMIN`, and mark the job + `continue-on-error` until it's proven stable. +- cross-node TIPC (bearer) cannot be CI'd; cover it with a + documented manual smoke test in the docs page, in the style + of gh #482's LAN examples. + +### 7.4 backend-specific tests worth writing + +- **name-publication is discovery**: bind a listener on + `(stype, inst)`, then from a second task `connect()` by name + and assert it lands — *without* any `tractor` registrar. +- **`get_random()` collision resistance**: 10k `get_random()` + calls with no live runtime → 10k distinct `_instance`s. + (This is the silent-crosstalk risk from §2.3; if the 4-byte + digest ever collides in this test, escalate to §9.) +- **round-robin surprise**: two listeners bound to the *same* + `(stype, inst)` both succeed (TIPC allows it) and connects + distribute. Assert the observed behaviour and reference it + from the `get_random()` docstring so the next reader knows + why the hash matters. +- **scope isolation**: a `TIPC_NODE_SCOPE` bind is not visible + to a cluster-scope lookup from another node (manual/marked). +- **importance opt** round-trips via `getsockopt`. +- **graceful + abrupt close** produce `TransportClosed` with the + same `loglevel` classification as tcp/uds — i.e. re-run the + relevant `tests/ipc/test_each_tpt.py` cases parametrized over + the new proto rather than writing new ones. + +--- + +## 8. Deployment / docs deliverable + +A `docs/` page (and/or an `examples/` script) covering: + +```bash +# single host, node-scope only +sudo modprobe tipc +tipc node get addr + +# multi-host over ethernet (pairs beautifully with plan 03's wg) +sudo tipc bearer enable media eth device eth0 +# ...or over UDP when L2 isn't available: +sudo tipc bearer enable media udp name uc localip 10.0.11.1 +tipc link list +tipc nametable show # <- see tractor's published services! +``` + +`tipc nametable show` displaying live `tractor` actors is the +single best demo this backend has; lead with it. + +--- + +## 9. Known risks + escalations + +| risk | mitigation | +| --- | --- | +| `_instance` hash collision → silent crosstalk (two actors share a service name, TIPC round-robins connects between them) | §7.4 test; if it bites, add a post-bind verification handshake, or bump to a 6-byte digest folded into `(stype_low, instance)` | +| kernel/module unavailability everywhere (dev boxes, macOS, CI) | hard gating (§7.2); TIPC is explicitly an *opt-in cluster* transport, never a default | +| `getsockname()` returns port-id not name | the `rebind_from_sockname` opt-out (§3.2), landed first | +| unregistered `/tipc` multiaddr proto | `str` maddr fallback (§4) + upstream track gh #483 | +| stale docs (#378 notes tipc.io docs may be out of date) | treat `include/uapi/linux/tipc.h` + `net/tipc/` as the only normative source; cite file+symbol in code comments | +| `SOCK_SEQPACKET` topology framing byte-order | probe helper + `?TODO` (§5.2) | + +## 10. Follow-up issue seeds + +- registrar-less discovery fast path via name derivation (§5.1) +- `TIPC_TOP_SRV`-driven push registry in + `discovery/_registry.py` (§5.2) +- `TIPC_IMPORTANCE` for the parent<->child lifetime channel + (§3.3) — genuinely novel supervision QoS, no other backend + can do it +- TIPC multicast / group messaging as a *broadcast* transport + for `tractor.trionics` fan-out (explicitly not `MsgTransport`) +- dual-link resiliency / multi-homing (#378's "hybrid dual link") + once bearers are scripted in the docs