TIPC (draft) support 😲 - #493
Draft
goodboy wants to merge 14 commits into
Draft
Conversation
`pformat_boxed_tb()` has never accepted an `indent` kwarg but `pformat_caller_frame(box_tb=True)` has been passing one since `888af602`. Nothing in the suite covered the branch, so the `TypeError` only ever surfaced from `_mk_send_mte()` — i.e. EVERY send-side `MsgTypeError` blew up while formatting itself and masked the real msg-spec violation behind a bogus `TypeError`. Red on purpose per the test-first convention; the 1-line fix lands next. Also pin `pformat_boxed_tb()`s signature so a future typo'd kwarg fails loudly at the call site instead of only when some rare error path runs. (this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
Just drop it — `pformat_boxed_tb()` spells its knobs `tb_box_indent`/`tb_body_indent`, and that fn's default (1-space box indent) is what the caller wanted anyway. Regressed-by: 888af60 (`pformat_cs()` mv into `.devx.pformat`) Found-via: `/run-tests` test_pformat_caller_frame_renders (this patch was generated in some part by `claude-code` using `claude-opus-5` (`anthropic`))
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`))
goodboy
force-pushed
the
wkt/tipc_backend_378
branch
from
August 14, 2026 14:02
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.
Comment on lines
+268
to
+270
| case ('tipc', *_): | ||
| cls = TIPCAddress | ||
|
|
Comment on lines
+134
to
+140
| if maddr_str.startswith(_tipc_maddr_prefix): | ||
| _, _, stype, instance, scope = maddr_str.split('/') | ||
| return TIPCAddress( | ||
| _stype=int(stype), | ||
| _instance=int(instance), | ||
| _scope=int(scope), | ||
| ) |
Comment on lines
+177
to
+185
| if _tipc_avail is None: | ||
| try: | ||
| socket.socket( | ||
| AF_TIPC, | ||
| SOCK_STREAM, | ||
| ).close() | ||
| _tipc_avail = True | ||
| except OSError: | ||
| _tipc_avail = False |
| @@ -117,6 +117,12 @@ class UDSAddress( | |||
| unwrapped_type: ClassVar[type] = tuple[str, int] | |||
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`))
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Finally attempting to implement #378, per #492 🏄🏼
Notes and ponderings..
a lovely thing to come out of adopting and/or leaning into
use of this proto is it already contains a built-in discovery
system which would avoid research-n-developing something more
involved on our own medium-term, per our oustandings:
there's a lot to leverage from the sophisticated msging
system including load-balancing in
ideal cases and fail-over connectivity for worst.
Things we need before landing ideally,
wga
tipcwhich will have a diff addr schema from mostposix-socket APIs; something like,
/tipc/<stype>/<instance>/<scope>testing over real clusters, ideally distilled into the
pytestharness with as many (
0mqand/orerlanginspired) examplesas possible Bo