# `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`](./01_tipc_backend.md) — `AF_TIPC`
  (gh #378)
- [`02_quic_iroh_backend.md`](./02_quic_iroh_backend.md) — QUIC
  via `iroh` FFI, uniffi-async rewritten onto `trio` (gh #353)
- [`03_wg_tunnel_bindspace.md`](./03_wg_tunnel_bindspace.md) —
  WireGuard (and other shuttle-able) tunnels as a *nested
  bindspace* layer via `pyroute2` (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 through `msgspec` and
  through `wrap_address()`.** It is what actually crosses the
  wire in `SpawnSpec`/`_root_mailbox`/`_registry_addrs`, and it
  is what `Actor.reg_addrs` and every test compares against. If
  your unwrapped form is not *uniquely* pattern-matchable
  against the other backends' forms in
  `wrap_address()` (`_addr.py:230`), you have a bug that
  manifests as the wrong transport being loaded — the file's own
  `XXX NOTE` warns 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 from `TCPAddress` — 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 `multiaddr` protocol name as the
  canonical spelling** — `('tcp', host, port)`,
  `('unix', path)`, `('udp', ...)`, `('tipc', stype, inst,
  scope)`. Then `wrap_address()` collapses from an
  order-sensitive `match` to `_address_types[addr[0]]`, and the
  whole collision class stops existing. Note this *also* aligns
  the on-wire form with `mk_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: `Address` subtypes
    are the public currency and `UnwrappedAddress` becomes an
    internal serialization detail — the same discipline
    `ipaddress` uses (you pass `IPv4Address`, not a 4-tuple).
    Public API should accept `Address|maddr-str` and treat bare
    tuples as legacy-tolerated input, ideally deprecated.
- **`.get_random()` must be collision-free without a live
  runtime.** See the `UDSAddress.get_random()` uuid-token
  comment (`_uds.py:207-220`): with no `current_actor()` the
  sockname degenerates to a pure fn of `(prefix, pid)` and two
  calls in one proc alias. Mix in a `uuid4().hex[:8]` token.
- **`.bindspace` semantics**: "the address' bindable space" —
  ip/host for `tcp`, the socket-file *directory* for `uds`. For
  the new backends: the TIPC *scope* (§1 of plan 01), the iroh
  *ALPN + relay/discovery realm* (plan 02), the netns (plan 03).
  `Address.namespace` is 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

```python
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`) `getattr`s 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`):

```python
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:

```python
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()` and `send()` already `match` on
  (`_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,
  with `OSError` **suppressed** (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:

1. `tractor/runtime/_state.py:46`
   `TransportProtocolKey = Literal['tcp', 'uds', ...]` — add the
   key. This `Literal` is the canonical set; `_testing/pytest.py`
   drives `--tpt-proto` validation 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.
2. `tractor/discovery/_addr.py:173` `_address_types: bidict` —
   `{'<key>': <Proto>Address}`. Note it is a **`bidict`**, so
   the mapping must stay 1:1.
3. `tractor/discovery/_addr.py:181` `_default_lo_addrs` —
   `'<key>': <Proto>Address.get_root().unwrap()`.
   ⚠️ this dict is built at **import time**, so
   `get_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.
4. `tractor/discovery/_addr.py:230` `wrap_address()` `match` —
   add a case iff your `unwrapped_type` isn't already uniquely
   matched. **Preferably do the proto-key migration in §1.1
   first**, after which this step becomes a one-line
   `_address_types` entry instead of an order-sensitive `case`.
5. `tractor/ipc/_types.py` — `Address` union alias,
   `_msg_transports` list, `_key_to_transport[('msgpack', key)]`,
   `_addr_to_transport[<Proto>Address]`.
6. `tractor/ipc/_types.py:92` `transport_from_stream()` — the
   `sock.family` `match`. For a non-socket stream type (iroh)
   this needs a different discriminator; see plan 02 §3.3.
7. `tractor/discovery/_multiaddr.py` —
   `_tpt_proto_to_maddr`, and a `case` in both `mk_maddr()` and
   `parse_maddr()`.
8. `tractor/ipc/__init__.py` — re-export if the backend has a
   public surface.
9. `tractor/_testing/addr.py::get_rando_addr()` — per-proto
   branch so the whole suite can run under `--tpt-proto <key>`.
10. `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 at `runtime` and dropped, **not** raised
  (`_server.py:334-365`). Discovery-sys "pings" rely on this,
  so your `connect_to()` must raise something that normalizes
  to `TransportClosed`/`ConnectionError` on a dead peer, never
  a novel exception type.
- `_root.py:381-406` fail-fasts when a `registry_addrs` entry's
  `proto_key` is not in `enable_transports`. Your key must be
  spellable in both.
- `_root.py:256` currently enforces `len(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-supplied
  `enable_transports`, `get_address_cls(key).get_random()`.
  So `get_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:

```toml
[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; the `tpt_proto` fixture mutates
  `_state._def_tpt_proto` + `_runtime_vars['_enable_tpts']`
  (`pytest.py:807-835`). Adding the key to `_address_types` is
  what makes `--tpt-proto <key>` legal (`pytest.py:795-800`
  asserts 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 in `tests/ipc/test_each_tpt.py` (the existing
  `test_uds_bindspace_created_implicitly` /
  `test_uds_double_listen_raises_connerr` are 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 box
  `socket.socket(AF_TIPC, SOCK_STREAM)` raises
  `OSError(97, 'Address family not supported by protocol')`
  because the `tipc` module 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 legacy
  `structured concurrent "actors".` form the existing `_tcp.py`
  carries).
- AGPL header block copied verbatim from `_tcp.py`.
- `from __future__ import annotations` first.
- annotate *everything*, including locals:
  `sockpath: Path = addr.sockpath`.
- `match`/`case` over `isinstance` chains for address and
  error dispatch.
- multi-line call/`import` style with trailing commas.
- never emit a whitespace-only line.
- error messages are multi-line f-strings ending in `\n`, with
  the `f'...\n' f'...\n'` implicit-concat layout and the
  `>[`/`[>`/`<=(` nested-op sigils where a `nest_from_op()` is
  in play.
- prefer pure functions + module-level helpers over methods;
  keep `Address` types 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 its
  `TunnelledAddress` design 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`/`tipc` protos in
  `py-multiaddr`; that upstream track is gh #483 and
  multiformats/py-multiaddr#107/#108.
