From e3089ba3564ff490b0e7a0367f6811d9cd18746b Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 14 Aug 2026 09:52:17 -0400 Subject: [PATCH] Add `TIPCAddress` + `start_listener()`, gh #378 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the `AF_TIPC` tpt backend: the addr type, the `is_tipc_available()` capability predicate and the name-publishing listener. No `MsgTransport` yet. An actor's TIPC addr is a *service name* `(stype, instance)`: `.bind()`ing the singleton `TIPC_ADDR_NAMESEQ` range IS the service registration (it shows up in `tipc nametable show`) and a peer's `.connect()`-by-name IS the lookup — so the kernel does discovery for us, no registrar hop. Deats, - `.unwrap()` is proto-keyed as `('tipc', stype, inst, scope)` using the `multiaddr` proto spelling so `wrap_address()` can't confuse it with `tcp`s or `uds`s 2-tuples. - `.rebind_from_sockname = False` bc `getsockname()` answers a port-id; `.from_addr()` raises on a bare `TIPC_ADDR_ID` rather than fabricate an un-dialable addr. - `.bindspace` is the TIPC *scope*, i.e. literally the set of hosts a published name is reachable from. `ZONE` scope is deprecated/aliased so fold it to `CLUSTER` on input. - mod stays importable on non-linux (uapi-value fallbacks, the `_uds.SO_PASSCRED` precedent) bc `._addr` builds its registration tables at import time. XXX a `.get_random()` clash does NOT raise `EADDRINUSE` — TIPC accepts multiple publishers of one name and round-robins connects between them (verified against a live kernel), so a collision is *silent crosstalk*. Hence the `blake2b` digest and its (birthday-bounded) collision test. Also, - a generic `.is_available() -> (ok, why_not)` classmethod; deliberately spelled generically (NOT `is_tipc_*`) so the sibling env-dependent backends — `quic`/`iroh` (gh #353) and the `wg` netns bindspace (gh #482) — get the same gate for free. Its consumer lands w/ the reg tables. - register a `tipc` pytest mark; the kernel-touching cases self-skip unless `sudo modprobe tipc` has been run. (this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`)) --- tests/ipc/test_tipc.py | 358 ++++++++++++++++++++++++ tractor/_testing/pytest.py | 6 + tractor/ipc/_tipc.py | 550 +++++++++++++++++++++++++++++++++++++ 3 files changed, 914 insertions(+) create mode 100644 tests/ipc/test_tipc.py create mode 100644 tractor/ipc/_tipc.py diff --git a/tests/ipc/test_tipc.py b/tests/ipc/test_tipc.py new file mode 100644 index 00000000..e2973aea --- /dev/null +++ b/tests/ipc/test_tipc.py @@ -0,0 +1,358 @@ +''' +Unit tests for the `AF_TIPC` transport backend, `tractor.ipc._tipc`. + +The kernel-touching cases are gated on `is_tipc_available()` since +the `tipc` module is NOT loaded by default (`sudo modprobe tipc`); +the pure address-algebra cases run everywhere. + +''' +from __future__ import annotations +import errno +from socket import ( + SOCK_STREAM, + SOL_SOCKET, + SO_ACCEPTCONN, +) + +import pytest +import trio + +from tractor.ipc import _tipc +from tractor.ipc._tipc import ( + AF_TIPC, + TIPC_ADDR_ID, + TIPC_ADDR_NAME, + TIPC_CLUSTER_SCOPE, + TIPC_NODE_SCOPE, + TIPC_ZONE_SCOPE, + TRACTOR_STYPE, + TIPCAddress, + instance_from_seed, + is_tipc_available, + start_listener, +) + + +pytestmark = pytest.mark.tipc + +requires_tipc = pytest.mark.skipif( + not is_tipc_available(), + reason=( + '`tipc` kernel module not loaded (`sudo modprobe tipc`)' + ), +) + + +# ------------------------------------------------------------------ +# address algebra (no kernel needed) +# ------------------------------------------------------------------ + +@pytest.mark.parametrize( + 'addr', + [ + TIPCAddress.get_root(), + TIPCAddress( + _stype=TRACTOR_STYPE, + _instance=42, + _scope=TIPC_NODE_SCOPE, + ), + ], + ids=['root', 'node-scoped'], +) +def test_addr_unwrap_roundtrip(addr: TIPCAddress): + ''' + `.unwrap()` is proto-keyed and `.from_addr()` inverts it — for + both the `tuple` form and the `list` form msgpack decodes to. + + ''' + unwrapped: tuple = addr.unwrap() + assert unwrapped[0] == 'tipc' == TIPCAddress.proto_key + assert len(unwrapped) == 4 + + assert TIPCAddress.from_addr(unwrapped) == addr + assert TIPCAddress.from_addr(list(unwrapped)) == addr + + +def test_addr_scope_defaults_when_omitted(): + ''' + A 3-elem `('tipc', stype, inst)` form defaults to the + cluster-scope bindspace. + + ''' + addr: TIPCAddress = TIPCAddress.from_addr( + ('tipc', TRACTOR_STYPE, 99), + ) + assert addr._scope == TIPC_CLUSTER_SCOPE + assert addr.bindspace == TIPCAddress.def_bindspace + + +def test_zone_scope_normalized_to_cluster(): + ''' + `TIPC_ZONE_SCOPE` is deprecated/aliased in modern kernels; + accept it on input, fold it to cluster. + + ''' + addr: TIPCAddress = TIPCAddress.from_addr( + ('tipc', TRACTOR_STYPE, 7, TIPC_ZONE_SCOPE), + ) + assert addr._scope == TIPC_CLUSTER_SCOPE + assert addr.is_valid + + +def test_addr_from_bare_port_id_raises(): + ''' + A `TIPC_ADDR_ID` 5-tuple carries no service-name so it can + NEVER be wrapped; it must fail loudly rather than silently + fabricate an un-dialable addr. + + This is the invariant that lets + `TIPCAddress.rebind_from_sockname` be `False`. + + ''' + with pytest.raises(ValueError) as excinfo: + TIPCAddress.from_addr((TIPC_ADDR_ID, 0, 12345, 0, 0)) + + assert 'port-id' in str(excinfo.value) + + +def test_addr_is_valid_predicate(): + assert TIPCAddress.get_root().is_valid + + # instance 0 is not a bindable name + assert not TIPCAddress( + _stype=TRACTOR_STYPE, + _instance=0, + ).is_valid + + # service-types 0..63 are TIPC-internal (`TIPC_CFG_SRV`, + # `TIPC_TOP_SRV`, ..) + assert not TIPCAddress( + _stype=1, + _instance=1616, + ).is_valid + + +def test_port_id_is_annotation_only(): + ''' + `.maybe_node`/`.maybe_ref` are *observed* metadata, excluded + from `.unwrap()` exactly like `UDSAddress.maybe_pid`. + + ''' + addr: TIPCAddress = TIPCAddress.get_root() + annotated: TIPCAddress = addr.with_port_id( + node=0xdead, + ref=1234, + ) + assert annotated.unwrap() == addr.unwrap() + assert annotated.maybe_ref == 1234 + assert '1234' in repr(annotated) + + +def test_instance_from_seed_is_pure(): + ''' + Same seed -> same instance (what the follow-up registrar-less + discovery fast-path will lean on), and always clear of the + reserved low range. + + ''' + for seed in ('doggy@123', 'kitty@456', ''): + inst: int = instance_from_seed(seed) + assert inst == instance_from_seed(seed) + assert 64 <= inst < 2**32 + + +def test_get_random_collision_resistance(): + ''' + A `.get_random()` clash does NOT raise `EADDRINUSE` — TIPC + accepts multiple publishers of one name and round-robins + connects between them, so a collision is *silent crosstalk*. + + Assert the 4-byte digest spreads well enough for that to stay + improbable. + + NOTE the bound is birthday-statistical, not absolute: + P(collision) ~= 1 - exp(-n**2 / 2**33) ~= 1.2e-2 for n=10k, so + a strict `== n` assert would be ~1-in-86 flaky. P(>2 + collisions) is ~1e-7, hence the slack. See plan 01 §9 for the + escalation path if this ever trips. + + ''' + n: int = 10_000 + addrs: list[TIPCAddress] = [ + TIPCAddress.get_random() + for _ in range(n) + ] + instances: set[int] = { + addr._instance + for addr in addrs + } + assert len(instances) >= n - 2 + + # every one is a legal, bindable name + assert all(addr.is_valid for addr in addrs) + + +def test_get_random_honors_bindspace(): + addr: TIPCAddress = TIPCAddress.get_random( + bindspace=TIPC_NODE_SCOPE, + ) + assert addr.bindspace == TIPC_NODE_SCOPE == addr._scope + + +def test_eafnosupport_is_actionable_connerr( + monkeypatch: pytest.MonkeyPatch, +): + ''' + With no `tipc` module the kernel answers `EAFNOSUPPORT`; that + MUST surface as a `ConnectionError` naming the fix rather than + a bare `OSError`. + + ''' + class _NoTIPCKernel: + @staticmethod + def socket(*args, **kwargs): + raise OSError( + errno.EAFNOSUPPORT, + 'Address family not supported by protocol', + ) + + monkeypatch.setattr(_tipc, 'trio_socket', _NoTIPCKernel) + + async def main(): + await start_listener(addr=TIPCAddress.get_root()) + + with pytest.raises(ConnectionError) as excinfo: + trio.run(main) + + report: str = str(excinfo.value) + assert 'modprobe tipc' in report + assert type(excinfo.value.__cause__) is OSError + + +# ------------------------------------------------------------------ +# kernel-touching +# ------------------------------------------------------------------ + +@requires_tipc +def test_listener_tolerates_so_acceptconn(): + ''' + `trio.SocketListener.__init__` asserts + `getsockopt(SOL_SOCKET, SO_ACCEPTCONN)` is truthy, suppressing + `OSError` for exotic families. + + Pin which of the two branches `AF_TIPC` actually takes (plan 01 + §3.1 left it as an assumption) so a kernel-side regression is + caught here rather than as a mystery bind failure. + + ''' + async def main(): + addr: TIPCAddress = TIPCAddress.get_random() + lstnr = await start_listener(addr=addr) + try: + assert lstnr.socket.getsockopt( + SOL_SOCKET, + SO_ACCEPTCONN, + ) + finally: + lstnr.socket.close() + + trio.run(main) + + +@requires_tipc +def test_bind_publishes_a_dialable_service_name(): + ''' + "Publishing a bind IS registration": `.bind()` a singleton + name-seq and a second task resolves it by *name* — with NO + `tractor` registrar in the loop. + + This is the core #378 property. + + ''' + async def main(): + addr: TIPCAddress = TIPCAddress.get_random() + lstnr = await start_listener(addr=addr) + + accepted: list = [] + + async def _accept(): + stream = await lstnr.accept() + accepted.append(stream) + await stream.send_all(b'woof') + await stream.aclose() + + async with trio.open_nursery() as tn: + tn.start_soon(_accept) + await trio.sleep(0.05) + + sock = _tipc.trio_socket.socket( + AF_TIPC, + SOCK_STREAM, + ) + # NOTE, connect by *name* -> the kernel does the + # lookup, i.e. this call IS the discovery query. + await sock.connect(( + TIPC_ADDR_NAME, + addr._stype, + addr._instance, + 0, # domain: 0 == "anywhere in scope" + addr._scope, + )) + stream = trio.SocketStream(sock) + assert await stream.receive_some(16) == b'woof' + await stream.aclose() + + assert len(accepted) == 1 + lstnr.socket.close() + + trio.run(main) + + +@requires_tipc +def test_getsockname_is_a_port_id_not_the_bound_name(): + ''' + The reason `TIPCAddress.rebind_from_sockname` is `False`. + + A NAMESEQ-bound listener's `getsockname()` answers a + `TIPC_ADDR_ID` port-id, which never equals `.unwrap()` and + cannot be wrapped back into a service name. + + ''' + async def main(): + addr: TIPCAddress = TIPCAddress.get_random() + lstnr = await start_listener(addr=addr) + try: + sockname: tuple = lstnr.socket.getsockname() + assert sockname[0] == TIPC_ADDR_ID + assert sockname != addr.unwrap() + with pytest.raises(ValueError): + TIPCAddress.from_addr(sockname) + finally: + lstnr.socket.close() + + trio.run(main) + + +@requires_tipc +def test_duplicate_name_bind_does_not_raise(): + ''' + Unlike every other backend, TIPC permits *two* publishers of + one service name and round-robins connects between them. + + Pin that observed behaviour — it's the whole reason + `.get_random()` bothers with a well-spread digest, and a + future kernel that starts raising `EADDRINUSE` here would be + very good news worth noticing. + + ''' + async def main(): + addr: TIPCAddress = TIPCAddress.get_random() + first = await start_listener(addr=addr) + second = await start_listener(addr=addr) + try: + assert first.socket.getsockname() != second.socket.getsockname() + finally: + first.socket.close() + second.socket.close() + + trio.run(main) diff --git a/tractor/_testing/pytest.py b/tractor/_testing/pytest.py index 90b0ae67..b8938fb1 100644 --- a/tractor/_testing/pytest.py +++ b/tractor/_testing/pytest.py @@ -496,6 +496,12 @@ def pytest_configure( 'has_nested_actors: test spawns nested (>1-level) subactor ' 'trees.' ) + config.addinivalue_line( + 'markers', + 'tipc: test targets the `AF_TIPC` tpt backend; the kernel- ' + 'touching cases self-skip unless the `tipc` module is loaded ' + '(`sudo modprobe tipc`).' + ) config.addinivalue_line( 'markers', 'trio: legacy mark for tests meant to run under the `trio` ' diff --git a/tractor/ipc/_tipc.py b/tractor/ipc/_tipc.py new file mode 100644 index 00000000..c428cd95 --- /dev/null +++ b/tractor/ipc/_tipc.py @@ -0,0 +1,550 @@ +# tractor: distributed structured concurrency. +# Copyright 2018-eternity Tyler Goodlet. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +''' +`AF_TIPC` (Transparent Inter-Process Communication) implementation of +the `tractor.ipc._transport.MsgTransport` protocol. + +TIPC is a linux-kernel cluster IPC protocol whose *service names* are +published in a cluster-wide name-table by the kernel itself. That +makes a `.bind()` literally a service **registration** and +a `.connect()` literally a service **lookup**, i.e. the discovery +machinery `tractor.discovery` normally implements with a registrar +actor comes for free, in-kernel. + +An actor's TIPC address is therefore a *service name* pair, +`(stype, instance)`, + +- a listener `.bind()`s the singleton published range + `(stype, instance, instance)` as a `TIPC_ADDR_NAMESEQ`, +- a peer `.connect()`s that name as a `TIPC_ADDR_NAME` and the kernel + resolves it, +- `TIPC_ADDR_ID` (a `(node, ref)` port-id) is only ever an *observed* + address, never a user-facing one. + +NOTE, the `tipc` kernel module is NOT loaded by default; see +`is_tipc_available()` and the `sudo modprobe tipc` hint carried in +this module's `ConnectionError` messages. + +Normative refs are the kernel sources (the tipc.io docs are stale), +- `include/uapi/linux/tipc.h` +- `net/tipc/socket.c` + +''' +from __future__ import annotations +from contextlib import ( + contextmanager as cm, +) +import errno +from hashlib import blake2b +import os +import socket +from socket import SOCK_STREAM +from typing import ( + ClassVar, + Type, + TYPE_CHECKING, +) +from uuid import uuid4 + +import msgspec +from trio import ( + socket as trio_socket, + SocketListener, +) + +from tractor.log import get_logger +from tractor.runtime._state import ( + current_actor, + is_root_process, +) + +if TYPE_CHECKING: + from tractor.runtime._runtime import Actor + + +log = get_logger() + + +# XXX, `AF_TIPC` and every `TIPC_*` constant are linux-ONLY in +# CPython's `socketmodule.c`. Mirror the `_uds.py` `SO_PASSCRED` +# precedent and fall back to the uapi values so this module stays +# **importable everywhere** — `.discovery._addr` builds its +# registration tables at import time (contract §2.3) — while +# `is_tipc_available()` remains the single *runtime* gate. +# +# values verified against `include/uapi/linux/tipc.h` +try: + from socket import ( + AF_TIPC, + SOL_TIPC, + TIPC_ADDR_ID, + TIPC_ADDR_NAME, + TIPC_ADDR_NAMESEQ, + TIPC_CLUSTER_SCOPE, + TIPC_NODE_SCOPE, + TIPC_ZONE_SCOPE, + ) +except ImportError: + AF_TIPC: int = 30 + SOL_TIPC: int = 271 + TIPC_ADDR_NAMESEQ: int = 1 + TIPC_ADDR_NAME: int = 2 + TIPC_ADDR_ID: int = 3 + TIPC_ZONE_SCOPE: int = 1 + TIPC_CLUSTER_SCOPE: int = 2 + TIPC_NODE_SCOPE: int = 3 + + +# `tractor`'s reserved TIPC service-class ("type"), spelling out +# ascii 'tr' in the high half and leaving the low 16b free for +# app-side partitioning via an explicit `TIPCAddress._stype`. +# +# NOTE, two `tractor` trees sharing BOTH a cluster and an `_stype` +# share a service-name space; see `.get_random()` on why that's +# only a *probabilistic* hazard. +TRACTOR_STYPE: int = 0x74_72_00_00 + +# TIPC reserves service-*types* 0..63 for its own internal services +# (`TIPC_CFG_SRV == 0`, `TIPC_TOP_SRV == 1`); see +# `include/uapi/linux/tipc.h`. +_tipc_reserved_stypes: range = range(0, 64) + +# sentinel for "this addr was *observed* off a `TIPC_ADDR_ID`, so +# the peer's service-name is unknowable from the socket alone". +# See plan 01 §3.4. +TIPC_NAME_UNKNOWN: int = -1 + +_scope_names: dict[int, str] = { + TIPC_ZONE_SCOPE: 'zone', + TIPC_CLUSTER_SCOPE: 'cluster', + TIPC_NODE_SCOPE: 'node', +} + +# see `is_tipc_available()` +_tipc_avail: bool|None = None + + +def is_tipc_available() -> bool: + ''' + `True` iff this kernel can create an `AF_TIPC` socket, i.e. the + `tipc` module is loaded (`sudo modprobe tipc`). + + Pure predicate; no side effects, no logging. The answer can't + change without a `modprobe` so it's memoized after the first + (one syscall) probe. + + ''' + global _tipc_avail + if _tipc_avail is None: + try: + socket.socket( + AF_TIPC, + SOCK_STREAM, + ).close() + _tipc_avail = True + except OSError: + _tipc_avail = False + + return _tipc_avail + + +class TIPCAddress( + msgspec.Struct, + frozen=True, +): + ''' + A TIPC *service name* as an address, i.e. the + `(type, instance)` pair a listener publishes and a peer + resolves, plus the optionally-*observed* `TIPC_ADDR_ID` + port-id of a live connection. + + ''' + _stype: int + _instance: int + _scope: int = TIPC_CLUSTER_SCOPE + + # observed-only, from a `TIPC_ADDR_ID` `getsockname()`/ + # `getpeername()`; excluded from `.unwrap()` exactly like + # `UDSAddress.maybe_pid`. + maybe_node: int|None = None + maybe_ref: int|None = None + + proto_key: ClassVar[str] = 'tipc' + unwrapped_type: ClassVar[type] = tuple[str, int, int, int] + def_bindspace: ClassVar[int] = TIPC_CLUSTER_SCOPE + + # XXX, TIPC's `getsockname()` answers a `TIPC_ADDR_ID` port-id + # and NEVER the name-seq we bound, so the `Endpoint`-level + # reconciliation would clobber a dialable service-name with an + # un-dialable port-id. There's also nothing to learn: unlike + # tcp's `port=0` there is no kernel-assigned-name analogue. + rebind_from_sockname: ClassVar[bool] = False + + @property + def bindspace(self) -> int: + ''' + The TIPC *scope*, i.e. literally "the set of hosts from + which this published name is reachable": `TIPC_NODE_SCOPE` + for same-host-only (the UDS analogue), + `TIPC_CLUSTER_SCOPE` for cluster-visible. + + ''' + return self._scope + + @property + def is_valid(self) -> bool: + ''' + Is this a *publishable/dialable* service name? + + NOTE the `> 0` (rather than `!= 0`) guards double-duty as + the `TIPC_NAME_UNKNOWN` reject, i.e. an addr merely + *observed* off a peer's port-id is never dialable. + + ''' + return ( + self._instance > 0 + and + self._stype > 0 + and + self._stype not in _tipc_reserved_stypes + and + self._scope in ( + TIPC_NODE_SCOPE, + TIPC_CLUSTER_SCOPE, + ) + ) + + @classmethod + def from_addr( + cls, + addr: tuple[str, int, int, int], + ) -> TIPCAddress: + match addr: + # our proto-keyed unwrapped form, w/ scope optional + case ( + ('tipc', int() as stype, int() as inst, int() as scope) + | + ['tipc', int() as stype, int() as inst, int() as scope] + ): + return TIPCAddress( + _stype=stype, + _instance=inst, + _scope=_norm_scope(scope), + ) + + case ( + ('tipc', int() as stype, int() as inst) + | + ['tipc', int() as stype, int() as inst] + ): + return TIPCAddress( + _stype=stype, + _instance=inst, + ) + + # a kernel-observed `TIPC_ADDR_ID` 5-tuple. + # + # XXX, a port-id carries NO service-name info, so we + # cannot reconstruct `(stype, instance)` from it. This + # is exactly why `.rebind_from_sockname` is `False`; + # if you land here something re-enabled that path. + case (int() as atype, *_) if atype == TIPC_ADDR_ID: + raise ValueError( + f'Can not wrap a bare TIPC_ADDR_ID port-id !\n' + f'addr: {addr!r}\n' + f'\n' + f'A port-id carries no service-name, so the\n' + f'`(stype, instance)` identity is unrecoverable.\n' + f'Use `.with_port_id()` to *annotate* a known\n' + f'{cls.__name__} instead.\n' + ) + + case _: + raise TypeError( + f'Bad unwrapped-address for {cls} !\n' + f'{addr!r}\n' + ) + + def unwrap(self) -> tuple[str, int, int, int]: + # NOTE, proto-keyed (w/ the `multiaddr` proto spelling) so + # `wrap_address()` can dispatch unambiguously against the + # other backends' 2-tuple forms; see contract §1.1. + return ( + 'tipc', + self._stype, + self._instance, + self._scope, + ) + + def with_port_id( + self, + node: int, + ref: int, + ) -> TIPCAddress: + ''' + A copy annotated with an *observed* `TIPC_ADDR_ID` + port-id, purely for logging/`__repr__`. + + ''' + return msgspec.structs.replace( + self, + maybe_node=node, + maybe_ref=ref, + ) + + @classmethod + def get_random( + cls, + bindspace: int|None = None, + ) -> TIPCAddress: + ''' + A per-subactor ephemeral service-name. + + XXX, TIPC has NO kernel-assigned-instance analogue of tcp's + `port=0`, so we must choose the instance ourselves — and a + clash does **not** raise `EADDRINUSE`: TIPC happily accepts + multiple publishers of one name and round-robins connects + between them (verified). I.e. a collision manifests as + *silent crosstalk*, not an error. + + So the instance is a `blake2b` digest of a per-call-unique + seed, giving a well-spread 32b value. Being a pure fn of the + seed it is also *reproducible*, which the (follow-up) + registrar-less discovery fast-path wants. + + NOTE the residual risk is birthday-bounded: ~1.2e-2 for 10k + names sharing one `_stype`. See plan 01 §9 for the + escalation (post-bind verification) if that ever bites. + + ''' + pid: int = os.getpid() + actor: Actor|None = current_actor( + err_on_no_runtime=False, + ) + if actor: + seed: str = f'{actor.aid.name}@{pid}' + else: + if is_root_process(): + prefix: str = 'no_runtime_root' + else: + prefix: str = 'no_runtime_actor' + + # XXX, no live actor -> no `Aid` to key off, so mix + # a per-CALL token in; w/o it the seed degenerates to + # a pure fn of `(prefix, pid)` and two calls in one + # proc alias to the SAME service name — the `_uds.py` + # `.get_random()` hazard, but silent here. + seed: str = f'{prefix}.{uuid4().hex[:8]}@{pid}' + + return TIPCAddress( + _stype=TRACTOR_STYPE, + _instance=instance_from_seed(seed), + _scope=( + bindspace + if bindspace is not None + else cls.def_bindspace + ), + ) + + @classmethod + def is_available(cls) -> tuple[bool, str]: + ''' + Generic tpt-capability hook: `(ok, why_not)`. + + Consumed by the `tpt_protos` test fixture so a + `--tpt-proto tipc` run on a box with no `tipc` module + fails loudly and early rather than as a few hundred + confusing connect timeouts. Apps can use it too. + + NOTE, deliberately spelled generically (NOT `is_tipc_*`) + so the sibling env-dependent backends — `quic`/`iroh` + (gh #353) and the `wg` netns bindspace (gh #482) — get + the same gate for free. + + ''' + if is_tipc_available(): + return (True, '') + + return ( + False, + 'the `tipc` kernel module is not loaded' + ' |_try: `sudo modprobe tipc`', + ) + + @classmethod + def get_root(cls) -> TIPCAddress: + # NOTE, `1616` mirrors `TCPAddress.get_root()`s port and + # the UDS `registry@1616.sock` filename so the "1616 is + # tractor's registrar" idiom holds across all backends. + return TIPCAddress( + _stype=TRACTOR_STYPE, + _instance=1616, + _scope=TIPC_CLUSTER_SCOPE, + ) + + def __repr__(self) -> str: + if self._instance == TIPC_NAME_UNKNOWN: + name: str = '' + else: + name: str = f'0x{self._stype:08x}:{self._instance}' + + body: str = ( + f'{name}, {_scope_names.get(self._scope, self._scope)}' + ) + if (node := self.maybe_node) is not None: + body += f', @0x{node:08x}:{self.maybe_ref}' + + return ( + f'{type(self).__name__}' + f'[' + f'{body}' + f']' + ) + + +def instance_from_seed(seed: str) -> int: + ''' + Derive a TIPC service *instance* from an actor-identity `seed`. + + A `blake2b` digest folded into `[64, 2**32)`; the low values are + skipped to stay clear of TIPC's own reserved numbering + conventions. + + ''' + inst: int = int.from_bytes( + blake2b( + seed.encode(), + digest_size=4, + ).digest(), + 'big', + ) + return 64 + (inst % (2**32 - 64)) + + +def _norm_scope(scope: int) -> int: + ''' + Normalize a `TIPC_*_SCOPE` value. + + `TIPC_ZONE_SCOPE` is deprecated and aliased to cluster-scope by + modern kernels; accept it on input and fold it. + + ''' + if scope == TIPC_ZONE_SCOPE: + log.transport( + f'Normalizing deprecated TIPC_ZONE_SCOPE -> cluster\n' + f'scope: {scope!r}\n' + ) + return TIPC_CLUSTER_SCOPE + + return scope + + +@cm +def _reraise_as_connerr( + src_excs: tuple[Type[Exception]], + addr: TIPCAddress, +): + ''' + Normalize TIPC's `OSError`s into `ConnectionError`s. + + XXX REQUIRED, not polish: TIPC answers a lookup for an + unpublished name with `EHOSTUNREACH` which python maps to a + **bare** `OSError`, NOT a `ConnectionError` subtype (unlike + `ECONNREFUSED` -> `ConnectionRefusedError`). Contract §4's + discovery-ping path requires the `ConnectionError` shape. + + ''' + try: + yield + except src_excs as src_exc: + match src_exc.errno: + case errno.EAFNOSUPPORT: + why: str = ( + 'TIPC unavailable — is the kernel module loaded?\n' + ' |_try: `sudo modprobe tipc`\n' + ) + case errno.EHOSTUNREACH: + why: str = ( + 'No TIPC publisher for this service name\n' + ' |_nothing has `.bind()`ed it in-scope\n' + ) + case _: + why: str = 'Bad TIPC service-name-as-address ??\n' + + raise ConnectionError( + f'{why}' + f'{addr}\n' + f'\n' + f'from src: {src_exc!r}\n' + ) from src_exc + + +async def start_listener( + addr: TIPCAddress, + backlog: int = 128, + **kwargs, +) -> SocketListener: + ''' + Publish `addr` as a TIPC service name and listen on it. + + The `.bind()` of a singleton `TIPC_ADDR_NAMESEQ` range + `(stype, instance, instance)` **is** the service registration — + it's what shows up in `tipc nametable show` and what a peer's + `.connect()`-by-name resolves against. + + NOTE, unlike every other backend a duplicate bind does NOT + raise: TIPC permits multiple publishers of one name and + round-robins connects between them. See + `TIPCAddress.get_random()`. + + ''' + log.info( + f'Attempting to publish TIPC service name\n' + f'>[\n' + f'|_{addr}\n' + ) + with _reraise_as_connerr( + src_excs=(OSError,), + addr=addr, + ): + sock = trio_socket.socket( + AF_TIPC, + SOCK_STREAM, + ) + await sock.bind(( + TIPC_ADDR_NAMESEQ, + addr._stype, + addr._instance, # lower + addr._instance, # upper + addr._scope, + )) + + # NOTE, backlog matches `_uds.start_listener()`'s hard-won + # value; a backlog of 1 overflows during concurrent + # deregistration storms at actor-tree teardown. + sock.listen(backlog) + log.info( + f'Published TIPC service name\n' + f'[>\n' + f' |_{addr}\n' + ) + return SocketListener(sock) + + +# NOTE, deliberately NO `close_listener()`: there's no filesys +# entry to unlink and the kernel withdraws the published name on +# socket close. Per contract §1.2 absence means "closing is +# implicit".