18 KiB
tractor.ipc next-gen transport backends: the shared contract
Status: design doc / implementation spec. Audience: any model or human implementing one of the three sibling plans in this directory.
01_tipc_backend.md—AF_TIPC(gh #378)02_quic_iroh_backend.md— QUIC viairohFFI, uniffi-async rewritten ontotrio(gh #353)03_wg_tunnel_bindspace.md— WireGuard (and other shuttle-able) tunnels as a nested bindspace layer viapyroute2(gh #482, #443)
This doc is the normative description of what a tractor transport backend is as of main@83b34884. Each sibling plan assumes it and only documents its own deltas. Read this first; do not re-derive it from the code.
0. Why a shared contract doc
The three plans are meant to be implementable independently and concurrently by different models/providers without design drift. Everything they share — the backend duck-type, the registration tables, the test harness plumbing, the naming and code-style rules — lives here exactly once. If an implementer finds this doc disagrees with main, the code wins; fix this doc in the same PR.
1. The backend duck-type (empirical, from _tcp.py/_uds.py)
A transport backend is one module under tractor/ipc/ exposing exactly four things. There is no ABC to subclass and no plugin entrypoint; wiring is by explicit table registration (§2) plus one piece of reflection (§1.3).
1.1 class <Proto>Address(msgspec.Struct, frozen=True)
Structurally conforms to the Address Protocol in tractor/discovery/_addr.py:82. Required surface:
| member | kind | notes |
|---|---|---|
proto_key |
ClassVar[str] |
the wire/registry key, e.g. 'tcp', 'uds' |
unwrapped_type |
ClassVar[type] |
the primitive tuple shape |
def_bindspace |
ClassVar |
default bindspace value |
is_valid |
@property -> bool |
“is this a dialable/bindable addr” |
bindspace |
@property |
the “set of hosts”-ish scope (see below) |
from_addr(cls, addr) |
@classmethod |
primitive -> wrapped, match-based |
unwrap(self) |
method | wrapped -> primitive (must be msgpack-native!) |
get_random(cls, bindspace=...) |
@classmethod |
per-subactor ephemeral addr |
get_root(cls) |
@classmethod |
host-singleton default registrar addr |
__repr__ |
method | f'{type(self).__name__}[{...}]' house style |
Hard constraints learned from the existing two:
frozen=True. Addresses are dict keys (Server.epsdict(),Endpoint.peer_tpts) and are compared by value all over the runtime..unwrap()output must round-trip throughmsgspecand throughwrap_address(). It is what actually crosses the wire inSpawnSpec/_root_mailbox/_registry_addrs, and it is whatActor.reg_addrsand every test compares against. If your unwrapped form is not uniquely pattern-matchable against the other backends’ forms inwrap_address()(_addr.py:230), you have a bug that manifests as the wrong transport being loaded — the file’s ownXXX NOTEwarns about precisely this.⚠️ and shape-matching does not survive 4 backends. Adding TIPC and iroh breaks it outright: TIPC’s natural form is a
(str, int)— indistinguishable fromTCPAddress— and iroh’s is a(str, str), which the existing UDS case (case (_, filename) if type(filename) is str) already swallows. Ordering hacks and prefix-tagging (an earlier revision of plan 01 proposed('tipc:<stype>:<scope>', inst)) paper over it at best.The fix, and the recommended prerequisite for all three backends: make the unwrapped form carry an explicit proto-key, using the
multiaddrprotocol name as the canonical spelling —('tcp', host, port),('unix', path),('udp', ...),('tipc', stype, inst, scope). Thenwrap_address()collapses from an order-sensitivematchto_address_types[addr[0]], and the whole collision class stops existing. Note this also aligns the on-wire form withmk_maddr()/parse_maddr(), so the two representations stop being independent inventions.Two consequences to plan for:
- it’s a wire-format change (
SpawnSpec,_root_mailbox,_registry_addrs) plus every test fixture and downstream config (piker’s[network]table). It wants its own migration commit, landed before any new backend, not smuggled into one. - it’s the moment to stop handing raw unwrapped tuples to users at all. The long-term shape is:
Addresssubtypes are the public currency andUnwrappedAddressbecomes an internal serialization detail — the same disciplineipaddressuses (you passIPv4Address, not a 4-tuple). Public API should acceptAddress|maddr-strand treat bare tuples as legacy-tolerated input, ideally deprecated.
- it’s a wire-format change (
.get_random()must be collision-free without a live runtime. See theUDSAddress.get_random()uuid-token comment (_uds.py:207-220): with nocurrent_actor()the sockname degenerates to a pure fn of(prefix, pid)and two calls in one proc alias. Mix in auuid4().hex[:8]token..bindspacesemantics: “the address’ bindable space” — ip/host fortcp, the socket-file directory foruds. For the new backends: the TIPC scope (§1 of plan 01), the iroh ALPN + relay/discovery realm (plan 02). Do not overload this transport-level bind selector with process namespace lifecycle. Plan 03 augments an maddr/address declaration with a serializableBindspaceSpecand a scoped, non-serializableBindspaceHandle; the latter owns namespace identity/FD/lifetime and is consumed at spawn bootstrap before a concrete address reaches transport bind.Address.namespaceis already spec’d in the Protocol as “the if-available OS-specific network namespace key” and is currently unimplemented by both backends — plan 03 is the first real consumer.
1.2 module-level listener lifecycle
async def start_listener(
addr: <Proto>Address,
**kwargs,
) -> trio.SocketListener # or a trio.abc.Listener, see §3
...
def close_listener( # OPTIONAL
addr: <Proto>Address,
lstnr: trio.abc.Listener,
) -> None:
...close_listener() is optional; Endpoint.close_listener() (_server.py:674) getattrs it and treats absence as “closing is implicit”. uds needs it (unlinks the sock-file), tcp does not.
1.3 the ONE piece of reflection you must not break
Endpoint.start_listener() (_server.py:656):
tpt_mod: ModuleType = inspect.getmodule(self.addr)
lstnr = await tpt_mod.start_listener(addr=self.addr)The transport module is found by inspect.getmodule() on the Address instance. Therefore: the Address class and its start_listener()/close_listener() MUST live in the same module. Do not define the address type in _types.py or a _addrs.py and the listener elsewhere.
Immediately after, the same method does:
if (unwrapped := lstnr.socket.getsockname()) != self.addr.unwrap():
self.addr = self.addr.from_addr(unwrapped)i.e. it assumes lstnr.socket.getsockname() exists and that its return value is a valid from_addr() input. This is fine for TIPC (§3 of plan 01) and is the main integration hazard for iroh (§3 of plan 02) — plans that break it must say so explicitly and propose the upstream _server.py patch.
1.4 class Msgpack<Proto>Stream(MsgpackTransport)
Subclass tractor.ipc._transport.MsgpackTransport. You inherit all framing (<I 4-byte little-endian length prefix), msgspec codec ctx-var lookup, TransportClosed normalization, .drain(), __aiter__. You implement only:
| member | notes |
|---|---|
address_type |
the <Proto>Address class |
layer_key: int |
OSI-ish layer, 4 for both current backends |
maddr @property |
-> Multiaddr\|str, via mk_maddr(self.raddr) |
connected(self) -> bool |
tcp/uds both use self.stream.socket.fileno() != -1 |
connect_to(cls, addr, prefix_size=4, codec=None, **kw) |
@classmethod, returns an instance |
get_stream_addrs(cls, stream) -> (laddr, raddr) |
@classmethod, called from MsgpackTransport.__init__ |
MsgpackTransport.__init__ requires the object passed as stream to satisfy:
await stream.send_all(bytes)- usable as
tricycle.BufferedReceiveStream(transport_stream=stream), i.e.await stream.receive_some(n) trio.BrokenResourceError/trio.ClosedResourceError/ValueError('...unclean EOF...')on the failure paths that_iter_packets()andsend()alreadymatchon (_transport.py:221-304,:436-499).
That is trio.abc.Stream, not trio.SocketStream. The MsgTransport Protocol’s stream: trio.SocketStream annotation (_transport.py:83) is a lie of convenience — the actual MsgpackTransport.__init__ param is typed trio.abc.Stream and nothing in the msg path touches .socket. Only connected() (which each backend defines) and Endpoint.start_listener()’s getsockname() do.
1.5 verified-good news for socket-family backends
Both trio.SocketStream and trio.SocketListener are address-family agnostic. Verified against the installed trio (trio/_highlevel_socket.py): the only constructor checks are
isinstance(socket, trio.socket.SocketType)socket.type == SOCK_STREAM- (listener)
getsockopt(SOL_SOCKET, SO_ACCEPTCONN)is truthy, withOSErrorsuppressed (the macOS carve-out, which also covers exotic families that reject the opt)
There is no AF_* check and no IPPROTO_TCP hard dependency (TCP_NODELAY/TCP_NOTSENT_LOWAT are set under suppress(OSError)). Consequence: any SOCK_STREAM family CPython can create — including AF_TIPC — drops straight into the existing trio.SocketStream + trio.serve_listeners() path. This is why plan 01 is small and plan 02 is not.
2. Registration tables (the full wiring checklist)
Adding a backend touches these and only these:
tractor/runtime/_state.py:46TransportProtocolKey = Literal['tcp', 'uds', ...]— add the key. ThisLiteralis the canonical set;_testing/pytest.pydrives--tpt-protovalidation off_addr._address_types, and the spawn-backend fixture already models the “drive-the-set-from-the-Literal” pattern (pytest.py:870-880) — do the same rather than hardcoding.tractor/discovery/_addr.py:173_address_types: bidict—{'<key>': <Proto>Address}. Note it is abidict, so the mapping must stay 1:1.tractor/discovery/_addr.py:181_default_lo_addrs—'<key>': <Proto>Address.get_root().unwrap(). ⚠️ this dict is built at import time, soget_root()must not require a live runtime, a loaded kernel module, or network I/O. (UDSAddress.def_bindspace = get_rt_dir()is the precedent for “cheap, pure, filesystem- ish”.) A backend whose root addr needs I/O must make this entry lazy — propose that refactor explicitly.tractor/discovery/_addr.py:230wrap_address()match— add a case iff yourunwrapped_typeisn’t already uniquely matched. Preferably do the proto-key migration in §1.1 first, after which this step becomes a one-line_address_typesentry instead of an order-sensitivecase.tractor/ipc/_types.py—Addressunion alias,_msg_transportslist,_key_to_transport[('msgpack', key)],_addr_to_transport[<Proto>Address].tractor/ipc/_types.py:92transport_from_stream()— thesock.familymatch. For a non-socket stream type (iroh) this needs a different discriminator; see plan 02 §3.3.tractor/discovery/_multiaddr.py—_tpt_proto_to_maddr, and acasein bothmk_maddr()andparse_maddr().tractor/ipc/__init__.py— re-export if the backend has a public surface.tractor/_testing/addr.py::get_rando_addr()— per-proto branch so the whole suite can run under--tpt-proto <key>.pyproject.toml— new deps go in an optional extra, never in[project].dependencies. See §5.
3. Where the trio.SocketListener assumption is load-bearing
_serve_ipc_eps() (_server.py:1041) annotates listener: trio.abc.Listener and hands the list to trio.serve_listeners(handler=handle_stream_from_peer, listeners=..., handler_nursery=stream_handler_tn). trio.serve_listeners itself is generic over trio.abc.Listener. So the only SocketListener-specific code in the server path is the getsockname() reconciliation in Endpoint.start_listener() (§1.3) and the type annotations.
handle_stream_from_peer() (_server.py:298) then does Channel.from_stream(stream) → transport_from_stream(stream) → sock.family match (§2.6).
Therefore: a non-socket backend needs (a) a trio.abc.Listener subclass, (b) a change to Endpoint.start_listener() to not blindly getsockname(), and (c) a change to transport_from_stream()’s discrimination. All three are small, upstream-able, and should be landed as their own prep PR before the backend itself — see plan 02 §3.
4. Handshake / discovery invariants you inherit
- Every accepted stream immediately does
chan._do_handshake(aid=actor.aid); a peer that fails it is logged atruntimeand dropped, not raised (_server.py:334-365). Discovery-sys “pings” rely on this, so yourconnect_to()must raise something that normalizes toTransportClosed/ConnectionErroron a dead peer, never a novel exception type. _root.py:381-406fail-fasts when aregistry_addrsentry’sproto_keyis not inenable_transports. Your key must be spellable in both._root.py:256currently enforceslen(enable_transports) == 1. Multi-tpt actors are a separate work item; none of these three plans may depend on lifting it.- Sub-actor bind addrs come from
_runtime.py:1600-1610: for each key in the parent-suppliedenable_transports,get_address_cls(key).get_random(). Soget_random()runs in the child, post-fork, pre-listen. Anything it needs (kernel module, netns membership, an iroh secret key) must already be true at that moment.
5. Dependency policy
[project].dependencies stays lean (see the boot-latency work, gh #470: import tractor is budgeted at ~0.145s). Every new backend dep is an extra:
[project.optional-dependencies]
tipc = [] # stdlib-only!
quic = ["iroh>=0.35"] # pin per plan 02 §1
wg = ["pyroute2>=0.9"] # pin per plan 03 §1
and every backend module must be import-lazy: a tractor/ipc/_<proto>.py that imports its 3rd-party dep at module scope must not be imported by tractor/__init__.py, tractor/ipc/__init__.py, or tractor/discovery/_addr.py’s import-time table construction. The _addr._default_lo_addrs eager-dict (§2.3) is the trap: keep the backend’s get_root() dep-free, or make that table lazy.
6. Test-harness plumbing (identical for all three)
--tpt-proto <key>(_testing/pytest.py:409) selects the session-wide proto; thetpt_protofixture mutates_state._def_tpt_proto+_runtime_vars['_enable_tpts'](pytest.py:807-835). Adding the key to_address_typesis what makes--tpt-proto <key>legal (pytest.py:795-800asserts the lookup).- The acceptance bar for every backend is: the entire existing suite passes under
--tpt-proto <key>, unmodified. That is the whole point of the abstraction. Backend-specific unit tests go intests/ipc/test_each_tpt.py(the existingtest_uds_bindspace_created_implicitly/test_uds_double_listen_raises_connerrare the model). - Capability gating: each backend needs a cheap, pure predicate + a
pytest.mark.skipif, because these are all environment-dependent. Verified example: on this dev boxsocket.socket(AF_TIPC, SOCK_STREAM)raisesOSError(97, 'Address family not supported by protocol')because thetipcmodule isn’t loaded. Put the predicate in the backend module (so apps can use it too), not in the test. - New pytest marks must be registered in
pyproject.toml, per the project’s fix-warnings-at-source rule (gh #469).
7. Code style (non-negotiable, matches the repo)
- module header tagline:
# tractor: distributed structured concurrency.for new files (not the legacystructured concurrent "actors".form the existing_tcp.pycarries). - AGPL header block copied verbatim from
_tcp.py. from __future__ import annotationsfirst.- annotate everything, including locals:
sockpath: Path = addr.sockpath. match/caseoverisinstancechains for address and error dispatch.- multi-line call/
importstyle with trailing commas. - never emit a whitespace-only line.
- error messages are multi-line f-strings ending in
\n, with thef'...\n' f'...\n'implicit-concat layout and the>[/[>/<=(nested-op sigils where anest_from_op()is in play. - prefer pure functions + module-level helpers over methods; keep
Addresstypes data-only. Where a helper needs scoped setup/teardown, it’s an@acm— not a class with.start()/.stop(). - pure getters: no
get_*(..., mutate=True)flags; split into a read-only getter and an explicit sibling setter.
8. Cross-plan sequencing
The three are independent except:
- plan 02 (iroh) needs the
_server.py/transport_from_stream()generalization (§3) — plan 01 does not, and should therefore land first as the cheap proof that the table-registration story works for a genuinely new proto. - plan 03 (wg) composes under whatever L4 tpt is in use and its netns work is what finally implements
Address.namespace. It can land before or after 02, but itsTunnelledAddressdesign must be reviewed against plan 02’s address shape so the “tunnelled maddr” grammar (gh #443) covers/…/quic-v1/p2p/…inner addrs too. - All three want first-class
wg/quic/tipcprotos inpy-multiaddr; that upstream track is gh #483 and multiformats/py-multiaddr#107/#108.