tractor/ai/tpt-backends/01_tipc_backend.md

33 KiB
Raw Permalink Blame History

Plan 01 — TIPC transport backend (tractor/ipc/_tipc.py)

Tracks gh #378. Prereq reading: 00_shared_backend_contract.md.

Thesis: TIPC is the cheapest new backend we can add and gives us kernel-native service-name publication, known-address dialling, and topology events. Those are primitives for reducing registrar traffic; they do not by themselves replace tractor.discovery, derive an actors address from its name, or elect one registrar. It is stdlib-only: zero new dependencies.

This plan is reconciled against downstream PR #493s code and tests. Treat that implementation as prior art without mistaking implemented transport primitives for completed discovery policy.


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 doesnt care about the address family. Per contract §1.5, trio.SocketStream and trio.SocketListener only require a trio socket object of type SOCK_STREAM. TIPCs SOCK_STREAM is a real connection-oriented reliable byte stream. So we reuse trio.SocketStream, trio.SocketListener, trio.serve_listeners(), MsgpackTransports 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 <if> or media udp name <n> localip <ip>). Everything about this plans 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 MsgTransports 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 actors 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 the “leverage the built-in discovery machinery” part of #378: publishing a bind is kernel name-table registration and connect() on an already-known name is a kernel lookup, with no registrar actor on that dial path. Mapping an application name to that address and maintaining Tractors actor registry remain separate work (§5).

2.2 the struct

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, excluded from the unwrapped service identity
    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, int, int]
    def_bindspace: ClassVar[int] = TIPC_CLUSTER_SCOPE

Unwrapped form (the wire/SpawnSpec shape).

TIPCs natural form is (stype, instance, scope) — but a 2-tuple squeeze of it is a (str, int), i.e. the same coarse shape as TCPAddress, so wrap_address()s case (str(), int()) steals it. This backend is therefore the forcing function for the contract-docs conclusion (§1.1):

make the unwrapped form carry the explicit internal TransportProtocolKey.

def unwrap(self) -> tuple[str, int, int, int]:
    return ('tipc', self._stype, self._instance, self._scope)

wrap_address() then dispatches _address_types[addr[0]] and the collision class disappears. The complete all-backend change is a prerequisite migration; #493 necessarily carried the transitional UnwrappedAddress/SpawnSpec.reg_addrs/ .bind_addrs widening needed for TIPC. See contract §1.1 for the remaining runtime annotations, fixtures and piker config. Here tipc is both the internal and external spelling; UDS remains internally uds and translates explicitly to external /unix/.

msgpack decodes tuples as lists, so both forms are part of the round-trip contract. Match only the exact three- or four-element tagged shapes and test all four routes:

case (
    ('tipc', int() as stype, int() as inst, int() as scope)
    |
    ['tipc', int() as stype, int() as inst, int() as scope]
):
    ...

Also test the scope-defaulted three-element form through TIPCAddress.from_addr(), and tuple/list forms through the global wrap_address(). A normal two-element TCP/UDS address whose first element happens to be 'tipc' must retain its classic dispatch.

⚠️ an earlier revision of this plan proposed a self-tagging ('tipc:<stype>:<scope>', instance) string-prefix hack with an ordered case guard. Dropped — it papers over the problem, keeps wrap_address() order-sensitive, and doesnt help irohs (str, str)-vs-UDS collision at all. Do not resurrect it.

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 tractors 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 well-distributed, not collision-free:

    # 32-bit instance derived from the actor's Aid.uid, or from a
    # per-call token + pid when there is no live runtime.
    inst: int = int.from_bytes(
        blake2b(seed.encode(), digest_size=4).digest(),
        'big',
    )

    where seed = '.'.join(actor.aid.uid) 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)). The UUID is load-bearing because TIPC names are cluster-wide while PIDs are only host-local: (actor name, pid) can alias on different hosts. ⚠️ 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 a statistical test and §9 records the unresolved recovery work in #501.

  • _scope: TIPC_NODE_SCOPE for a same-host-only actor (the UDS-equivalent), TIPC_CLUSTER_SCOPE (default) for cluster-visible. This is .bindspace:

    @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

@property
def is_valid(self) -> bool:
    return (
        self._instance > 0
        and
        self._stype > 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()

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 cant block on DNS, but keep it awaited 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__ calls getsockopt(SOL_SOCKET, SO_ACCEPTCONN). The live-kernel probe used by #493 answers 1; retain the unit test so a kernel-side change is visible rather than relying on trios suppressed- OSError carve-out.
  • 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 — trysudo 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.

TIPCAddress.from_addr() must accept only proto-keyed service names. It must reject a bare port ID because no conversion can recover (stype, instance):

@classmethod
def from_addr(cls, addr) -> TIPCAddress:
    match addr:
        # our proto-keyed tuple or decoded-list wire form
        case (
            ('tipc', int() as stype, int() as inst, int() as scope)
            |
            ['tipc', int() as stype, int() as inst, int() as scope]
        ):
            return TIPCAddress(stype, inst, _norm_scope(scope))

        # a bare kernel-observed TIPC_ADDR_ID 5-tuple has no
        # service identity to annotate.
        case (int() as atype, *rest) if atype == socket.TIPC_ADDR_ID:
            raise ValueError(...)

The TIPC_ADDR_ID case cannot reconstruct (stype, instance). The resolution is the explicit listener-rebind policy added ahead of the backend in #493:

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 todays 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. Keep the guard test that TCPs port=0 behaviour is unchanged.

Do not annotate Endpoint.addr from getsockname(): the listener endpoint must remain the dialable service name. Port IDs are observed only on connected streams and may annotate a copy via with_port_id() purely for logging/repr.

3.3 MsgpackTIPCStream

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,
            ))
            stream = trio.SocketStream(sock)
            return cls(
                stream,
                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 cant: the runtimes 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.pys parent-chan path pass the high value in a follow-up (dont 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: the live-kernel result is immediate EHOSTUNREACH. Python exposes that as a bare OSError, not a ConnectionError subtype, so _reraise_as_connerr() is load-bearing for contract §4. Keep the exact errno and normalization under test.

3.4 get_stream_addrs()

@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 ends port-id tells us the service name. The laddr/raddr are used for logging, Channel.raddr, Server._peers keying-adjacent repr, and maddr. Design:

  • get_stream_addrs() converts both socket results into observed-only addresses: _stype/_instance use the TIPC_NAME_UNKNOWN = -1 sentinel and maybe_node/maybe_ref carry the port ID. Such addresses are invalid for dialling.
  • the connecting side knows the service name it dialled, so connect_to() replaces _raddr after construction with that known TIPCAddress while retaining the constructors one tolerant port-ID observation. Do not call getpeername() a second time: the peer can withdraw between the two calls.
  • the accepting side genuinely cannot recover the peers service name from a port ID. Keep the observed-only raddr; the handshakes Aid supplies logical identity. Piggybacking a bound name in the handshake is outside this backend.
  • laddr is observed-only as well. It is used for repr/logging, not to replace the endpoints known service name.
  • unlike TCP/UDS, TIPC can answer ENOTCONN from getpeername() after a connect-then-drop. This lookup happens during MsgpackTransport construction, before handshake error tolerance. Wrap getsockname() and getpeername() in a tolerant helper and degrade to a port-ID-less observed address; a dropped peer must cost an observation, not kill the actor.

4. Multiaddr representation

There is no /tipc in the multiaddr protocol table. Interim grammar, mirroring how uds maps to the spec-legal /unix:

/tipc/<stype>/<instance>            # scope implied = cluster
/tipc/<stype>/<instance>/<scope>    # 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 #443s “standardize on returning Multiaddr everywhere” item stays blocked.

Propose /tipc/ upstream as: name tipc, code TBD, size variable, value <stype>:<instance>:<scope> — or as three composed protos. Prefer one proto with a structured value so the maddr stays 2-segment like /unix/....


5. Discovery primitives and explicit limits

The backend provides independently-shippable kernel primitives. Neither primitive alone implements Tractors actor-name discovery, registry ownership, or registrar election.

5.1 Layer A — “discovery by bind” (free)

Because bind(TIPC_ADDR_NAMESEQ) publishes and connect(TIPC_ADDR_NAME) resolves, a caller that already knows a TIPC service address can dial it without a registrar lookup. This is narrower than registrar-less find_actor(name):

  • tractor.discovery._api.find_actor() and peers still query a registrar; #493 does not change them.
  • deriving a stable service address from (name, uuid) and dialling it directly is follow-up #499. The mapping must be documented and cross-language stable.
  • registry_addrs still identify registrars. Connecting to a known registrar by TIPC name removes no registrar bookkeeping or ownership semantics.

There is also an unresolved split-brain election problem. Duplicate TIPC name publication succeeds and round-robins, so two roots can both probe an unoccupied registrar name, both bind it, and both believe they won. The backend provides no atomic compare-and-publish, lease, quorum, or deterministic winner. A topology subscription can reveal multiple publisher port IDs but does not elect or fence one. Do not describe registrar election as solved until a separate protocol closes this race.

5.2 Layer B — the topology service (TIPC_TOP_SRV)

This is the push primitive behind #378s “end game cluster proto” direction: a subscription to kernel name-table publish/withdraw events. #493 implements open_topology_events(); consuming that feed in discovery._registry is follow-up #496. Until then it does not replace registrar state or find_actor().

Mechanics, verified against linux/include/uapi/linux/tipc.h, net/tipc/topsrv.c and #493s live-kernel probe:

# 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 = '=5I8s'
  • byte order: #493s live-kernel probe verified native standard-size ('=') packing for publish and withdraw events. Use '=5I8s' for the 28-byte subscription. Do not retain the speculative '>' retry/probe as if it were required. Preserve the earlier # ?TODO to verify the deterministic rule directly against net/tipc/topsrv.c; it is source-audit work, not a runtime retry requirement.
  • events: struct tipc_event is event: u32, found_lower: u32, found_upper: u32, port: {ref: u32, node: u32}, then the 28-byte subscription echo: 48 bytes (4 + 4 + 4 + 8 + 28), not 40. Use '=10I8s' and assert struct.calcsize(...) == 48. event ∈ {TIPC_PUBLISHED, TIPC_WITHDRAWN, TIPC_SUBSCR_TIMEOUT}. Python exposes TIPC_WAIT_FOREVER as -1, so mask it with & 0xFFFF_FFFF before packing an unsigned I.
  • 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:
@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(48) in a loop. The feed is authoritative and may neither block the socket reader nor drop transitions silently. Use send_nowait() and, on trio.WouldBlock, raise a dedicated TIPCNameEventOverflow that aborts the subscription and tells the consumer to resubscribe and rebuild its view. A timeout event is delivered once and then closes the channel. The @acm cancels its reader before closing the fd so teardown cannot race a retried recv() into EBADF. - scope: topology events carry no publication scope. Use an explicit unknown-scope sentinel and keep the resulting address non-dialable; never copy caller/subscription context into supposedly observed data. - consumer: #496 owns the optional watch mode and the decision whether the feed subsumes or merely accelerates existing registrar bookkeeping. - SOCK_SEQPACKET is fine here because this socket never goes through MsgpackTransport — its a plain trio socket used with recv(). The contracts “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() distribution, 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-8 and 10) + pyproject.toml mark/extra. Test: full suite under --tpt-proto tipc (§7.3). Keep TIPC in the conservative remote preference tier until a follow-up implements and tests contract item 9s node-scope locality policy.
  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)

def is_tipc_available() -> bool:
    '''
    True iff this kernel can create an `AF_TIPC` socket, i.e.
    the `tipc` module is loaded.

    '''
    if sys.platform != 'linux':
        return False
    try:
        socket.socket(socket.AF_TIPC, socket.SOCK_STREAM).close()
        return True
    except OSError:
        return False

Do not permanently memoize the result: modprobe tipc and module removal can change it during a long-lived process. Probe once per runtime startup, or use an explicitly refreshable cache whose owner invalidates it after module-management operations. The predicate itself remains side-effect-free and silent.

7.2 gating

  • pytest.mark.tipc registered in _testing/pytest.py::pytest_configure() via config.addinivalue_line(), where this repo declares its other custom marks. Do not invent a pyproject.toml marker table.
  • keep pure address, serialization, and topology-codec tests runnable on every host. Apply a shared requires_tipc marker only to tests that create sockets or otherwise touch the kernel; do not module-skip 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 fixtures 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 (dont 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. GHs 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. #493s TIPC leg is now blocking. If runners cease permitting the module load, fix the environment or use a suitable container rather than silently restoring continue-on-error.
  • cross-node TIPC (bearer) cannot be CId; cover it with a documented manual smoke test in the docs page, in the style of gh #482s LAN examples.

7.4 backend-specific tests worth writing

  • known-name publication/resolution: bind a listener on (stype, inst), then from a second task connect() by name and assert it lands — without any tractor registrar.
  • get_random() distribution: 10k get_random() calls with no live runtime. Do not assert 10k distinct values: the no-runtime seeds and outputs are both only 32 bits. Including duplicate seeds plus distinct-seed hash collisions puts the modeled chance of at least one duplicate near 2.3% for 10k calls. #493 uses >= n - 2 (modeled probability of more than two collisions around 2e-6) and separately proves instance_from_seed() is a pure function. Also hold actor name/PID fixed while varying only Aid.uuid to prove live actors seed from Aid.uid.
  • 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:

# 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

  • Instance collision / silent crosstalk remains unresolved. Aid.uid seeding and §7.4 tests reduce and measure risk, but the instance field is still a hard 32 bits. #501 owns post-bind verification and recovery. Do not fold bits into _stype: topology can watch only one service type.
  • Concurrent registrar startup can split brain. Topology can observe duplicate publisher port IDs but cannot elect or fence a winner; a separate election protocol is required (§5.1).
  • Kernel/module availability is opt-in. Keep the hard gate in §7.2; TIPC is never the default transport.
  • A listener sockname is a port ID, not its service name. Keep the rebind_from_sockname opt-out (§3.2).
  • /tipc is not yet a registered multiaddr protocol. Keep the interim str maddr fallback (§4) and upstream gh #483.
  • The public TIPC docs can be stale. Treat include/uapi/linux/tipc.h and net/tipc/ as normative and cite file/symbol names in code comments.
  • A slow topology consumer loses continuity. Fail fast with TIPCNameEventOverflow; resubscribe and rebuild rather than block the reader or retain stale state (§5.2).
  • TIPC locality preference is not implemented. Current _is_local_addr() handles only UDS and TCP, so node- and cluster-scope TIPC both remain in the conservative remote tier. Add explicit scope-aware policy and multihomed selection tests before claiming node-scope preference (contract §2.9).

9.1 remaining constructor/error cleanup

#493 closes the peer-withdrawal race in transport construction, but it is not a blanket error-path cleanup. Keep these gaps explicit rather than reporting the backend as fully hardened:

  • direct TIPCAddress(...) construction bypasses from_addr() scope normalization; is_valid is queried later rather than enforcing validity at construction. Decide whether constructors should reject bad service types/instances/scopes or document direct construction as trusted-internal.
  • maybe_node/maybe_ref are excluded from .unwrap() but, as msgspec.Struct fields, still participate in structural equality/hash. If service-name identity must ignore observation metadata, represent or compare it explicitly instead of relying on the current “observed-only” description.
  • start_listener() must keep ownership of the raw socket through bind(), listen() and SocketListener(...). The downstream implementation normalizes bind errors but does not yet wrap the complete listener-construction sequence in close-on-error, so a later setup failure can leak the fd.
  • _maybe_sockaddr() currently degrades every OSError to an unknown observed address. Narrow that tolerance to expected peer-withdrawal errors (notably ENOTCONN) so unrelated bad-fd or programming failures remain visible.
  • error normalization is intentionally required for an unpublished-name EHOSTUNREACH, but setup setsockopt, listener-constructor, and topology setup failures still need a consistent policy and focused regression tests.

10. Follow-up issue seeds

  • register /tipc in the multiaddr spec, mirroring the wg track (multiformats/py-multiaddr#107/#108 + gh #483). Same shape of work: propose the proto + code, land a codec in py-multiaddr, then drop our str-maddr fallback (§4). Worth filing alongside the wg spec-submission issue so both proposals go up together rather than as one-offs.
  • registrar-less discovery fast path via name derivation (#499, §5.1)
  • TIPC_TOP_SRV-driven push registry in discovery/_registry.py (#496, §5.2)
  • post-bind collision verification and recovery (#501, §9)
  • 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 (#378s “hybrid dual link”) once bearers are scripted in the docs