Compare commits

..

6 Commits

Author SHA1 Message Date
Gud Boi a2e6c10118 Add the interim `/tipc/` maddr grammar
`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`))
2026-08-14 09:55:01 -04:00
Gud Boi 95360fcdf1 Register `tipc` in the tpt tables + test harness
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`))
2026-08-14 09:52:42 -04:00
Gud Boi e9cc5f4a7b Widen `UnwrappedAddress` to admit proto-keyed addrs
`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`))
2026-08-14 09:52:33 -04:00
Gud Boi 5f0334c6c3 Add `MsgpackTIPCStream`, the `AF_TIPC` `MsgTransport`
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`))
2026-08-14 09:52:25 -04:00
Gud Boi 175712ae04 Add `TIPCAddress` + `start_listener()`, gh #378
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`))
2026-08-14 09:52:17 -04:00
Gud Boi 2637e6bdc5 Add `Address.rebind_from_sockname` opt-out
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`))
2026-08-14 09:50:27 -04:00
7 changed files with 142 additions and 10 deletions

View File

@ -84,6 +84,35 @@ def _wait_for_daemon_ready(
timeout=poll_interval,
):
return
elif tpt_proto == 'tipc':
# TIPC — `reg_addr` is the proto-keyed
# `('tipc', stype, instance, scope)` per
# `tractor.ipc._tipc.TIPCAddress.unwrap()`.
#
# NOTE, connecting *by name* IS the readiness
# probe: until the daemon `.bind()`s (i.e.
# publishes) the name, the kernel answers
# `EHOSTUNREACH` immediately — no timeout wait.
from tractor.ipc._tipc import (
AF_TIPC,
TIPC_ADDR_NAME,
)
_, stype, instance, scope = reg_addr
sock = socket.socket(AF_TIPC, socket.SOCK_STREAM)
try:
sock.settimeout(poll_interval)
sock.connect((
TIPC_ADDR_NAME,
stype,
instance,
0, # domain: 0 == "anywhere in scope"
scope,
))
return
finally:
sock.close()
else:
# UDS — `reg_addr` is a `(filedir, sockname)`
# tuple per `tractor.ipc._uds.UDSAddress.unwrap`.

View File

@ -19,7 +19,10 @@ from tractor.discovery._multiaddr import (
_tpt_proto_to_maddr,
_maddr_to_tpt_proto,
)
from tractor.discovery._addr import wrap_address
from tractor.discovery._addr import (
wrap_address,
_address_types,
)
def test_tpt_proto_to_maddr_mapping():
@ -30,7 +33,12 @@ def test_tpt_proto_to_maddr_mapping():
'''
assert _tpt_proto_to_maddr['tcp'] == 'tcp'
assert _tpt_proto_to_maddr['uds'] == 'unix'
assert len(_tpt_proto_to_maddr) == 2
assert _tpt_proto_to_maddr['tipc'] == 'tipc'
# NOTE, drive the expected set off the registration table
# (per the "drive-the-set-from-the-`Literal`" pattern) so
# adding a backend can't fail this for the wrong reason.
assert set(_tpt_proto_to_maddr) == set(_address_types)
def test_mk_maddr_tcp_ipv4():
@ -153,9 +161,12 @@ def test_maddr_to_tpt_proto_mapping():
'''
assert _maddr_to_tpt_proto == {
'tcp': 'tcp',
'unix': 'uds',
maddr_proto: proto_key
for proto_key, maddr_proto in _tpt_proto_to_maddr.items()
}
assert _maddr_to_tpt_proto['tcp'] == 'tcp'
assert _maddr_to_tpt_proto['unix'] == 'uds'
assert _maddr_to_tpt_proto['tipc'] == 'tipc'
def test_parse_maddr_tcp_ipv4():

View File

@ -99,6 +99,20 @@ def get_rando_addr(
assert addr.sockpath.resolve()
testrun_reg_addr = addr.unwrap()
# NOTE, `.get_random()` already derives the service
# *instance* from a `uuid4`+pid-salted seed, so both the
# within- and cross-proc isolation the other 2 protos
# hand-roll above comes for free.
#
# XXX matters MORE here than for tcp/uds: a TIPC name
# clash doesn't raise `EADDRINUSE`, it silently
# round-robins connects between both publishers.
case 'tipc':
from tractor.ipc._tipc import TIPCAddress
addr: TIPCAddress = addr_type.get_random()
assert addr.is_valid
testrun_reg_addr = addr.unwrap()
# XXX, as sanity it should never the same as the default for the
# host-singleton registry actor.
assert def_reg_addr != testrun_reg_addr

View File

@ -38,8 +38,23 @@ if TYPE_CHECKING:
_tpt_proto_to_maddr: dict[str, str] = {
'tcp': 'tcp',
'uds': 'unix',
'tipc': 'tipc',
}
# XXX, there is NO `/tipc` in the multiaddr protocol table yet
# (upstream track: gh #483 + multiformats/py-multiaddr#107), and
# `Multiaddr()` rejects an unregistered proto name outright.
#
# So until that lands `tipc` maddrs stay **`str`**-only — which
# `MsgTransport.maddr`s `Multiaddr|str` return type already
# allows and `MsgpackUDSStream.maddr` already exercises — and
# `parse_maddr()` special-cases the prefix BEFORE handing
# anything to `Multiaddr()`.
#
# This is also why gh #443's "always return `Multiaddr`" item
# stays blocked.
_tipc_maddr_prefix: str = '/tipc/'
# reverse mapping: multiaddr protocol name -> tractor proto_key
_maddr_to_tpt_proto: dict[str, str] = {
v: k for k, v in _tpt_proto_to_maddr.items()
@ -49,7 +64,7 @@ _maddr_to_tpt_proto: dict[str, str] = {
def mk_maddr(
addr: 'Address',
) -> Multiaddr:
) -> Multiaddr|str:
'''
Construct a `Multiaddr` from a tractor `Address` instance,
dispatching on the `.proto_key` to build the correct
@ -75,6 +90,18 @@ def mk_maddr(
f'/{net_proto}/{host}/{maddr_proto}/{port}'
)
# NOTE, interim `str`-only grammar (see the
# `_tipc_maddr_prefix` note above),
#
# /tipc/<stype>/<instance>/<scope>
#
# mirroring how `uds` maps onto the spec-legal `/unix`.
case 'tipc':
_, stype, instance, scope = addr.unwrap()
return (
f'/{maddr_proto}/{stype}/{instance}/{scope}'
)
case 'uds':
filedir, filename = addr.unwrap()
filepath = Path(filedir) / filename
@ -100,6 +127,17 @@ def parse_maddr(
# lazy imports to avoid circular deps
from tractor.ipc._tcp import TCPAddress
from tractor.ipc._uds import UDSAddress
from tractor.ipc._tipc import TIPCAddress
# XXX MUST come before `Multiaddr()` which rejects the
# not-yet-registered `/tipc` proto name outright.
if maddr_str.startswith(_tipc_maddr_prefix):
_, _, stype, instance, scope = maddr_str.split('/')
return TIPCAddress(
_stype=int(stype),
_instance=int(instance),
_scope=int(scope),
)
maddr = Multiaddr(maddr_str)
proto_names: list[str] = [

View File

@ -38,17 +38,23 @@ from tractor.ipc._uds import (
UDSAddress,
MsgpackUDSStream,
)
from tractor.ipc._tipc import (
AF_TIPC,
TIPCAddress,
MsgpackTIPCStream,
)
# if TYPE_CHECKING:
# from tractor._addr import Address
Address = TCPAddress|UDSAddress
Address = TCPAddress|UDSAddress|TIPCAddress
# manually updated list of all supported msg transport types
_msg_transports = [
MsgpackTCPStream,
MsgpackUDSStream
MsgpackUDSStream,
MsgpackTIPCStream,
]
@ -59,15 +65,17 @@ _key_to_transport: dict[
] = {
('msgpack', 'tcp'): MsgpackTCPStream,
('msgpack', 'uds'): MsgpackUDSStream,
('msgpack', 'tipc'): MsgpackTIPCStream,
}
# convert an Address wrapper to its corresponding transport type
_addr_to_transport: dict[
Type[TCPAddress|UDSAddress],
Type[TCPAddress|UDSAddress|TIPCAddress],
Type[MsgTransport]
] = {
TCPAddress: MsgpackTCPStream,
UDSAddress: MsgpackUDSStream,
TIPCAddress: MsgpackTIPCStream,
}
@ -108,6 +116,12 @@ def transport_from_stream(
case socket.AF_UNIX:
transport = 'uds'
# NOTE, `AF_TIPC` is linux-only in CPython so we
# match the `._tipc` constant (which carries a uapi
# fallback) rather than `socket.AF_TIPC`.
case _ if sock.family == AF_TIPC:
transport = 'tipc'
case _:
raise NotImplementedError(
f'Unsupported socket family: {sock.family}'

View File

@ -188,6 +188,31 @@ class Aid(
__repr__ = pretty_struct.Struct.__repr__
# NOTE, mirrors `.discovery._addr.UnwrappedAddress` but is
# re-declared here to dodge the circular import
# (`._addr` -> `.ipc._tcp` -> `.msg`).
#
# XXX this is the **wire** shape, so widening it is a wire-format
# change; keep the two decls in sync.
# XXX VARIADIC on purpose! `msgspec` rejects a union holding
# more than one array-like type ("Type unions may not contain
# more than one array-like (list, set, tuple) type"), so the two
# concrete shapes,
#
# ('127.0.0.1', 1616) # tcp
# ('/run/user/1000/tractor', 'x.sock') # uds
# ('tipc', 1953628160, 1616, 2) # proto-keyed (tipc)
#
# can't be spelled as `tuple[str, str|int]|tuple[str, int, int,
# int]`. Widen to one homogeneous variadic tuple instead.
#
# ?TODO, the real fix is the `UnwrappedAddress` proto-key
# migration (see `.discovery._addr`) after which this becomes a
# tagged union keyed off elem 0 and full per-proto validation
# comes back.
UnwrappedAddress = tuple[str|int, ...]
class SpawnSpec(
pretty_struct.Struct,
tag=True,
@ -213,8 +238,8 @@ class SpawnSpec(
# TODO: not just sockaddr pairs?
# -[ ] abstract into a `TransportAddr` type?
reg_addrs: list[tuple[str, str|int]]
bind_addrs: list[tuple[str, str|int]]|None
reg_addrs: list[UnwrappedAddress]
bind_addrs: list[UnwrappedAddress]|None
# TODO: caps based RPC support in the payload?

View File

@ -47,6 +47,7 @@ if TYPE_CHECKING:
TransportProtocolKey = Literal[
'tcp',
'uds',
'tipc',
]
_def_tpt_proto: TransportProtocolKey = 'tcp'