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

27 KiB
Raw 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 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.


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 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

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).

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 an explicit proto-key, spelled with the multiaddr protocol name.

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. This is a prerequisite migration commit, not part of this backend — see contract §1.1 for its blast radius (wire format + every fixture + piker config) and for the follow-on “stop handing raw tuples to users at all, à la ipaddress” direction.

⚠️ 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 collision-free:

    # 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 isnt.

  • _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 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__ will try getsockopt(SOL_SOCKET, SO_ACCEPTCONN). If TIPC rejects it, trios 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 — 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.

Handle it inside TIPCAddress.from_addr() — do not patch _server.py:

@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 isnt 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:

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. Land this as its own commit, ahead of the backend, with a test that tcps 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

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 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: 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()

@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:

  • 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 peers 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[<peer-node:0x...>:<ref>]. The Aid from the handshake already gives us the peers logical identity, so nothing in the runtime actually needs the peers service name. Recommended.
      1. piggyback the peers 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.pys 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/<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: 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 actors 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 #378s “end game cluster proto” claim real: a subscription to name-table events, i.e. push-based register/deregister for free, replacing the registrars 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):

# 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:
@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 — 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() 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)

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 cant 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='tipckernel 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 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. 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 its proven stable.
  • 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

  • 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 _instances. (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:

# 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

  • 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 (§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 (#378s “hybrid dual link”) once bearers are scripted in the docs