Add an AF_TIPC transport backend - #493
Conversation
a2e6c10 to
51d7133
Compare
There was a problem hiding this comment.
Pull request overview
This PR introduces an initial AF_TIPC transport backend and the supporting wiring needed to register it as a first-class IPC transport alongside existing tcp and uds backends. It also widens the on-wire “unwrapped address” representation to accommodate proto-keyed transport shapes and adds targeted tests (including kernel-gated tests) to validate behavior.
Changes:
- Add a new TIPC transport backend (
tractor.ipc._tipc) and register it in transport/address dispatch tables. - Widen
UnwrappedAddressin the wire types (SpawnSpec) and discovery layer to support proto-keyed/variadic address shapes. - Improve listener-address reconciliation via
Address.rebind_from_sockname, add/tipc/...multiaddr parsing/formatting, and expand tests (TIPC + server reconciliation + devx pformat).
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tractor/runtime/_state.py | Adds 'tipc' to the supported transport protocol key literal. |
| tractor/msg/types.py | Introduces a variadic UnwrappedAddress wire alias and updates SpawnSpec address fields. |
| tractor/ipc/_uds.py | Adds rebind_from_sockname opt-in for UDS listener reconciliation. |
| tractor/ipc/_types.py | Registers TIPC address/transport types and adds AF_TIPC detection in transport_from_stream(). |
| tractor/ipc/_tipc.py | New TIPC backend implementing TIPCAddress, listener publish, and msgpack stream support. |
| tractor/ipc/_tcp.py | Adds rebind_from_sockname=True to preserve port-0 bind reconciliation behavior. |
| tractor/ipc/_server.py | Gates post-bind address reconciliation on Address.rebind_from_sockname. |
| tractor/discovery/_multiaddr.py | Adds TIPC mapping and interim /tipc/... string parsing/formatting. |
| tractor/discovery/_addr.py | Registers TIPCAddress and adds proto-keyed dispatch support in wrap_address(). |
| tractor/devx/pformat.py | Removes an invalid kwarg passed to pformat_boxed_tb(). |
| tractor/_testing/pytest.py | Adds tipc marker and a generic backend capability gate via Address.is_available(). |
| tractor/_testing/addr.py | Adds random TIPC address generation for test isolation. |
| tests/ipc/test_tipc.py | Adds address-algebra + kernel-gated integration tests for the TIPC backend. |
| tests/ipc/test_server.py | Adds regression test for listener .addr reconciliation for tcp and uds. |
| tests/discovery/test_multiaddr.py | Extends proto↔multiaddr mapping expectations to include tipc. |
| tests/discovery/conftest.py | Adds TIPC readiness probing via connect-by-name for daemon startup polling. |
| tests/devx/test_pformat.py | Adds coverage to ensure pformat_caller_frame() and boxed TB rendering don’t raise. |
Suppressed comments (1)
tractor/discovery/_multiaddr.py:71
mk_maddr()can now returnstrfor TIPC while the docstring still claims it constructs aMultiaddr. Update the docstring to reflect theMultiaddr|strreturn and thatstris used when the upstream proto isn’t registered yet.
Construct a `Multiaddr` from a tractor `Address` instance,
dispatching on the `.proto_key` to build the correct
multiaddr-spec-compliant protocol path.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| case ('tipc', *_): | ||
| cls = TIPCAddress | ||
|
|
There was a problem hiding this comment.
Addressed in a19a639d. wrap_address() now structurally matches only the exact 3- and 4-element proto-keyed TIPC forms, so a normal two-element address such as ("tipc", 80) continues through TCP dispatch. The regression test covers that collision explicitly.
| if maddr_str.startswith(_tipc_maddr_prefix): | ||
| _, _, stype, instance, scope = maddr_str.split('/') | ||
| return TIPCAddress( | ||
| _stype=int(stype), | ||
| _instance=int(instance), | ||
| _scope=int(scope), | ||
| ) |
There was a problem hiding this comment.
Addressed in a19a639d. /tipc parsing now routes the decoded tuple through TIPCAddress.from_addr(), preserving scope normalization and validation, and malformed forms are re-raised as an actionable Invalid TIPC multiaddr error. Regression coverage pins both behaviors.
| if _tipc_avail is None: | ||
| try: | ||
| socket.socket( | ||
| AF_TIPC, | ||
| SOCK_STREAM, | ||
| ).close() | ||
| _tipc_avail = True | ||
| except OSError: | ||
| _tipc_avail = False |
There was a problem hiding this comment.
Addressed in 145782d3 with a stricter platform gate rather than a post-creation sockopt probe. is_tipc_available() now returns False before socket creation on every non-Linux platform, so fallback UAPI integers cannot alias another OS socket family. The Darwin regression test verifies the socket constructor is never called.
Record #493's current draft head, #492's advanced planning tip and the exact restack sequence before final landing. Also, - keep the unrelated `pformat` red-test/fix pair ordered for its standalone `main` PR - distinguish the 17 substantive arc commits from the local-cache ignore - make the in-repo handoff authoritative over agent memory - preserve digest/drift checks for already-authorized forge writes (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
There was a problem hiding this comment.
[P1] Keep Linux-only constants out of macOS collection
tests/ipc/test_tipc.py:12-17 | confidence: high | category: portability
The module imports SOL_TIPC directly from socket. On macOS that symbol does not exist, and pytest collects this module even during a TCP run. Consequently, the entire macOS suite aborts during collection.
Evidence: the current #493 CI job fails with:
ImportError: cannot import name 'SOL_TIPC' from 'socket'
Recommendation: import SOL_TIPC from tractor.ipc._tipc, which already provides the cross-platform UAPI fallback, or otherwise guard the Linux-only import.
[P1] Include actor UUID in generated service names
tractor/ipc/_tipc.py:382-403 | confidence: high | category: correctness
With a live runtime, TIPCAddress.get_random() hashes only actor.aid.name and the process-local PID. Actors with the same name and PID on different hosts therefore publish the same cluster-wide TIPC name.
Because duplicate binds succeed and connections round-robin, this produces silent cross-tree routing rather than EADDRINUSE. PID overlap across hosts is normal, so this is materially more likely than the documented random 32-bit collision.
The collision test at tests/ipc/test_tipc.py:179-204 exercises only the no-runtime branch, which includes a per-call UUID and therefore misses the production branch.
Recommendation: derive the instance from actor.aid.uuid, optionally retaining the actor name for domain separation. Add a regression test where two actors have equal names/PIDs but different UUIDs.
Related follow-ups: #499 owns stable (name, uuid) derivation;
#501 owns post-bind collision verification.
[P2] Make the two-host walkthrough executable
examples/multihost/tipc_cluster/host_a_srv.py:50-55
examples/multihost/tipc_cluster/host_b_client.py:29-47
confidence: high | category: correctness
The advertised two-host example has two deterministic blockers:
host_ais the registrar itself and is never registered in its ownRegistrar._registry, sofind_actor('host_a')returnsNone.- If a portal is obtained another way,
Portal.open_context()receives the string'host_a_srv:echo', butNamespacePath.from_ref()requires a callable and accessesref.__module__.
The documented multihost demonstration therefore cannot reach echo().
Recommendation: spawn and register a named service actor on host A, and import/pass the enabled echo callable on host B, following the existing working RPC examples.
Related follow-up: #502 owns the reference multihost deployment.
[P2] Handle peer withdrawal during post-connect setup
tractor/ipc/_tipc.py:685-700 | confidence: high | category: reliability
MsgpackTIPCStream.__init__() tolerates getpeername() failing after a peer disconnects, but connect_to() immediately calls sock.getpeername() again without that protection.
A service that accepts and closes promptly can make this second call raise ENOTCONN. The successful dial then fails with a raw OSError, and the now-wrapped socket is not deterministically closed.
Recommendation: reuse the tolerant address already obtained during construction, retain destaddr without a port ID when unavailable, and keep socket/stream ownership under cleanup protection until initialization completes.
[P2] Do not fabricate topology-event scope
tractor/ipc/_tipc.py:941-949
tractor/ipc/_tipc.py:1003-1062
confidence: high | category: correctness
The scope argument is not encoded in struct tipc_subscr, the topology event contains no scope, and this implementation does not include it in the topology-server connection. Calls differing only by scope therefore send identical kernel requests.
Nevertheless, every received event is labeled with the caller-supplied scope. Consumers can consequently treat a publication as cluster-visible even though that scope was never observed or filtered.
Recommendation: represent event scope as unknown or explicitly as caller context. Remove the claim that subscriptions are per-scope unless an actual filtering mechanism is added.
Related follow-up: this is prerequisite feed correctness for #496.
[P2] Surface topology-stream overflow
tractor/ipc/_tipc.py:979-989 | confidence: high | category: reliability
When the memory channel fills, publication and withdrawal events are dropped with only a warning. A push registry can then permanently retain a withdrawn actor or miss a newly published actor while continuing to treat its state as authoritative.
Recommendation: terminate the stream with an explicit overflow/resync signal, or use backpressure if kernel-queue behavior permits it. Consumers must not continue without knowing their view is incomplete.
Related follow-up: #496 is the consumer that needs an authoritative
feed or an explicit resync signal.
[P3] Close finite subscriptions after timeout
tractor/ipc/_tipc.py:929-999 | confidence: high | category: reliability
A TIPC_SUBSCR_TIMEOUT event is forwarded and then the reader resumes waiting. The socket has no remaining subscription, so a caller that receives the timeout and asks for another event waits indefinitely.
Recommendation: return from _stream_name_events() after forwarding the timeout event so the send channel closes.
Related follow-up: #496 will consume this channel lifecycle.
Stack Context
The current submitted PR and contextual branches are not at one common tip:
- #493 head:
1298ba945f9d0a2dfcde014be39c10d8e9169878 - Forge-reported #493 base snapshot:
ee17ed9f6e13d955029b2f30c296d036aacc1434 - Current #492 /
ng_tpts_planning:d9a6e2e9b4213bb0900b99deda851cb2eaaa2b1b - Local merge base:
ee17ed9f - Current-upstream divergence: 2 commits on
ng_tpts_planning, 19 on #493
The staged wkt/addr_unpacking work is prospective context, not part of #493. Before combining them:
- Preserve #493’s variadic
UnwrappedAddressand TIPC registration; the WIP starts from the older two-element alias. - Peel
TunnelledAddressbeforetransport_from_addr()andEndpoint; both currently dispatch by exact wrapper type/module. - Generalize the WIP’s string-only
bindspaceannotation becauseTIPCAddress.bindspaceis an integer scope. - Either peel before endpoint reconciliation or delegate
rebind_from_sockname; TIPC must retainFalse. - The WIP’s intentionally lossy
.unwrap()remains compatible with TIPC’s four-element overlay descriptor.
The composed-address specification is tracked by #498, while #502
owns the resulting WireGuard/TIPC deployment.
The WIP’s unstaged .claude/settings.local.json was excluded.
Checks Run
- Submitted range whitespace check passed:
git diff --check ee17ed9f...1298ba94 - Prospective current-upstream range check passed:
git diff --check d9a6e2e9...1298ba94 - WIP staged diff check passed for its four staged source/test files.
- Current CI inspected at head
1298ba94. - Ubuntu TCP, UDS, TIPC, sdist, and Sphinx checks passed.
- macOS TCP failed during collection as described above.
Checks Not Run
No local tests or analyzers were executed because this was a read-only review. Existing CI results were used as runtime evidence.
Scope
Reviewed GitHub PR #493 at exact range ee17ed9f...1298ba94, covering all 30 changed paths. Provider diff-base OID was unavailable. Current #492 and the staged wkt/addr_unpacking changes were inspected as prospective integration context, not folded into the submitted PR diff.
(this review was generated in some part by opencode using gpt-5.6-sol
(openai))
Review follow-upAll findings from the full review are addressed at current head
Regression coverage was added with each fix. Local IPC verification passed ( |
Guard test for `.start_listener()`s post-bind `getsockname()`-vs-`.addr` round-trip, landed *before* that reconciliation gets gated on an opt-out `ClassVar`. - tcp: a `port=0` bind MUST still learn the kernel-picked port, since the reconciliation is the only path that ever does. - uds: the sock-file path must survive the `.from_addr()` round-trip unchanged. (this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
Gate `Endpoint.start_listener()`s `getsockname()`-vs-`.addr` reconciliation on a new per-addr-type `ClassVar[bool]`, set `True` on both `TCPAddress` and `UDSAddress` so existing behaviour is bit-for-bit unchanged. That reconciliation exists ONLY to learn a kernel-assigned port from a `port=0` tcp bind (its own comment says so). The incoming `tipc` backend (gh #378) has no late-binding analogue AND its `getsockname()` answers a `TIPC_ADDR_ID` port-id rather than the name-seq it published — rebinding from that would swap a dialable service name for an un-dialable, un-reconstructable port id. So opting out is semantically right rather than a hack. (this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
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`))
Wire `.connect_to()` (dial by service name), `.connected()` and `.get_stream_addrs()` on top of `MsgpackTransport` so `trio.SocketStream` + the existing `<I`-prefix framing carry `msgpack` msgs over TIPC unchanged. XXX both ends of a connected TIPC sock answer `TIPC_ADDR_ID` port-ids and a port-id carries NO service name, so, - the *dialling* side re-asserts the name it actually dialled over `._raddr` (same move as `MsgpackUDSStream`s peer-pid re-assign), - the *accepting* side keeps a `TIPC_NAME_UNKNOWN` sentinel plus the observed `(node, ref)`. It doesn't need more — the `Aid` from `._do_handshake()` already carries the peer's logical identity. Also normalize dial failures: TIPC answers an unpublished-name lookup with `EHOSTUNREACH`, which python maps to a **bare** `OSError` and NOT a `ConnectionError` subtype the way `ECONNREFUSED` maps to `ConnectionRefusedError`. The discovery-ping path needs the `ConnectionError` shape, so the `_reraise_as_connerr()` wrap is load-bearing, not polish. XXX ALSO tolerate a dead peer in `.get_stream_addrs()`! Unlike tcp/uds — where the kernel keeps answering the peer addr until *we* close — TIPC answers `ENOTCONN` once the peer is gone. Since `MsgpackTransport.__init__()` calls `.get_stream_addrs()` (via `Channel.from_stream()`) BEFORE the handshake, an unguarded `OSError` there escapes `handle_stream_from_peer()`s handshake tolerance (contract §4) and tears down the WHOLE actor. Any connect-then-drop peer — a port scan, a liveness probe, a cancelled dial — was a remote actor-kill. A dead peer must cost us an addr, not the runtime. Deats, - `TIPC_IMPORTANCE` exposed as a `.connect_to()` kwarg — TIPC can rank a conn's traffic under congestion, which no other backend can do. Defaulted to the kernel default for now; wiring the parent<->child chan to `HIGH` is a follow-up. - `TIPC_DEST_DROPPABLE = 0` so undeliverable msgs surface as errors instead of being silently dropped. (this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
`SpawnSpec.reg_addrs`/`.bind_addrs` pinned the wire shape to
a 2-tuple, so a `tipc` addr (`('tipc', stype, inst, scope)`)
died at the child w/ `msgspec.ValidationError: Expected array
of length 2, got 4` -> `invalid SpawnSpec IPC msg`.
Point those fields at `UnwrappedAddress` (which `SpawnSpec`s
own TODO already asked for) and widen the alias.
XXX VARIADIC (`tuple[str|int, ...]`) rather than a union of
the two concrete shapes, bc `msgspec` refuses a union holding
more than one array-like type.
?TODO, the real fix is the full proto-key migration (contract
§1.1) after which this becomes a tagged union keyed off elem
0 and per-proto validation comes back.
Note the alias is declared TWICE — `.msg.types` re-declares it
to dodge a circular import (`._addr` -> `.ipc._tcp` -> `.msg`)
and *that* copy is what actually validates the wire msg.
(this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
Wire the backend through every registration site (contract §2)
so `--tpt-proto tipc` is a first-class suite mode,
- `_state.TransportProtocolKey` gains the key
- `_addr._address_types` + `._default_lo_addrs`
- `_addr.wrap_address()` gets a `case ('tipc', *_)`; being a
4-elem seq it can't collide w/ `tcp`s or `uds`s 2-tuple
cases, so NO ordering hazard (and a bare seq-pattern matches
the `list` form `msgpack` decodes to).
- `_types`: the `Address` union, `_msg_transports`,
`_key_to_transport`, `_addr_to_transport` and the
`transport_from_stream()` family match. That last one keys
off `._tipc.AF_TIPC` (which carries the uapi fallback) NOT
`socket.AF_TIPC` which is linux-only.
Test-harness side,
- `get_rando_addr()` gains a `tipc` branch; `.get_random()`
already salts w/ `uuid4`+pid so both within- and cross-proc
isolation come for free.
- the `tpt_protos` fixture calls an addr-type's optional
`.is_available()` and `pytest.fail()`s w/ its reason. Keeps
a module-less box from turning `--tpt-proto tipc` into a few
hundred confusing connect-timeouts. Generic on purpose —
plans 02/03 need the same hook.
- the discovery `daemon` fixture's readiness probe learns to
dial a TIPC service name (it previously assumed tcp-or-uds
and blew up on the 4-tuple).
(this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
`mk_maddr()`/`parse_maddr()` learn,
/tipc/<stype>/<instance>/<scope>
mirroring how `uds` maps onto the spec-legal `/unix`.
XXX `str`-ONLY for now: there is no registered `/tipc` proto
in the multiaddr table (upstream track gh #483 +
multiformats/py-multiaddr#107) and `Multiaddr()` rejects an
unregistered name outright. `MsgTransport.maddr`s return type
is already `Multiaddr|str` (and `MsgpackUDSStream` already
exercises the `str` branch), so this fits — but it IS why gh
`parse_maddr()` therefore special-cases the `/tipc/` prefix
BEFORE handing anything to `Multiaddr()`.
Also drive the maddr mapping-table tests off `_address_types`
instead of a hardcoded len/dict so the next backend can't
fail them for the wrong reason.
(this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
Per contract §0 ("if this doc disagrees with the code, the code
wins; fix it in the same PR"), fold the step-0 probe results and
the as-landed impl back into `01_tipc_backend.md`.
Settled the two claims §9 flagged as unverified,
- `SO_ACCEPTCONN` on `AF_TIPC` **works** (answers `1`); we never
needed trio's `except OSError` carve-out.
- dup-name bind → **silent crosstalk is real**: both binds
succeed and dials alternate strictly, so a `.get_random()`
clash is never `EADDRINUSE`.
Corrections where the plan was wrong,
- §5.2's `tipc_event` is **48B not 40B** (`4+4+4+8+28`), and
python exposes `TIPC_WAIT_FOREVER` as `-1` so it needs masking
before packing as `'I'`.
- §7.2's pytest mark goes in `_testing/pytest.py::
pytest_configure()`, NOT `pyproject.toml` — the repo has no
`markers` ini table.
- §7.4's "10k → 10k distinct" is a ~1.2% flaky assert by
birthday bound on a 32b instance space; use `>= n-2` w/ the
arithmetic documented.
- §2.2's `unwrapped_type` and §3.2's `from_addr()` sketch still
showed the 2-tuple + the `'tipc:<stype>:<scope>'` prefix hack
that §2.2 itself had already withdrawn.
Two hazards the plan never anticipated, now recorded in §9,
- an unpublished-name dial answers `EHOSTUNREACH` which python
maps to a **bare `OSError`**, NOT a `ConnectionError` subtype,
so the `_reraise_as_connerr()` wrap is contract-§4 mandatory.
- a connect-then-drop peer answers `ENOTCONN` from
`getpeername()`, which — since `.get_stream_addrs()` runs
BEFORE the handshake — used to kill the whole actor.
Also withdraw §9's "fold a 6-byte digest into `(stype_low,
instance)`" escalation: varying `_stype` per-actor would need
65536 topology subscriptions and kills layer B outright.
(this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
First half of plan 01 §5.2 (layer B): the `struct` layouts and the `TIPCNameEvent` type for the kernel's *push-based* name table, w/o any socket plumbing yet. Pure-python, so it tests w/o a loaded `tipc` module. Deats, - `_SUBSCR_FMT = '=5I8s'` (28B `struct tipc_subscr`) and `_EVENT_FMT = '=10I8s'` (48B `struct tipc_event`). - `_mk_subscr()` masks the timeout: python exposes `TIPC_WAIT_FOREVER` as **`-1`** which `struct` flat refuses to pack into an unsigned `'I'`. - `_decode_name_event()` *drops* runt frames and unknown event codes rather than raising — a confused kernel must not be able to kill the reader task. XXX two corrections to what the plan §5.2 sketch claimed, both verified against a live kernel, - the event is **48B** (`4+4+4+8+28`), NOT 40. - native (`'='`) byte-order is **accepted**; publish+withdraw both round-tripped w/ the 28B subscription echoed back intact. So the proposed `_detect_topsrv_endianness()` `'>'` retry-probe is unnecessary and is NOT implemented. Note the event carries no *scope* — the name-table doesn't report one — so the decoded `.addr` echoes the subscription's own rather than pretending to observe it. (this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
Second half of layer B: an `@acm` yielding a `trio` receive-chan of `TIPCNameEvent` fed by a nursery-spawned reader on a `SOCK_SEQPACKET` conn to `TIPC_TOP_SRV`. This is the bit that makes #378's "end game cluster proto" claim real — the kernel *tells* us when any actor anywhere in the cluster publishes or withdraws a service name, so a registrar never has to poll `find_actor()`. Groundwork for the push registry in `discovery/_registry.py` (gh #184, #216). Deats, - `filt` selects granularity; `TIPC_SUB_SERVICE` is one event per *name*, `TIPC_SUB_PORTS` one per *publisher* — the latter makes the §2.3 duplicate-name/round-robin crosstalk case externally observable, which is how a push-registry could ever detect it. - a full event buf **drops** w/ a loud warning rather than blocking the reader; stalling it just backs up the kernel's own queue and loses the event less visibly. - `SOCK_SEQPACKET` is fine here bc this sock never goes through `MsgpackTransport` — the contract's "`SOCK_STREAM` only" rule is about `MsgTransport` streams, not this. XXX teardown order is load-bearing: cancel the nursery BEFORE closing the fd. `.close()`ing out from under a pending `.recv()` races — trio's retry can land on an already-freed fd and raise a bare `OSError(EBADF)` instead of the `ClosedResourceError` the reader guards for, which then escapes the nursery as an eg. (this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
Plan 01 §8's deployment deliverable, under `examples/multihost/` (like the `wg_lan` set) since these need the `tipc` kernel module — and, for the 2-host pair, a live bearer — so they can't satisfy `test_docs_examples.py`'s "walk `examples/` and assert rc == 0". `'multihost'` is already in that test's exclusion list. - `single_host.py` — boots a 4-actor tree and shells out to `tipc nametable show` before/during/after. Watching 4 service names appear in the KERNEL's table and vanish on teardown, entirely outside any `tractor` API, is the single best demo this backend has. - `watch_nametable.py` — the same story push-based, via `open_topology_events()`: live `[+] published` / `[-] withdrawn` as actors come and go. - `host_a_srv.py` + `host_b_client.py` — the cross-node pair. Note what's absent from both: any IP, hostname or port. Both sides name the same *service* and the kernel routes it. - `README.md` — the manual smoke test (bearer setup, `tipc link list` verify) per §7.3, plus the gotchas: silent crosstalk, graceful-close-looks-like-`ECONNRESET`, the interim maddr. Both single-host scripts were RUN against a live kernel and their real output is what's pasted in the README. (this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
Plan 01 §8's docs deliverable: `docs/guide/tipc.rst`, leading
w/ the `tipc nametable show` demo as the plan asked.
Frames the backend by what makes it different — every other tpt
gives you a pipe and leaves discovery to the registrar, whereas
TIPC's service names live in a kernel-maintained cluster-wide
name table, so a `.bind()` IS registration and a `.connect()` IS
the lookup. Then: push-based discovery via
`open_topology_events()`, scope-as-`.bindspace`, bearer setup
for spanning hosts, and the gotchas.
Also,
- roster it in `guide/index.rst` (prose list + toctree)
- `api/ipc.rst`'s transport line said `['tcp' | 'uds']` and
described only 2 unwrapped-addr shapes; now mentions `tipc`
and its proto-keyed `('tipc', stype, instance, scope)`.
Verified w/ a full `sphinx -b html` build: succeeded, page
renders, internal refs resolve.
(this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
Plan 01 §7.3's last item. The module ships w/ the standard
ubuntu kernel package but is NOT loaded by default, so the leg
gets a gated `sudo modprobe tipc` step plus a verify that
asserts `TIPCAddress.is_available()` before the suite runs —
i.e. a missing module fails w/ an actionable line instead of a
few hundred connect timeouts.
Deats,
- `tipc` added to the `tpt_proto` matrix axis, and excluded on
`macos-latest` bc `AF_TIPC` is a linux-kernel proto that
doesn't exist on darwin at all.
- `continue-on-error` is scoped to just this leg via
`${{ matrix.tpt_proto == 'tipc' }}` — GH's runners have never
been asked to `modprobe` for us, so it lands NON-blocking
until it's had a few green runs. Drop the gate then.
- if the runners do refuse, the documented fallback is a
container job w/ `--cap-add NET_ADMIN`.
Cross-node (bearer) TIPC still can't be CI'd; that stays the
manual smoke test in `examples/multihost/tipc_cluster/README.md`.
Partially addresses #420.
(this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
`gh issue create` caches bodies under the `<backend>/<repo>/<kind>/<num>.md` path — and that dir is named for the *service*, not the CLI, so the existing `gitea/` + `gh/` entries never covered it. Filing the `tipc` follow-ups (#495-501) is what surfaced it. (this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
Anticipating gh #502 — TIPC over a WireGuard mesh as our go-to multihost tpt deployment — plus a cold-start handoff for whoever (or whatever) picks this up next. The wg deats, both verified locally, - a wg iface is L3/`tun` (`POINTOPOINT,NOARP`, `link/none`, no L2 addr) so TIPC's `eth` media **cannot** bind it; the udp bearer is *mandatory* over wg, not merely an alternative. Also its ~1420 MTU sits under ethernet's 1500. - the composed deployment maddr is `/ip4/<pub>/udp/51820/wg/u<key>/tipc/<stype>/<inst>/<scope>`. XXX note the tipc segment has NO locative part unlike tcp's inner `/ip4/../tcp/..` — a service name is location-independent, so wg carries routing and tipc carries identity. That's the argument for one `/tipc` proto w/ a structured value in the #498 spec proposal. XXX ALSO correcting a premise: TIPC is **not** unencrypted. It ships AES-GCM crypto (`tipc node set key`, linux 5.9+) w/ cluster/master/per-node keys + rekeying. Those keys are symmetric+pre-shared tho, so wg is still preferred for public-key identity, NAT traversal, and one overlay every tpt can share. `01_tipc_HANDOFF.md` is deliberately provider-neutral: env setup, the hard-won kernel facts table, the two closed design decisions (+why), what landed, the pre-land TODOs and the repo's working conventions. (this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
Follow-on to 4aa7a89 now that the encryption premise is corrected: reframe *why* we want a `wg` mesh under TIPC (#502) rather than leaving a "wg adds the crypto TIPC lacks" reading lying around, since that reading is flat wrong. The motivation is different but still real, - TIPC's keys are **symmetric + pre-shared**, so distribution, rotation and revocation are all on the operator; `wg` brings public-key identity and a handshake. - `wg` is an overlay *every* tpt can sit on (tcp now, quic later), not a TIPC-only mechanism. - NAT traversal / roaming, which raw TIPC bearers have no story for at all. Which to actually default to wants **benchmarking** — native crypto skips a tunnel hop and may win for LAN-local clusters. Also lean much harder on the udp-bearer-only caveat in the handoff doc; it's the one that bites. A wg iface is L3/`tun` w/ no L2 addr, so there's no device for `media eth` to name — which means #378's "ethernet bearers pair most excellently w/ wg tunnelling" framing does NOT hold: on a given link the L2 path and the wg path are mutually exclusive. Any design assuming both is broken from the start. (this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
Record #493's current draft head, #492's advanced planning tip and the exact restack sequence before final landing. Also, - keep the unrelated `pformat` red-test/fix pair ordered for its standalone `main` PR - distinguish the 17 substantive arc commits from the local-cache ignore - make the in-repo handoff authoritative over agent memory - preserve digest/drift checks for already-authorized forge writes (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Restrict proto-key matching to numeric 3- or 4-element descriptors so a UDS directory named `tipc` stays UDS. Route `/tipc` parsing through `TIPCAddress.from_addr()` to normalize zone scope and report malformed input clearly. Also align UDS unwrapped metadata with its actual `(str, str)` shape. Keep the TIPC test module portable by importing `SOL_TIPC` from the backend's UAPI fallback instead of the host `socket` module. Review: PR #493 (copilot-pull-request-reviewer[bot],goodboy) #493 (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
TIPC service names span the cluster while PIDs remain host-local. Hashing only `(name, pid)` could therefore make same-named actors on different hosts silently share one round-robin service name. Derive the live-runtime seed from `Aid.uid` so the actor UUID separates those names while keeping each identity reproducible. Pin both properties with a deterministic regression test. Review: PR #493 (copilot-pull-request-reviewer[bot],goodboy) #493 (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Reject TIPC availability outside Linux before probing the fallback socket-family integer, which can alias an unrelated family on another OS. Keep dialled sockets under setup ownership through transport construction, then reuse the constructor's tolerant peer observation. A peer withdrawing after `.connect()` can no longer trigger a second raw `getpeername()` or leak setup resources. Review: PR #493 (copilot-pull-request-reviewer[bot],goodboy) #493 (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Stop labeling topology events with caller-supplied scope that the kernel never reports. Event addresses now carry an explicit unknown scope instead of fabricated reachability. Apply memory-channel backpressure rather than silently dropping publish/withdraw transitions, and close the stream after delivering the terminal event from a finite subscription. Review: PR #493 (copilot-pull-request-reviewer[bot],goodboy) #493 (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
A registrar root does not register itself in its own actor-name registry, so host B could never discover the advertised `host_a`. Boot that service as a child actor under the `TIPC` registrar instead. Import and pass the enabled `echo` callable to `.open_context()`; the prior module-path string could not produce a `NamespacePath`. Review: PR #493 (copilot-pull-request-reviewer[bot],goodboy) #493 (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Keep `_stream_name_events()` non-blocking so a slow memory-channel consumer cannot back up the kernel topology queue. Raise `TIPCNameEventOverflow` and end the subscription rather than drop a transition or let the socket reader stall. Discovery consumers must then resubscribe and rebuild their name-table view. Also, - document topology semantics and scope with Linux references - diagram the `.connect()`/`.getpeername()` withdrawal schedules - explain the child-service and callable requirements in the two-host example Review: PR #493 (copilot-pull-request-reviewer[bot],goodboy) #493 (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
The refreshed PR matrix passes on Ubuntu with the TIPC kernel module loaded, along with the TCP, UDS and macOS legs. Remove the temporary `continue-on-error` expression so future TIPC regressions block CI. Prompt-IO: ai/prompt-io/opencode/20260819T003326Z_53516b09_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Turn the physical-host sketch into an operator runbook covering cluster identity, interface and bearer setup, link validation, failure/rejoin testing, diagnostic capture and cleanup. Explain the cluster-domain-socket analogy and identify a future `pyroute2` TIPC codec as the path from manual `tipc(8)` commands to the same netlink management stack planned for WireGuard. Authorize `host_a_srv` by its stable import name so direct script execution does not expose only `__main__` while host B requests the callable's actual `NamespacePath`. Prompt-IO: ai/prompt-io/opencode/20260819T003327Z_53516b09_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Propose a fixed-width service endpoint carrying the TIPC type, instance and publication scope, with one canonical structured value that generic multiaddr parsers can compose normally. Retain the kernel-standard `tipc` name while using “Cluster Domain Sockets” as explanatory terminology. Document the binary and text encodings, WireGuard composition, deployment-management boundary, upstream sequence, test vector and open maintainer questions. Prompt-IO: ai/prompt-io/opencode/20260819T003328Z_53516b09_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Carry the WG-aware py-multiaddr rev into the refreshed lock while retaining `main`'s current dependency set. Keep TIPC's `Multiaddr` annotation off the eager import path, and extend lazy annotation checks for TIPC's interim `Multiaddr|str` shape. Prompt-IO: ai/prompt-io/opencode/20260830T045303Z_69a0e504_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
be108cc to
80e1ec6
Compare
Add an
AF_TIPCtransport backendMotivation
Finally attempting to implement #378, per #492 🏄🏼
Every other
tractortransport hands us a pipe and leaves discoveryto us: the registrar actor, the
find_actor()round-trip, the wholetractor.discoveryapparatus. TIPC is different in a way thatactually matters — its service names live in a cluster-wide name
table the kernel itself maintains. So a
.bind()is serviceregistration and a
.connect()-by-name is the lookup, resolved andload-balanced in-kernel.
That makes it the cheapest new backend we can add (stdlib-only, zero
new deps, and
trio.SocketStream/SocketListenerturn out to befully address-family agnostic) while simultaneously being the only
one that gives us cluster-wide discovery for free. It's also the
right first backend to land of the three planned in #492: unlike the
iroh/QUIC work it needs no generalization of_server.pyortransport_from_stream(), so it's a cheap proof that thetable-registration story works for a genuinely new proto.
A lovely side-effect of leaning into this proto is that it already
contains a built-in discovery system, which would let
us avoid R&D-ing something more involved of our own medium-term
(#184, #216). There's also a lot to leverage from the sophisticated
msging system including load-balancing in ideal cases and
fail-over connectivity for worst.
Src of research
The tipc.io docs are stale in places, so the kernel sources are
treated as the only normative reference throughout,
include/uapi/linux/tipc.h— address flavours, sockopts, thetopology
structsnet/tipc/socket.c,net/tipc/topsrv.cman 8 tipcEvery behavioural claim below was settled by probing a live kernel
(
modprobe tipc, py3.13) rather than reasoning from docs — severalturned out to contradict the plan.
Summary of changes
(stype, instance),wrapped as
TIPCAddress. Binding the singletonTIPC_ADDR_NAMESEQrange publishes it — it shows up in
tipc nametable show— andMsgpackTIPCStream.connect_to()dials it by name..unwrap()isproto-keyed as
('tipc', stype, inst, scope)using themultiaddrproto spelling so
wrap_address()can't confuse it with thetcp/uds2-tuples..bindspaceis the TIPC scope, which is about as literal areading of that property's "set of hosts this bind is reachable
from" docstring as exists.
TIPC_ZONE_SCOPEis deprecated/aliasedin modern kernels so it's folded to cluster on input.
Address.rebind_from_sockname: ClassVar[bool]gatesEndpoint.start_listener()'s post-bindgetsockname()reconciliation (cca3a70d). That reconciliation exists
only to learn a kernel-assigned port from a
port=0tcp bind;TIPC has no such late-binding and its
getsockname()answers aTIPC_ADDR_IDport-id, so rebinding from it would swap a dialableservice name for an un-dialable one.
Trueon tcp/uds keepstoday's behaviour bit-for-bit.
open_topology_events()subscribes toTIPC_TOP_SRVandyields a
trioreceive-chan ofTIPCNameEvent— push-baseddiscovery, where the kernel tells us the instant any actor anywhere
in the cluster publishes or withdraws a name. This is the
groundwork for a registrar that never polls
find_actor(). Eventdelivery now aborts explicitly on user-space overflow so a consumer
must resubscribe instead of silently trusting stale topology state.
checklist (
_state.TransportProtocolKey,_addr._address_types/._default_lo_addrs/wrap_address(),_types' four tables +transport_from_stream()), plustest-harness plumbing so
--tpt-proto tipcis a first-class suitemode.
str-only/tipc/<stype>/<instance>/<scope>maddrgrammar;
parse_maddr()special-cases the prefix beforeMultiaddr(), which would otherwise reject the unregistered protoname outright.
--tpt-proto=tipcCI leg with a gatedsudo modprobe tipcstep. The experimentalcontinue-on-errorwas removed afterrepeated green runs (partially addresses Run (various) test suite(s) under different tpt protocols in CI #420).
docs/guide/tipc.rstpage and anexamples/multihost/tipc_cluster/set. Both single-host exampleswere run against a live kernel and their real output is what's
pasted in the README. The two-physical-host example is now an
operator runbook covering cluster identity, bearer setup,
failure/rejoin and diagnostic capture; it introduces “Cluster
Domain Sockets” as the newcomer-facing explanation while retaining
tipcas the interoperable key. Plusai/tpt-backends/01_tipc_HANDOFF.md, a provider-neutral cold-starthandoff carrying the verified-behaviour table and closed decisions.
/tipcmultiaddr issue draft with a fixed-width(type, instance, scope)value, canonical structured text form,WireGuard composition and separation from future
pyroute2generic-netlink deployment management (#498).
Two fixes fell out that are not TIPC-specific,
pformat_caller_frame()was passing anindent=''kwargpformat_boxed_tb()has never accepted, so EVERY send-sideMsgTypeErrordied with aTypeErrorwhile formatting itself,masking the real msg-spec violation (f9f98eeb). Dates
to
888af602; present onmainand everywkt/*branch. It is nowisolated in PR #503 for landing before this stack.
SpawnSpec.reg_addrs/.bind_addrspinned the wire shape to a2-tuple, so every TIPC subactor died at
Expected array of length 2, got 4(22049794). Widened toUnwrappedAddress—which
SpawnSpec's own# TODOalready asked for — and note thealias had to become variadic (
tuple[str|int, ...]) bcmsgspecrefuses a union holding more than one array-like type.First real bite of the proto-key migration in Add impl plans for
TIPC/QUIC/wgtpt backends #492's sharedcontract.
Verified kernel behaviour
Several of these contradict what the plan assumed, and two were
hazards it never anticipated,
between them (six dials alternated strictly). So a
get_random()instance collision is silent crosstalk, never
EADDRINUSE—which is why the instance is a
blake2bdigest of the actoridentity.
EHOSTUNREACHinstantly,no SYN-timeout wait — better discovery-ping behaviour than TCP. But
python maps it to a bare
OSError, NOT aConnectionErrorsubtype the way
ECONNREFUSEDmaps toConnectionRefusedError, sothe
_reraise_as_connerr()normalization is required by the sharedcontract's handshake rules rather than being polish.
answers
ENOTCONNfromgetpeername()once the peer's gone(tcp/uds keep answering until we close), and
MsgpackTransport.__init__()calls.get_stream_addrs()beforethe handshake — so the
OSErrorescapedhandle_stream_from_peer()'s handshake tolerance. A port scan wasa remote actor-kill. Found by our own
daemonfixture's readinessprobe.
SO_ACCEPTCONNworks onAF_TIPC(answers1); trio'sexcept OSErrorcarve-out isn't load-bearing here after all.struct tipc_eventis 48 bytes, not 40, andnative
'='byte-order is accepted — so the plan's proposed_detect_topsrv_endianness()'>'-retry probe was deleted asunnecessary.
TIPC_WAIT_FOREVERis-1in python and must bemasked before packing as an unsigned field.
BrokenResourceError/ECONNRESETrather than a clean 0-byte EOF. Benign —
_iter_packets()alreadyclassifies it as a normal disconnect — but it looks alarming in
transportlogs.The plan doc was reconciled against all of the above in
7e20585f, per the shared contract's "if the doc disagrees
with the code, the code wins" rule.
Testing
The acceptance bar for any backend is that the entire existing
suite passes under it unmodified. The refreshed blocking matrix passes
on Linux with TCP, UDS and TIPC and on macOS with TCP, alongside sdist
and docs. Local verification collected 479 tests, passed all
43 IPC tests, and passed all 35 TIPC-specific tests.
TODOs before landing
pformatred-test/fix pair in #503 beforeAdd an
AF_TIPCtransport backend #493 — it is unrelated to TIPC and every branch has the bugblocking CI leg
AF_TIPCtransport backend #493 onto Add impl plans forTIPC/QUIC/wgtpt backends #492's current head, then ontomainonceAdd impl plans for
TIPC/QUIC/wgtpt backends #492 mergestesting over real clusters, ideally distilled into the
pytestharness with as many (
0mqand/orerlanginspired) examples aspossible Bo
/tipcmultiaddr protocol upstream (#498),similar to our recent one for
wg, after local reviewof its fixed-width schema and canonical text form
Future follow up
All filed as
follow-up-labelled issues,TIPC_IMPORTANCEsupervision QoS on the parent<->childchan. Genuinely novel: no other backend can rank a conn's traffic
under congestion.
TIPC_TOP_SRV-driven push registry indiscovery._registry. The consumer side ofopen_topology_events(), and what would move the needle on Discovery and concensus: research and discussion. #184 /Multi-root discovery: pragmatic, simple consensus. #216.
udpbearers overtwo distinct
wgpaths, rather than assuming two physical NICs./tipcmultiaddr spec submission, with the composed/ip4/…/udp/…/wg/u<key>/tipc/<stype>/<inst>/<scope>form as thereal target. Goes up alongside
wgmultiaddr protocol: upstream spec submission plan #483 and unblocks the "returnMultiaddreverywhere" item in Follow-up: multiaddr_support (PR #429) #443.bind already is a registration.
tractor.trionicsfan-out (explicitly NOT aMsgTransport).escalation if the
blake2bdigest ever proves too narrow.wgmesh, the intended referencemultihost deployment. NB the motivation is not confidentiality
(TIPC ships its own AES-GCM crypto); it's public-key identity, NAT
traversal and one overlay every tpt can share.
(this pr content was generated in some part by
claude-codeusingclaude-opus-5(anthropic))(this update was generated in some part by
opencodeusinggpt-5.6-sol(openai))Links
TIPC, the "end game" cluster proto maybe? #378TIPC/QUIC/wgtpt backends #492wgmultiaddr protocol: upstream spec submission plan #483pformat_caller_frame()render failure #503'wg'?) multiformats/py-multiaddr#107