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`))
wkt/pr493_review
parent
cca3a70de4
commit
e3089ba356
|
|
@ -0,0 +1,358 @@
|
|||
'''
|
||||
Unit tests for the `AF_TIPC` transport backend, `tractor.ipc._tipc`.
|
||||
|
||||
The kernel-touching cases are gated on `is_tipc_available()` since
|
||||
the `tipc` module is NOT loaded by default (`sudo modprobe tipc`);
|
||||
the pure address-algebra cases run everywhere.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
import errno
|
||||
from socket import (
|
||||
SOCK_STREAM,
|
||||
SOL_SOCKET,
|
||||
SO_ACCEPTCONN,
|
||||
)
|
||||
|
||||
import pytest
|
||||
import trio
|
||||
|
||||
from tractor.ipc import _tipc
|
||||
from tractor.ipc._tipc import (
|
||||
AF_TIPC,
|
||||
TIPC_ADDR_ID,
|
||||
TIPC_ADDR_NAME,
|
||||
TIPC_CLUSTER_SCOPE,
|
||||
TIPC_NODE_SCOPE,
|
||||
TIPC_ZONE_SCOPE,
|
||||
TRACTOR_STYPE,
|
||||
TIPCAddress,
|
||||
instance_from_seed,
|
||||
is_tipc_available,
|
||||
start_listener,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.tipc
|
||||
|
||||
requires_tipc = pytest.mark.skipif(
|
||||
not is_tipc_available(),
|
||||
reason=(
|
||||
'`tipc` kernel module not loaded (`sudo modprobe tipc`)'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# address algebra (no kernel needed)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'addr',
|
||||
[
|
||||
TIPCAddress.get_root(),
|
||||
TIPCAddress(
|
||||
_stype=TRACTOR_STYPE,
|
||||
_instance=42,
|
||||
_scope=TIPC_NODE_SCOPE,
|
||||
),
|
||||
],
|
||||
ids=['root', 'node-scoped'],
|
||||
)
|
||||
def test_addr_unwrap_roundtrip(addr: TIPCAddress):
|
||||
'''
|
||||
`.unwrap()` is proto-keyed and `.from_addr()` inverts it — for
|
||||
both the `tuple` form and the `list` form msgpack decodes to.
|
||||
|
||||
'''
|
||||
unwrapped: tuple = addr.unwrap()
|
||||
assert unwrapped[0] == 'tipc' == TIPCAddress.proto_key
|
||||
assert len(unwrapped) == 4
|
||||
|
||||
assert TIPCAddress.from_addr(unwrapped) == addr
|
||||
assert TIPCAddress.from_addr(list(unwrapped)) == addr
|
||||
|
||||
|
||||
def test_addr_scope_defaults_when_omitted():
|
||||
'''
|
||||
A 3-elem `('tipc', stype, inst)` form defaults to the
|
||||
cluster-scope bindspace.
|
||||
|
||||
'''
|
||||
addr: TIPCAddress = TIPCAddress.from_addr(
|
||||
('tipc', TRACTOR_STYPE, 99),
|
||||
)
|
||||
assert addr._scope == TIPC_CLUSTER_SCOPE
|
||||
assert addr.bindspace == TIPCAddress.def_bindspace
|
||||
|
||||
|
||||
def test_zone_scope_normalized_to_cluster():
|
||||
'''
|
||||
`TIPC_ZONE_SCOPE` is deprecated/aliased in modern kernels;
|
||||
accept it on input, fold it to cluster.
|
||||
|
||||
'''
|
||||
addr: TIPCAddress = TIPCAddress.from_addr(
|
||||
('tipc', TRACTOR_STYPE, 7, TIPC_ZONE_SCOPE),
|
||||
)
|
||||
assert addr._scope == TIPC_CLUSTER_SCOPE
|
||||
assert addr.is_valid
|
||||
|
||||
|
||||
def test_addr_from_bare_port_id_raises():
|
||||
'''
|
||||
A `TIPC_ADDR_ID` 5-tuple carries no service-name so it can
|
||||
NEVER be wrapped; it must fail loudly rather than silently
|
||||
fabricate an un-dialable addr.
|
||||
|
||||
This is the invariant that lets
|
||||
`TIPCAddress.rebind_from_sockname` be `False`.
|
||||
|
||||
'''
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
TIPCAddress.from_addr((TIPC_ADDR_ID, 0, 12345, 0, 0))
|
||||
|
||||
assert 'port-id' in str(excinfo.value)
|
||||
|
||||
|
||||
def test_addr_is_valid_predicate():
|
||||
assert TIPCAddress.get_root().is_valid
|
||||
|
||||
# instance 0 is not a bindable name
|
||||
assert not TIPCAddress(
|
||||
_stype=TRACTOR_STYPE,
|
||||
_instance=0,
|
||||
).is_valid
|
||||
|
||||
# service-types 0..63 are TIPC-internal (`TIPC_CFG_SRV`,
|
||||
# `TIPC_TOP_SRV`, ..)
|
||||
assert not TIPCAddress(
|
||||
_stype=1,
|
||||
_instance=1616,
|
||||
).is_valid
|
||||
|
||||
|
||||
def test_port_id_is_annotation_only():
|
||||
'''
|
||||
`.maybe_node`/`.maybe_ref` are *observed* metadata, excluded
|
||||
from `.unwrap()` exactly like `UDSAddress.maybe_pid`.
|
||||
|
||||
'''
|
||||
addr: TIPCAddress = TIPCAddress.get_root()
|
||||
annotated: TIPCAddress = addr.with_port_id(
|
||||
node=0xdead,
|
||||
ref=1234,
|
||||
)
|
||||
assert annotated.unwrap() == addr.unwrap()
|
||||
assert annotated.maybe_ref == 1234
|
||||
assert '1234' in repr(annotated)
|
||||
|
||||
|
||||
def test_instance_from_seed_is_pure():
|
||||
'''
|
||||
Same seed -> same instance (what the follow-up registrar-less
|
||||
discovery fast-path will lean on), and always clear of the
|
||||
reserved low range.
|
||||
|
||||
'''
|
||||
for seed in ('doggy@123', 'kitty@456', ''):
|
||||
inst: int = instance_from_seed(seed)
|
||||
assert inst == instance_from_seed(seed)
|
||||
assert 64 <= inst < 2**32
|
||||
|
||||
|
||||
def test_get_random_collision_resistance():
|
||||
'''
|
||||
A `.get_random()` clash does NOT raise `EADDRINUSE` — TIPC
|
||||
accepts multiple publishers of one name and round-robins
|
||||
connects between them, so a collision is *silent crosstalk*.
|
||||
|
||||
Assert the 4-byte digest spreads well enough for that to stay
|
||||
improbable.
|
||||
|
||||
NOTE the bound is birthday-statistical, not absolute:
|
||||
P(collision) ~= 1 - exp(-n**2 / 2**33) ~= 1.2e-2 for n=10k, so
|
||||
a strict `== n` assert would be ~1-in-86 flaky. P(>2
|
||||
collisions) is ~1e-7, hence the slack. See plan 01 §9 for the
|
||||
escalation path if this ever trips.
|
||||
|
||||
'''
|
||||
n: int = 10_000
|
||||
addrs: list[TIPCAddress] = [
|
||||
TIPCAddress.get_random()
|
||||
for _ in range(n)
|
||||
]
|
||||
instances: set[int] = {
|
||||
addr._instance
|
||||
for addr in addrs
|
||||
}
|
||||
assert len(instances) >= n - 2
|
||||
|
||||
# every one is a legal, bindable name
|
||||
assert all(addr.is_valid for addr in addrs)
|
||||
|
||||
|
||||
def test_get_random_honors_bindspace():
|
||||
addr: TIPCAddress = TIPCAddress.get_random(
|
||||
bindspace=TIPC_NODE_SCOPE,
|
||||
)
|
||||
assert addr.bindspace == TIPC_NODE_SCOPE == addr._scope
|
||||
|
||||
|
||||
def test_eafnosupport_is_actionable_connerr(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
'''
|
||||
With no `tipc` module the kernel answers `EAFNOSUPPORT`; that
|
||||
MUST surface as a `ConnectionError` naming the fix rather than
|
||||
a bare `OSError`.
|
||||
|
||||
'''
|
||||
class _NoTIPCKernel:
|
||||
@staticmethod
|
||||
def socket(*args, **kwargs):
|
||||
raise OSError(
|
||||
errno.EAFNOSUPPORT,
|
||||
'Address family not supported by protocol',
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_tipc, 'trio_socket', _NoTIPCKernel)
|
||||
|
||||
async def main():
|
||||
await start_listener(addr=TIPCAddress.get_root())
|
||||
|
||||
with pytest.raises(ConnectionError) as excinfo:
|
||||
trio.run(main)
|
||||
|
||||
report: str = str(excinfo.value)
|
||||
assert 'modprobe tipc' in report
|
||||
assert type(excinfo.value.__cause__) is OSError
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# kernel-touching
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@requires_tipc
|
||||
def test_listener_tolerates_so_acceptconn():
|
||||
'''
|
||||
`trio.SocketListener.__init__` asserts
|
||||
`getsockopt(SOL_SOCKET, SO_ACCEPTCONN)` is truthy, suppressing
|
||||
`OSError` for exotic families.
|
||||
|
||||
Pin which of the two branches `AF_TIPC` actually takes (plan 01
|
||||
§3.1 left it as an assumption) so a kernel-side regression is
|
||||
caught here rather than as a mystery bind failure.
|
||||
|
||||
'''
|
||||
async def main():
|
||||
addr: TIPCAddress = TIPCAddress.get_random()
|
||||
lstnr = await start_listener(addr=addr)
|
||||
try:
|
||||
assert lstnr.socket.getsockopt(
|
||||
SOL_SOCKET,
|
||||
SO_ACCEPTCONN,
|
||||
)
|
||||
finally:
|
||||
lstnr.socket.close()
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
@requires_tipc
|
||||
def test_bind_publishes_a_dialable_service_name():
|
||||
'''
|
||||
"Publishing a bind IS registration": `.bind()` a singleton
|
||||
name-seq and a second task resolves it by *name* — with NO
|
||||
`tractor` registrar in the loop.
|
||||
|
||||
This is the core #378 property.
|
||||
|
||||
'''
|
||||
async def main():
|
||||
addr: TIPCAddress = TIPCAddress.get_random()
|
||||
lstnr = await start_listener(addr=addr)
|
||||
|
||||
accepted: list = []
|
||||
|
||||
async def _accept():
|
||||
stream = await lstnr.accept()
|
||||
accepted.append(stream)
|
||||
await stream.send_all(b'woof')
|
||||
await stream.aclose()
|
||||
|
||||
async with trio.open_nursery() as tn:
|
||||
tn.start_soon(_accept)
|
||||
await trio.sleep(0.05)
|
||||
|
||||
sock = _tipc.trio_socket.socket(
|
||||
AF_TIPC,
|
||||
SOCK_STREAM,
|
||||
)
|
||||
# NOTE, connect by *name* -> the kernel does the
|
||||
# lookup, i.e. this call IS the discovery query.
|
||||
await sock.connect((
|
||||
TIPC_ADDR_NAME,
|
||||
addr._stype,
|
||||
addr._instance,
|
||||
0, # domain: 0 == "anywhere in scope"
|
||||
addr._scope,
|
||||
))
|
||||
stream = trio.SocketStream(sock)
|
||||
assert await stream.receive_some(16) == b'woof'
|
||||
await stream.aclose()
|
||||
|
||||
assert len(accepted) == 1
|
||||
lstnr.socket.close()
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
@requires_tipc
|
||||
def test_getsockname_is_a_port_id_not_the_bound_name():
|
||||
'''
|
||||
The reason `TIPCAddress.rebind_from_sockname` is `False`.
|
||||
|
||||
A NAMESEQ-bound listener's `getsockname()` answers a
|
||||
`TIPC_ADDR_ID` port-id, which never equals `.unwrap()` and
|
||||
cannot be wrapped back into a service name.
|
||||
|
||||
'''
|
||||
async def main():
|
||||
addr: TIPCAddress = TIPCAddress.get_random()
|
||||
lstnr = await start_listener(addr=addr)
|
||||
try:
|
||||
sockname: tuple = lstnr.socket.getsockname()
|
||||
assert sockname[0] == TIPC_ADDR_ID
|
||||
assert sockname != addr.unwrap()
|
||||
with pytest.raises(ValueError):
|
||||
TIPCAddress.from_addr(sockname)
|
||||
finally:
|
||||
lstnr.socket.close()
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
@requires_tipc
|
||||
def test_duplicate_name_bind_does_not_raise():
|
||||
'''
|
||||
Unlike every other backend, TIPC permits *two* publishers of
|
||||
one service name and round-robins connects between them.
|
||||
|
||||
Pin that observed behaviour — it's the whole reason
|
||||
`.get_random()` bothers with a well-spread digest, and a
|
||||
future kernel that starts raising `EADDRINUSE` here would be
|
||||
very good news worth noticing.
|
||||
|
||||
'''
|
||||
async def main():
|
||||
addr: TIPCAddress = TIPCAddress.get_random()
|
||||
first = await start_listener(addr=addr)
|
||||
second = await start_listener(addr=addr)
|
||||
try:
|
||||
assert first.socket.getsockname() != second.socket.getsockname()
|
||||
finally:
|
||||
first.socket.close()
|
||||
second.socket.close()
|
||||
|
||||
trio.run(main)
|
||||
|
|
@ -496,6 +496,12 @@ def pytest_configure(
|
|||
'has_nested_actors: test spawns nested (>1-level) subactor '
|
||||
'trees.'
|
||||
)
|
||||
config.addinivalue_line(
|
||||
'markers',
|
||||
'tipc: test targets the `AF_TIPC` tpt backend; the kernel- '
|
||||
'touching cases self-skip unless the `tipc` module is loaded '
|
||||
'(`sudo modprobe tipc`).'
|
||||
)
|
||||
config.addinivalue_line(
|
||||
'markers',
|
||||
'trio: legacy mark for tests meant to run under the `trio` '
|
||||
|
|
|
|||
|
|
@ -0,0 +1,550 @@
|
|||
# tractor: distributed structured concurrency.
|
||||
# Copyright 2018-eternity Tyler Goodlet.
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
'''
|
||||
`AF_TIPC` (Transparent Inter-Process Communication) implementation of
|
||||
the `tractor.ipc._transport.MsgTransport` protocol.
|
||||
|
||||
TIPC is a linux-kernel cluster IPC protocol whose *service names* are
|
||||
published in a cluster-wide name-table by the kernel itself. That
|
||||
makes a `.bind()` literally a service **registration** and
|
||||
a `.connect()` literally a service **lookup**, i.e. the discovery
|
||||
machinery `tractor.discovery` normally implements with a registrar
|
||||
actor comes for free, in-kernel.
|
||||
|
||||
An actor's TIPC address is therefore a *service name* pair,
|
||||
`(stype, instance)`,
|
||||
|
||||
- a listener `.bind()`s the singleton published range
|
||||
`(stype, instance, instance)` as a `TIPC_ADDR_NAMESEQ`,
|
||||
- a peer `.connect()`s that name as a `TIPC_ADDR_NAME` and the kernel
|
||||
resolves it,
|
||||
- `TIPC_ADDR_ID` (a `(node, ref)` port-id) is only ever an *observed*
|
||||
address, never a user-facing one.
|
||||
|
||||
NOTE, the `tipc` kernel module is NOT loaded by default; see
|
||||
`is_tipc_available()` and the `sudo modprobe tipc` hint carried in
|
||||
this module's `ConnectionError` messages.
|
||||
|
||||
Normative refs are the kernel sources (the tipc.io docs are stale),
|
||||
- `include/uapi/linux/tipc.h`
|
||||
- `net/tipc/socket.c`
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
from contextlib import (
|
||||
contextmanager as cm,
|
||||
)
|
||||
import errno
|
||||
from hashlib import blake2b
|
||||
import os
|
||||
import socket
|
||||
from socket import SOCK_STREAM
|
||||
from typing import (
|
||||
ClassVar,
|
||||
Type,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
from uuid import uuid4
|
||||
|
||||
import msgspec
|
||||
from trio import (
|
||||
socket as trio_socket,
|
||||
SocketListener,
|
||||
)
|
||||
|
||||
from tractor.log import get_logger
|
||||
from tractor.runtime._state import (
|
||||
current_actor,
|
||||
is_root_process,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tractor.runtime._runtime import Actor
|
||||
|
||||
|
||||
log = get_logger()
|
||||
|
||||
|
||||
# XXX, `AF_TIPC` and every `TIPC_*` constant are linux-ONLY in
|
||||
# CPython's `socketmodule.c`. Mirror the `_uds.py` `SO_PASSCRED`
|
||||
# precedent and fall back to the uapi values so this module stays
|
||||
# **importable everywhere** — `.discovery._addr` builds its
|
||||
# registration tables at import time (contract §2.3) — while
|
||||
# `is_tipc_available()` remains the single *runtime* gate.
|
||||
#
|
||||
# values verified against `include/uapi/linux/tipc.h`
|
||||
try:
|
||||
from socket import (
|
||||
AF_TIPC,
|
||||
SOL_TIPC,
|
||||
TIPC_ADDR_ID,
|
||||
TIPC_ADDR_NAME,
|
||||
TIPC_ADDR_NAMESEQ,
|
||||
TIPC_CLUSTER_SCOPE,
|
||||
TIPC_NODE_SCOPE,
|
||||
TIPC_ZONE_SCOPE,
|
||||
)
|
||||
except ImportError:
|
||||
AF_TIPC: int = 30
|
||||
SOL_TIPC: int = 271
|
||||
TIPC_ADDR_NAMESEQ: int = 1
|
||||
TIPC_ADDR_NAME: int = 2
|
||||
TIPC_ADDR_ID: int = 3
|
||||
TIPC_ZONE_SCOPE: int = 1
|
||||
TIPC_CLUSTER_SCOPE: int = 2
|
||||
TIPC_NODE_SCOPE: int = 3
|
||||
|
||||
|
||||
# `tractor`'s reserved TIPC service-class ("type"), spelling out
|
||||
# ascii 'tr' in the high half and leaving the low 16b free for
|
||||
# app-side partitioning via an explicit `TIPCAddress._stype`.
|
||||
#
|
||||
# NOTE, two `tractor` trees sharing BOTH a cluster and an `_stype`
|
||||
# share a service-name space; see `.get_random()` on why that's
|
||||
# only a *probabilistic* hazard.
|
||||
TRACTOR_STYPE: int = 0x74_72_00_00
|
||||
|
||||
# TIPC reserves service-*types* 0..63 for its own internal services
|
||||
# (`TIPC_CFG_SRV == 0`, `TIPC_TOP_SRV == 1`); see
|
||||
# `include/uapi/linux/tipc.h`.
|
||||
_tipc_reserved_stypes: range = range(0, 64)
|
||||
|
||||
# sentinel for "this addr was *observed* off a `TIPC_ADDR_ID`, so
|
||||
# the peer's service-name is unknowable from the socket alone".
|
||||
# See plan 01 §3.4.
|
||||
TIPC_NAME_UNKNOWN: int = -1
|
||||
|
||||
_scope_names: dict[int, str] = {
|
||||
TIPC_ZONE_SCOPE: 'zone',
|
||||
TIPC_CLUSTER_SCOPE: 'cluster',
|
||||
TIPC_NODE_SCOPE: 'node',
|
||||
}
|
||||
|
||||
# see `is_tipc_available()`
|
||||
_tipc_avail: bool|None = None
|
||||
|
||||
|
||||
def is_tipc_available() -> bool:
|
||||
'''
|
||||
`True` iff this kernel can create an `AF_TIPC` socket, i.e. the
|
||||
`tipc` module is loaded (`sudo modprobe tipc`).
|
||||
|
||||
Pure predicate; no side effects, no logging. The answer can't
|
||||
change without a `modprobe` so it's memoized after the first
|
||||
(one syscall) probe.
|
||||
|
||||
'''
|
||||
global _tipc_avail
|
||||
if _tipc_avail is None:
|
||||
try:
|
||||
socket.socket(
|
||||
AF_TIPC,
|
||||
SOCK_STREAM,
|
||||
).close()
|
||||
_tipc_avail = True
|
||||
except OSError:
|
||||
_tipc_avail = False
|
||||
|
||||
return _tipc_avail
|
||||
|
||||
|
||||
class TIPCAddress(
|
||||
msgspec.Struct,
|
||||
frozen=True,
|
||||
):
|
||||
'''
|
||||
A TIPC *service name* as an address, i.e. the
|
||||
`(type, instance)` pair a listener publishes and a peer
|
||||
resolves, plus the optionally-*observed* `TIPC_ADDR_ID`
|
||||
port-id of a live connection.
|
||||
|
||||
'''
|
||||
_stype: int
|
||||
_instance: int
|
||||
_scope: int = TIPC_CLUSTER_SCOPE
|
||||
|
||||
# observed-only, from a `TIPC_ADDR_ID` `getsockname()`/
|
||||
# `getpeername()`; excluded from `.unwrap()` exactly like
|
||||
# `UDSAddress.maybe_pid`.
|
||||
maybe_node: int|None = None
|
||||
maybe_ref: int|None = None
|
||||
|
||||
proto_key: ClassVar[str] = 'tipc'
|
||||
unwrapped_type: ClassVar[type] = tuple[str, int, int, int]
|
||||
def_bindspace: ClassVar[int] = TIPC_CLUSTER_SCOPE
|
||||
|
||||
# XXX, TIPC's `getsockname()` answers a `TIPC_ADDR_ID` port-id
|
||||
# and NEVER the name-seq we bound, so the `Endpoint`-level
|
||||
# reconciliation would clobber a dialable service-name with an
|
||||
# un-dialable port-id. There's also nothing to learn: unlike
|
||||
# tcp's `port=0` there is no kernel-assigned-name analogue.
|
||||
rebind_from_sockname: ClassVar[bool] = False
|
||||
|
||||
@property
|
||||
def bindspace(self) -> int:
|
||||
'''
|
||||
The TIPC *scope*, i.e. literally "the set of hosts from
|
||||
which this published name is reachable": `TIPC_NODE_SCOPE`
|
||||
for same-host-only (the UDS analogue),
|
||||
`TIPC_CLUSTER_SCOPE` for cluster-visible.
|
||||
|
||||
'''
|
||||
return self._scope
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
'''
|
||||
Is this a *publishable/dialable* service name?
|
||||
|
||||
NOTE the `> 0` (rather than `!= 0`) guards double-duty as
|
||||
the `TIPC_NAME_UNKNOWN` reject, i.e. an addr merely
|
||||
*observed* off a peer's port-id is never dialable.
|
||||
|
||||
'''
|
||||
return (
|
||||
self._instance > 0
|
||||
and
|
||||
self._stype > 0
|
||||
and
|
||||
self._stype not in _tipc_reserved_stypes
|
||||
and
|
||||
self._scope in (
|
||||
TIPC_NODE_SCOPE,
|
||||
TIPC_CLUSTER_SCOPE,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_addr(
|
||||
cls,
|
||||
addr: tuple[str, int, int, int],
|
||||
) -> TIPCAddress:
|
||||
match addr:
|
||||
# our proto-keyed unwrapped form, w/ scope optional
|
||||
case (
|
||||
('tipc', int() as stype, int() as inst, int() as scope)
|
||||
|
|
||||
['tipc', int() as stype, int() as inst, int() as scope]
|
||||
):
|
||||
return TIPCAddress(
|
||||
_stype=stype,
|
||||
_instance=inst,
|
||||
_scope=_norm_scope(scope),
|
||||
)
|
||||
|
||||
case (
|
||||
('tipc', int() as stype, int() as inst)
|
||||
|
|
||||
['tipc', int() as stype, int() as inst]
|
||||
):
|
||||
return TIPCAddress(
|
||||
_stype=stype,
|
||||
_instance=inst,
|
||||
)
|
||||
|
||||
# a kernel-observed `TIPC_ADDR_ID` 5-tuple.
|
||||
#
|
||||
# XXX, a port-id carries NO service-name info, so we
|
||||
# cannot reconstruct `(stype, instance)` from it. This
|
||||
# is exactly why `.rebind_from_sockname` is `False`;
|
||||
# if you land here something re-enabled that path.
|
||||
case (int() as atype, *_) if atype == TIPC_ADDR_ID:
|
||||
raise ValueError(
|
||||
f'Can not wrap a bare TIPC_ADDR_ID port-id !\n'
|
||||
f'addr: {addr!r}\n'
|
||||
f'\n'
|
||||
f'A port-id carries no service-name, so the\n'
|
||||
f'`(stype, instance)` identity is unrecoverable.\n'
|
||||
f'Use `.with_port_id()` to *annotate* a known\n'
|
||||
f'{cls.__name__} instead.\n'
|
||||
)
|
||||
|
||||
case _:
|
||||
raise TypeError(
|
||||
f'Bad unwrapped-address for {cls} !\n'
|
||||
f'{addr!r}\n'
|
||||
)
|
||||
|
||||
def unwrap(self) -> tuple[str, int, int, int]:
|
||||
# NOTE, proto-keyed (w/ the `multiaddr` proto spelling) so
|
||||
# `wrap_address()` can dispatch unambiguously against the
|
||||
# other backends' 2-tuple forms; see contract §1.1.
|
||||
return (
|
||||
'tipc',
|
||||
self._stype,
|
||||
self._instance,
|
||||
self._scope,
|
||||
)
|
||||
|
||||
def with_port_id(
|
||||
self,
|
||||
node: int,
|
||||
ref: int,
|
||||
) -> TIPCAddress:
|
||||
'''
|
||||
A copy annotated with an *observed* `TIPC_ADDR_ID`
|
||||
port-id, purely for logging/`__repr__`.
|
||||
|
||||
'''
|
||||
return msgspec.structs.replace(
|
||||
self,
|
||||
maybe_node=node,
|
||||
maybe_ref=ref,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_random(
|
||||
cls,
|
||||
bindspace: int|None = None,
|
||||
) -> TIPCAddress:
|
||||
'''
|
||||
A per-subactor ephemeral service-name.
|
||||
|
||||
XXX, TIPC has NO kernel-assigned-instance analogue of tcp's
|
||||
`port=0`, so we must choose the instance ourselves — and a
|
||||
clash does **not** raise `EADDRINUSE`: TIPC happily accepts
|
||||
multiple publishers of one name and round-robins connects
|
||||
between them (verified). I.e. a collision manifests as
|
||||
*silent crosstalk*, not an error.
|
||||
|
||||
So the instance is a `blake2b` digest of a per-call-unique
|
||||
seed, giving a well-spread 32b value. Being a pure fn of the
|
||||
seed it is also *reproducible*, which the (follow-up)
|
||||
registrar-less discovery fast-path wants.
|
||||
|
||||
NOTE the residual risk is birthday-bounded: ~1.2e-2 for 10k
|
||||
names sharing one `_stype`. See plan 01 §9 for the
|
||||
escalation (post-bind verification) if that ever bites.
|
||||
|
||||
'''
|
||||
pid: int = os.getpid()
|
||||
actor: Actor|None = current_actor(
|
||||
err_on_no_runtime=False,
|
||||
)
|
||||
if actor:
|
||||
seed: str = f'{actor.aid.name}@{pid}'
|
||||
else:
|
||||
if is_root_process():
|
||||
prefix: str = 'no_runtime_root'
|
||||
else:
|
||||
prefix: str = 'no_runtime_actor'
|
||||
|
||||
# XXX, no live actor -> no `Aid` to key off, so mix
|
||||
# a per-CALL token in; w/o it the seed degenerates to
|
||||
# a pure fn of `(prefix, pid)` and two calls in one
|
||||
# proc alias to the SAME service name — the `_uds.py`
|
||||
# `.get_random()` hazard, but silent here.
|
||||
seed: str = f'{prefix}.{uuid4().hex[:8]}@{pid}'
|
||||
|
||||
return TIPCAddress(
|
||||
_stype=TRACTOR_STYPE,
|
||||
_instance=instance_from_seed(seed),
|
||||
_scope=(
|
||||
bindspace
|
||||
if bindspace is not None
|
||||
else cls.def_bindspace
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
'''
|
||||
Generic tpt-capability hook: `(ok, why_not)`.
|
||||
|
||||
Consumed by the `tpt_protos` test fixture so a
|
||||
`--tpt-proto tipc` run on a box with no `tipc` module
|
||||
fails loudly and early rather than as a few hundred
|
||||
confusing connect timeouts. Apps can use it too.
|
||||
|
||||
NOTE, 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.
|
||||
|
||||
'''
|
||||
if is_tipc_available():
|
||||
return (True, '')
|
||||
|
||||
return (
|
||||
False,
|
||||
'the `tipc` kernel module is not loaded'
|
||||
' |_try: `sudo modprobe tipc`',
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_root(cls) -> TIPCAddress:
|
||||
# NOTE, `1616` mirrors `TCPAddress.get_root()`s port and
|
||||
# the UDS `registry@1616.sock` filename so the "1616 is
|
||||
# tractor's registrar" idiom holds across all backends.
|
||||
return TIPCAddress(
|
||||
_stype=TRACTOR_STYPE,
|
||||
_instance=1616,
|
||||
_scope=TIPC_CLUSTER_SCOPE,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if self._instance == TIPC_NAME_UNKNOWN:
|
||||
name: str = '<unknown-service>'
|
||||
else:
|
||||
name: str = f'0x{self._stype:08x}:{self._instance}'
|
||||
|
||||
body: str = (
|
||||
f'{name}, {_scope_names.get(self._scope, self._scope)}'
|
||||
)
|
||||
if (node := self.maybe_node) is not None:
|
||||
body += f', @0x{node:08x}:{self.maybe_ref}'
|
||||
|
||||
return (
|
||||
f'{type(self).__name__}'
|
||||
f'['
|
||||
f'{body}'
|
||||
f']'
|
||||
)
|
||||
|
||||
|
||||
def instance_from_seed(seed: str) -> int:
|
||||
'''
|
||||
Derive a TIPC service *instance* from an actor-identity `seed`.
|
||||
|
||||
A `blake2b` digest folded into `[64, 2**32)`; the low values are
|
||||
skipped to stay clear of TIPC's own reserved numbering
|
||||
conventions.
|
||||
|
||||
'''
|
||||
inst: int = int.from_bytes(
|
||||
blake2b(
|
||||
seed.encode(),
|
||||
digest_size=4,
|
||||
).digest(),
|
||||
'big',
|
||||
)
|
||||
return 64 + (inst % (2**32 - 64))
|
||||
|
||||
|
||||
def _norm_scope(scope: int) -> int:
|
||||
'''
|
||||
Normalize a `TIPC_*_SCOPE` value.
|
||||
|
||||
`TIPC_ZONE_SCOPE` is deprecated and aliased to cluster-scope by
|
||||
modern kernels; accept it on input and fold it.
|
||||
|
||||
'''
|
||||
if scope == TIPC_ZONE_SCOPE:
|
||||
log.transport(
|
||||
f'Normalizing deprecated TIPC_ZONE_SCOPE -> cluster\n'
|
||||
f'scope: {scope!r}\n'
|
||||
)
|
||||
return TIPC_CLUSTER_SCOPE
|
||||
|
||||
return scope
|
||||
|
||||
|
||||
@cm
|
||||
def _reraise_as_connerr(
|
||||
src_excs: tuple[Type[Exception]],
|
||||
addr: TIPCAddress,
|
||||
):
|
||||
'''
|
||||
Normalize TIPC's `OSError`s into `ConnectionError`s.
|
||||
|
||||
XXX REQUIRED, not polish: TIPC answers a lookup for an
|
||||
unpublished name with `EHOSTUNREACH` which python maps to a
|
||||
**bare** `OSError`, NOT a `ConnectionError` subtype (unlike
|
||||
`ECONNREFUSED` -> `ConnectionRefusedError`). Contract §4's
|
||||
discovery-ping path requires the `ConnectionError` shape.
|
||||
|
||||
'''
|
||||
try:
|
||||
yield
|
||||
except src_excs as src_exc:
|
||||
match src_exc.errno:
|
||||
case errno.EAFNOSUPPORT:
|
||||
why: str = (
|
||||
'TIPC unavailable — is the kernel module loaded?\n'
|
||||
' |_try: `sudo modprobe tipc`\n'
|
||||
)
|
||||
case errno.EHOSTUNREACH:
|
||||
why: str = (
|
||||
'No TIPC publisher for this service name\n'
|
||||
' |_nothing has `.bind()`ed it in-scope\n'
|
||||
)
|
||||
case _:
|
||||
why: str = 'Bad TIPC service-name-as-address ??\n'
|
||||
|
||||
raise ConnectionError(
|
||||
f'{why}'
|
||||
f'{addr}\n'
|
||||
f'\n'
|
||||
f'from src: {src_exc!r}\n'
|
||||
) from src_exc
|
||||
|
||||
|
||||
async def start_listener(
|
||||
addr: TIPCAddress,
|
||||
backlog: int = 128,
|
||||
**kwargs,
|
||||
) -> SocketListener:
|
||||
'''
|
||||
Publish `addr` as a TIPC service name and listen on it.
|
||||
|
||||
The `.bind()` of a singleton `TIPC_ADDR_NAMESEQ` range
|
||||
`(stype, instance, instance)` **is** the service registration —
|
||||
it's what shows up in `tipc nametable show` and what a peer's
|
||||
`.connect()`-by-name resolves against.
|
||||
|
||||
NOTE, unlike every other backend a duplicate bind does NOT
|
||||
raise: TIPC permits multiple publishers of one name and
|
||||
round-robins connects between them. See
|
||||
`TIPCAddress.get_random()`.
|
||||
|
||||
'''
|
||||
log.info(
|
||||
f'Attempting to publish TIPC service name\n'
|
||||
f'>[\n'
|
||||
f'|_{addr}\n'
|
||||
)
|
||||
with _reraise_as_connerr(
|
||||
src_excs=(OSError,),
|
||||
addr=addr,
|
||||
):
|
||||
sock = trio_socket.socket(
|
||||
AF_TIPC,
|
||||
SOCK_STREAM,
|
||||
)
|
||||
await sock.bind((
|
||||
TIPC_ADDR_NAMESEQ,
|
||||
addr._stype,
|
||||
addr._instance, # lower
|
||||
addr._instance, # upper
|
||||
addr._scope,
|
||||
))
|
||||
|
||||
# NOTE, backlog matches `_uds.start_listener()`'s hard-won
|
||||
# value; a backlog of 1 overflows during concurrent
|
||||
# deregistration storms at actor-tree teardown.
|
||||
sock.listen(backlog)
|
||||
log.info(
|
||||
f'Published TIPC service name\n'
|
||||
f'[>\n'
|
||||
f' |_{addr}\n'
|
||||
)
|
||||
return SocketListener(sock)
|
||||
|
||||
|
||||
# NOTE, deliberately NO `close_listener()`: there's no filesys
|
||||
# entry to unlink and the kernel withdraws the published name on
|
||||
# socket close. Per contract §1.2 absence means "closing is
|
||||
# implicit".
|
||||
Loading…
Reference in New Issue