2025-03-16 17:14:32 +00:00
|
|
|
# tractor: structured concurrent "actors".
|
|
|
|
|
# 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/>.
|
|
|
|
|
'''
|
2025-04-07 22:07:58 +00:00
|
|
|
typing.Protocol based generic msg API, implement this class to add
|
|
|
|
|
backends for tractor.ipc.Channel
|
2025-03-16 17:14:32 +00:00
|
|
|
|
|
|
|
|
'''
|
2025-03-22 18:29:48 +00:00
|
|
|
from __future__ import annotations
|
2025-03-16 17:14:32 +00:00
|
|
|
from typing import (
|
|
|
|
|
runtime_checkable,
|
2025-03-22 18:29:48 +00:00
|
|
|
Type,
|
2025-03-16 17:14:32 +00:00
|
|
|
Protocol,
|
2025-04-07 22:07:58 +00:00
|
|
|
# TypeVar,
|
|
|
|
|
ClassVar,
|
|
|
|
|
TYPE_CHECKING,
|
2025-03-22 18:29:48 +00:00
|
|
|
)
|
2026-03-25 23:04:13 +00:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from multiaddr import Multiaddr
|
2025-03-22 18:29:48 +00:00
|
|
|
from collections.abc import (
|
|
|
|
|
AsyncGenerator,
|
|
|
|
|
AsyncIterator,
|
|
|
|
|
)
|
2026-08-13 22:30:44 +00:00
|
|
|
import errno
|
2025-03-22 18:29:48 +00:00
|
|
|
import struct
|
|
|
|
|
|
|
|
|
|
import trio
|
|
|
|
|
import msgspec
|
|
|
|
|
from tricycle import BufferedReceiveStream
|
|
|
|
|
|
|
|
|
|
from tractor.log import get_logger
|
|
|
|
|
from tractor._exceptions import (
|
|
|
|
|
MsgTypeError,
|
|
|
|
|
TransportClosed,
|
|
|
|
|
_mk_send_mte,
|
|
|
|
|
_mk_recv_mte,
|
|
|
|
|
)
|
|
|
|
|
from tractor.msg import (
|
|
|
|
|
_ctxvar_MsgCodec,
|
|
|
|
|
# _codec, XXX see `self._codec` sanity/debug checks
|
|
|
|
|
MsgCodec,
|
2025-04-07 22:07:58 +00:00
|
|
|
MsgType,
|
2025-03-22 18:29:48 +00:00
|
|
|
types as msgtypes,
|
|
|
|
|
pretty_struct,
|
2025-03-16 17:14:32 +00:00
|
|
|
)
|
2025-04-07 22:07:58 +00:00
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
Mv core mods to `runtime/`, `spawn/`, `discovery/` subpkgs
Restructure the flat `tractor/` top-level private mods
into (more nested) subpackages:
- `runtime/`: `_runtime`, `_portal`, `_rpc`, `_state`,
`_supervise`
- `spawn/`: `_spawn`, `_entry`, `_forkserver_override`,
`_mp_fixup_main`
- `discovery/`: `_addr`, `_discovery`, `_multiaddr`
Each subpkg `__init__.py` is kept lazy (no eager
imports) to avoid circular import issues.
Also,
- update all intra-pkg imports across ~35 mods to use
the new subpkg paths (e.g. `from .runtime._state`
instead of `from ._state`)
(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-03-23 22:42:16 +00:00
|
|
|
from tractor.discovery._addr import Address
|
2025-03-22 18:29:48 +00:00
|
|
|
|
2026-02-09 18:15:47 +00:00
|
|
|
log = get_logger()
|
2025-03-16 17:14:32 +00:00
|
|
|
|
|
|
|
|
|
2026-08-13 22:30:44 +00:00
|
|
|
def _peer_closed_errno(exc: BaseException) -> int|None:
|
|
|
|
|
'''
|
2026-08-14 22:54:56 +00:00
|
|
|
Classify a complete transport exception tree as peer closure.
|
|
|
|
|
|
|
|
|
|
Follow explicit cause/context links. For a `BaseExceptionGroup`,
|
|
|
|
|
require every child branch to resolve to a peer-close errno so an
|
|
|
|
|
unrelated concurrent failure is never hidden as `TransportClosed`.
|
2026-08-13 22:30:44 +00:00
|
|
|
|
|
|
|
|
'''
|
2026-08-14 22:54:56 +00:00
|
|
|
def find_peer_errno(
|
|
|
|
|
current_exc: BaseException,
|
|
|
|
|
ancestors: set[int],
|
|
|
|
|
) -> int|None:
|
|
|
|
|
exc_id: int = id(current_exc)
|
|
|
|
|
if exc_id in ancestors:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
ancestors = ancestors | {exc_id}
|
2026-08-13 22:30:44 +00:00
|
|
|
if (
|
2026-08-14 22:54:56 +00:00
|
|
|
isinstance(current_exc, OSError)
|
2026-08-13 22:30:44 +00:00
|
|
|
and
|
2026-08-14 22:54:56 +00:00
|
|
|
current_exc.errno in {
|
2026-08-13 22:30:44 +00:00
|
|
|
errno.ECONNRESET,
|
|
|
|
|
errno.EPIPE,
|
|
|
|
|
}
|
|
|
|
|
):
|
2026-08-14 22:54:56 +00:00
|
|
|
return current_exc.errno
|
|
|
|
|
|
|
|
|
|
if isinstance(current_exc, BaseExceptionGroup):
|
|
|
|
|
child_errnos: list[int|None] = [
|
|
|
|
|
find_peer_errno(
|
|
|
|
|
child_exc,
|
|
|
|
|
ancestors,
|
|
|
|
|
)
|
|
|
|
|
for child_exc in current_exc.exceptions
|
|
|
|
|
]
|
|
|
|
|
if all(
|
|
|
|
|
child_errno is not None
|
|
|
|
|
for child_errno in child_errnos
|
|
|
|
|
):
|
|
|
|
|
return child_errnos[0]
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
chained_exc: BaseException|None = (
|
|
|
|
|
current_exc.__cause__
|
|
|
|
|
or
|
|
|
|
|
current_exc.__context__
|
|
|
|
|
)
|
|
|
|
|
if chained_exc is not None:
|
|
|
|
|
return find_peer_errno(
|
|
|
|
|
chained_exc,
|
|
|
|
|
ancestors,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
return find_peer_errno(
|
|
|
|
|
exc,
|
|
|
|
|
set(),
|
|
|
|
|
)
|
2026-08-13 22:30:44 +00:00
|
|
|
|
|
|
|
|
|
2025-03-22 19:17:50 +00:00
|
|
|
# (codec, transport)
|
|
|
|
|
MsgTransportKey = tuple[str, str]
|
|
|
|
|
|
|
|
|
|
|
2025-03-16 17:14:32 +00:00
|
|
|
# from tractor.msg.types import MsgType
|
|
|
|
|
# ?TODO? this should be our `Union[*msgtypes.__spec__]` alias now right..?
|
|
|
|
|
# => BLEH, except can't bc prots must inherit typevar or param-spec
|
|
|
|
|
# vars..
|
2025-04-07 22:07:58 +00:00
|
|
|
# MsgType = TypeVar('MsgType')
|
2025-03-16 17:14:32 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@runtime_checkable
|
2025-04-07 22:07:58 +00:00
|
|
|
class MsgTransport(Protocol):
|
2025-03-16 17:14:32 +00:00
|
|
|
#
|
2025-04-07 22:07:58 +00:00
|
|
|
# class MsgTransport(Protocol[MsgType]):
|
2025-03-16 17:14:32 +00:00
|
|
|
# ^-TODO-^ consider using a generic def and indexing with our
|
|
|
|
|
# eventual msg definition/types?
|
|
|
|
|
# - https://docs.python.org/3/library/typing.html#typing.Protocol
|
|
|
|
|
|
|
|
|
|
stream: trio.SocketStream
|
|
|
|
|
drained: list[MsgType]
|
|
|
|
|
|
2025-03-23 03:14:04 +00:00
|
|
|
address_type: ClassVar[Type[Address]]
|
2025-03-22 19:17:50 +00:00
|
|
|
codec_key: ClassVar[str]
|
|
|
|
|
|
2025-03-16 17:14:32 +00:00
|
|
|
# XXX: should this instead be called `.sendall()`?
|
|
|
|
|
async def send(self, msg: MsgType) -> None:
|
|
|
|
|
...
|
|
|
|
|
|
|
|
|
|
async def recv(self) -> MsgType:
|
|
|
|
|
...
|
|
|
|
|
|
|
|
|
|
def __aiter__(self) -> MsgType:
|
|
|
|
|
...
|
|
|
|
|
|
|
|
|
|
def connected(self) -> bool:
|
|
|
|
|
...
|
|
|
|
|
|
|
|
|
|
# defining this sync otherwise it causes a mypy error because it
|
|
|
|
|
# can't figure out it's a generator i guess?..?
|
|
|
|
|
def drain(self) -> AsyncIterator[dict]:
|
|
|
|
|
...
|
|
|
|
|
|
2025-03-22 19:17:50 +00:00
|
|
|
@classmethod
|
|
|
|
|
def key(cls) -> MsgTransportKey:
|
Factor actor-embedded IPC-tpt-server to `ipc` subsys
Primarily moving the `Actor._serve_forever()`-task-as-method and
supporting actor-instance attributes to a new `.ipo._server` sub-mod
which now encapsulates,
- the coupling various `trio.Nursery`s (and their independent lifetime mgmt)
to different `trio.serve_listener()`s tasks and `SocketStream`
handler scopes.
- `Address` and `SocketListener` mgmt and tracking through the idea of
an "IPC endpoint": each "bound-and-active instance" of a served-listener
for some (varied transport protocol's socket) address.
- start and shutdown of the entire server's lifetime via an `@acm`.
- delegation of starting/stopping tpt-protocol-specific `trio.abc.Listener`s
to the corresponding `.ipc._<proto_key>` sub-module (newly defined
mod-top-level instead of `Address` method) `start/close_listener()`
funcs.
Impl details of the `.ipc._server` sub-sys,
- add new `IPCServer`, allocated with `open_ipc_server()`, and which
encapsulates starting multiple-transport-proto-`trio.abc.Listener`s
from an input set of `._addr.Address`s using,
|_`IPCServer.listen_on()` which internally spawns tasks that delegate to a new
`_serve_ipc_eps()`, a rework of what was (effectively)
`Actor._serve_forever()` and which now,
* allocates a new `IPCEndpoint`-struct (see below) for each
address-listener pair alongside the specified
listener-serving/stream-handling `trio.Nursery`s provided by the
caller.
* starts and stops each transport (socket's) listener by calling
`IPCEndpoint.start/close_listener()` which in turn delegates to
the underlying `inspect.getmodule(IPCEndpoint.addr)` backend tpt
module's equivalent impl.
* tracks all created endpoints in a `._endpoints: list[IPCEndpoint]`
which is further exposed through public properties for
introspection of served transport-protocols and their addresses.
|_`IPCServer._[parent/stream_handler]_tn: Nursery`s which are either
allocated (in which case, as the same instance) or provided by the
caller of `open_ipc_server()` such that the same nursery-cancel-scope
controls offered by `trio.serve_listeners(handler_nursery=)` are
offered where the `._parent_tn` is used to spawn `_serve_ipc_eps()`
tasks, and `._stream_handler_tn` is passed verbatim as `handler_nursery`.
- a new `IPCEndpoint`-struct (as mentioned) which wraps each
transport-proto's address + listener + allocated-supervising-nursery
to encapsulate the "lifetime of a server IPC endpoint" such that
eventually we can track and managed per-protocol/address/`.listen_on()`-call
scoped starts/stops/restarts for the purposes of filtering/banning
peer traffic.
|_ also included is an unused `.peer_tpts` table which we can
hopefully use to replace `Actor._peers` in a `Channel`-tracking
transport-proto-aware way!
Surrounding changes to `.ipc.*` primitives to match,
- make `[TCP|UDS]Address` types `msgspec.Struct(frozen=True)` and thus
drop any-and-all `addr._host =` style mutation throughout.
|_ as such also drop their `.__init__()` and `.__eq__()` meths.
|_ UDS tweaks to field names and thus `.__repr__()`.
- move `[TCP|UDS]Address.[start/close]_listener()` meths to be mod-level
equiv `start|close_listener()` funcs.
- just hard code the `.ipc._types._key_to_transport/._addr_to_transport`
table entries instead of all the prior fancy dynamic class property
reading stuff (remember, "explicit is better then implicit").
Modified in `._runtime.Actor` internals,
- drop the `._serve_forever()` and `.cancel_server()`, methods and
`._server_down` waiting logic from `.cancel_soon()`
- add `.[_]ipc_server` which is opened just after the `._service_n` and
delegate to it for any equivalent publicly exposed instance
attributes/properties.
2025-04-10 22:06:12 +00:00
|
|
|
return (
|
|
|
|
|
cls.codec_key,
|
|
|
|
|
cls.address_type.proto_key,
|
|
|
|
|
)
|
2025-03-22 19:17:50 +00:00
|
|
|
|
2025-03-16 17:14:32 +00:00
|
|
|
@property
|
2025-03-23 03:14:04 +00:00
|
|
|
def laddr(self) -> Address:
|
2025-03-16 17:14:32 +00:00
|
|
|
...
|
|
|
|
|
|
|
|
|
|
@property
|
2025-03-23 03:14:04 +00:00
|
|
|
def raddr(self) -> Address:
|
|
|
|
|
...
|
|
|
|
|
|
|
|
|
|
@property
|
2026-03-25 23:04:13 +00:00
|
|
|
def maddr(self) -> Multiaddr|str:
|
2025-03-22 18:29:48 +00:00
|
|
|
...
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
async def connect_to(
|
|
|
|
|
cls,
|
2025-03-23 03:14:04 +00:00
|
|
|
addr: Address,
|
2025-03-22 18:29:48 +00:00
|
|
|
**kwargs
|
|
|
|
|
) -> MsgTransport:
|
2025-03-16 17:14:32 +00:00
|
|
|
...
|
2025-03-22 18:29:48 +00:00
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def get_stream_addrs(
|
|
|
|
|
cls,
|
|
|
|
|
stream: trio.abc.Stream
|
|
|
|
|
) -> tuple[
|
2025-03-23 03:14:04 +00:00
|
|
|
Address, # local
|
|
|
|
|
Address # remote
|
2025-03-22 18:29:48 +00:00
|
|
|
]:
|
|
|
|
|
'''
|
Factor actor-embedded IPC-tpt-server to `ipc` subsys
Primarily moving the `Actor._serve_forever()`-task-as-method and
supporting actor-instance attributes to a new `.ipo._server` sub-mod
which now encapsulates,
- the coupling various `trio.Nursery`s (and their independent lifetime mgmt)
to different `trio.serve_listener()`s tasks and `SocketStream`
handler scopes.
- `Address` and `SocketListener` mgmt and tracking through the idea of
an "IPC endpoint": each "bound-and-active instance" of a served-listener
for some (varied transport protocol's socket) address.
- start and shutdown of the entire server's lifetime via an `@acm`.
- delegation of starting/stopping tpt-protocol-specific `trio.abc.Listener`s
to the corresponding `.ipc._<proto_key>` sub-module (newly defined
mod-top-level instead of `Address` method) `start/close_listener()`
funcs.
Impl details of the `.ipc._server` sub-sys,
- add new `IPCServer`, allocated with `open_ipc_server()`, and which
encapsulates starting multiple-transport-proto-`trio.abc.Listener`s
from an input set of `._addr.Address`s using,
|_`IPCServer.listen_on()` which internally spawns tasks that delegate to a new
`_serve_ipc_eps()`, a rework of what was (effectively)
`Actor._serve_forever()` and which now,
* allocates a new `IPCEndpoint`-struct (see below) for each
address-listener pair alongside the specified
listener-serving/stream-handling `trio.Nursery`s provided by the
caller.
* starts and stops each transport (socket's) listener by calling
`IPCEndpoint.start/close_listener()` which in turn delegates to
the underlying `inspect.getmodule(IPCEndpoint.addr)` backend tpt
module's equivalent impl.
* tracks all created endpoints in a `._endpoints: list[IPCEndpoint]`
which is further exposed through public properties for
introspection of served transport-protocols and their addresses.
|_`IPCServer._[parent/stream_handler]_tn: Nursery`s which are either
allocated (in which case, as the same instance) or provided by the
caller of `open_ipc_server()` such that the same nursery-cancel-scope
controls offered by `trio.serve_listeners(handler_nursery=)` are
offered where the `._parent_tn` is used to spawn `_serve_ipc_eps()`
tasks, and `._stream_handler_tn` is passed verbatim as `handler_nursery`.
- a new `IPCEndpoint`-struct (as mentioned) which wraps each
transport-proto's address + listener + allocated-supervising-nursery
to encapsulate the "lifetime of a server IPC endpoint" such that
eventually we can track and managed per-protocol/address/`.listen_on()`-call
scoped starts/stops/restarts for the purposes of filtering/banning
peer traffic.
|_ also included is an unused `.peer_tpts` table which we can
hopefully use to replace `Actor._peers` in a `Channel`-tracking
transport-proto-aware way!
Surrounding changes to `.ipc.*` primitives to match,
- make `[TCP|UDS]Address` types `msgspec.Struct(frozen=True)` and thus
drop any-and-all `addr._host =` style mutation throughout.
|_ as such also drop their `.__init__()` and `.__eq__()` meths.
|_ UDS tweaks to field names and thus `.__repr__()`.
- move `[TCP|UDS]Address.[start/close]_listener()` meths to be mod-level
equiv `start|close_listener()` funcs.
- just hard code the `.ipc._types._key_to_transport/._addr_to_transport`
table entries instead of all the prior fancy dynamic class property
reading stuff (remember, "explicit is better then implicit").
Modified in `._runtime.Actor` internals,
- drop the `._serve_forever()` and `.cancel_server()`, methods and
`._server_down` waiting logic from `.cancel_soon()`
- add `.[_]ipc_server` which is opened just after the `._service_n` and
delegate to it for any equivalent publicly exposed instance
attributes/properties.
2025-04-10 22:06:12 +00:00
|
|
|
Return the transport protocol's address pair for the local
|
|
|
|
|
and remote-peer side.
|
2025-03-22 18:29:48 +00:00
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
...
|
|
|
|
|
|
2025-04-02 02:01:51 +00:00
|
|
|
# TODO, such that all `.raddr`s for each `SocketStream` are
|
|
|
|
|
# delivered?
|
|
|
|
|
# -[ ] move `.open_listener()` here and internally track the
|
|
|
|
|
# listener set, per address?
|
|
|
|
|
# def get_peers(
|
|
|
|
|
# self,
|
|
|
|
|
# ) -> list[Address]:
|
|
|
|
|
# ...
|
|
|
|
|
|
|
|
|
|
|
2025-03-22 18:29:48 +00:00
|
|
|
class MsgpackTransport(MsgTransport):
|
|
|
|
|
|
|
|
|
|
# TODO: better naming for this?
|
|
|
|
|
# -[ ] check how libp2p does naming for such things?
|
|
|
|
|
codec_key: str = 'msgpack'
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
stream: trio.abc.Stream,
|
|
|
|
|
prefix_size: int = 4,
|
|
|
|
|
|
|
|
|
|
# XXX optionally provided codec pair for `msgspec`:
|
|
|
|
|
# https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types
|
|
|
|
|
#
|
|
|
|
|
# TODO: define this as a `Codec` struct which can be
|
|
|
|
|
# overriden dynamically by the application/runtime?
|
|
|
|
|
codec: MsgCodec = None,
|
|
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
self.stream = stream
|
2025-03-31 02:42:51 +00:00
|
|
|
(
|
|
|
|
|
self._laddr,
|
|
|
|
|
self._raddr,
|
|
|
|
|
) = self.get_stream_addrs(stream)
|
2025-03-22 18:29:48 +00:00
|
|
|
|
|
|
|
|
# create read loop instance
|
|
|
|
|
self._aiter_pkts = self._iter_packets()
|
|
|
|
|
self._send_lock = trio.StrictFIFOLock()
|
|
|
|
|
|
|
|
|
|
# public i guess?
|
|
|
|
|
self.drained: list[dict] = []
|
|
|
|
|
|
|
|
|
|
self.recv_stream = BufferedReceiveStream(
|
|
|
|
|
transport_stream=stream
|
|
|
|
|
)
|
|
|
|
|
self.prefix_size = prefix_size
|
|
|
|
|
|
|
|
|
|
# allow for custom IPC msg interchange format
|
|
|
|
|
# dynamic override Bo
|
|
|
|
|
self._task = trio.lowlevel.current_task()
|
|
|
|
|
|
|
|
|
|
# XXX for ctxvar debug only!
|
|
|
|
|
# self._codec: MsgCodec = (
|
|
|
|
|
# codec
|
|
|
|
|
# or
|
|
|
|
|
# _codec._ctxvar_MsgCodec.get()
|
|
|
|
|
# )
|
|
|
|
|
|
|
|
|
|
async def _iter_packets(self) -> AsyncGenerator[dict, None]:
|
|
|
|
|
'''
|
|
|
|
|
Yield `bytes`-blob decoded packets from the underlying TCP
|
|
|
|
|
stream using the current task's `MsgCodec`.
|
|
|
|
|
|
|
|
|
|
This is a streaming routine implemented as an async generator
|
|
|
|
|
func (which was the original design, but could be changed?)
|
|
|
|
|
and is allocated by a `.__call__()` inside `.__init__()` where
|
|
|
|
|
it is assigned to the `._aiter_pkts` attr.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
decodes_failed: int = 0
|
|
|
|
|
|
2025-04-06 17:54:10 +00:00
|
|
|
tpt_name: str = f'{type(self).__name__!r}'
|
2025-03-22 18:29:48 +00:00
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
header: bytes = await self.recv_stream.receive_exactly(4)
|
|
|
|
|
except (
|
|
|
|
|
ValueError,
|
|
|
|
|
ConnectionResetError,
|
|
|
|
|
|
|
|
|
|
# not sure entirely why we need this but without it we
|
|
|
|
|
# seem to be getting racy failures here on
|
Rename `Arbiter` -> `Registrar`, mv to `discovery._registry`
Move the `Arbiter` class out of `runtime._runtime` into its
logical home at `discovery._registry` as `Registrar(Actor)`.
This completes the long-standing terminology migration from
"arbiter" to "registrar/registry" throughout the codebase.
Deats,
- add new `discovery/_registry.py` mod with `Registrar`
class + backward-compat `Arbiter = Registrar` alias.
- rename `Actor.is_arbiter` attr -> `.is_registrar`;
old attr now a `@property` with `DeprecationWarning`.
- `_root.py` imports `Registrar` directly for
root-actor instantiation.
- export `Registrar` + `Arbiter` from `tractor.__init__`.
- `_runtime.py` re-imports from `discovery._registry`
for backward compat.
Also,
- update all test files to use `.is_registrar`
(`test_local`, `test_rpc`, `test_spawning`,
`test_discovery`, `test_multi_program`).
- update "arbiter" -> "registrar" in comments/docstrings
across `_discovery.py`, `_server.py`, `_transport.py`,
`_testing/pytest.py`, and examples.
- drop resolved TODOs from `_runtime.py` and `_root.py`.
(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-03-23 22:56:21 +00:00
|
|
|
# registrar name subs..
|
2025-03-22 18:29:48 +00:00
|
|
|
trio.BrokenResourceError,
|
|
|
|
|
|
|
|
|
|
) as trans_err:
|
|
|
|
|
|
|
|
|
|
loglevel = 'transport'
|
|
|
|
|
match trans_err:
|
|
|
|
|
# case (
|
|
|
|
|
# ConnectionResetError()
|
|
|
|
|
# ):
|
|
|
|
|
# loglevel = 'transport'
|
|
|
|
|
|
|
|
|
|
# peer actor (graceful??) TCP EOF but `tricycle`
|
|
|
|
|
# seems to raise a 0-bytes-read?
|
|
|
|
|
case ValueError() if (
|
|
|
|
|
'unclean EOF' in trans_err.args[0]
|
|
|
|
|
):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# peer actor (task) prolly shutdown quickly due
|
|
|
|
|
# to cancellation
|
|
|
|
|
case trio.BrokenResourceError() if (
|
|
|
|
|
'Connection reset by peer' in trans_err.args[0]
|
|
|
|
|
):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# unless the disconnect condition falls under "a
|
|
|
|
|
# normal operation breakage" we usualy console warn
|
|
|
|
|
# about it.
|
|
|
|
|
case _:
|
|
|
|
|
loglevel: str = 'warning'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
raise TransportClosed(
|
|
|
|
|
message=(
|
2025-04-06 17:54:10 +00:00
|
|
|
f'{tpt_name} already closed by peer\n'
|
2025-03-22 18:29:48 +00:00
|
|
|
),
|
2025-04-06 17:54:10 +00:00
|
|
|
src_exc=trans_err,
|
2025-03-22 18:29:48 +00:00
|
|
|
loglevel=loglevel,
|
|
|
|
|
) from trans_err
|
|
|
|
|
|
|
|
|
|
# XXX definitely can happen if transport is closed
|
|
|
|
|
# manually by another `trio.lowlevel.Task` in the
|
|
|
|
|
# same actor; we use this in some simulated fault
|
|
|
|
|
# testing for ex, but generally should never happen
|
|
|
|
|
# under normal operation!
|
|
|
|
|
#
|
|
|
|
|
# NOTE: as such we always re-raise this error from the
|
|
|
|
|
# RPC msg loop!
|
2025-04-06 17:54:10 +00:00
|
|
|
except trio.ClosedResourceError as cre:
|
|
|
|
|
closure_err = cre
|
|
|
|
|
|
2026-02-19 00:36:45 +00:00
|
|
|
# await tractor.devx._trace.maybe_pause_bp()
|
|
|
|
|
|
2025-03-22 18:29:48 +00:00
|
|
|
raise TransportClosed(
|
|
|
|
|
message=(
|
2026-02-19 00:36:45 +00:00
|
|
|
f'{tpt_name} was already closed locally?'
|
2025-03-22 18:29:48 +00:00
|
|
|
),
|
2025-04-06 17:54:10 +00:00
|
|
|
src_exc=closure_err,
|
2025-03-22 18:29:48 +00:00
|
|
|
loglevel='error',
|
|
|
|
|
raise_on_report=(
|
2026-02-19 00:36:45 +00:00
|
|
|
'another task closed this fd'
|
|
|
|
|
in
|
|
|
|
|
closure_err.args
|
2025-03-22 18:29:48 +00:00
|
|
|
),
|
|
|
|
|
) from closure_err
|
|
|
|
|
|
|
|
|
|
# graceful TCP EOF disconnect
|
|
|
|
|
if header == b'':
|
|
|
|
|
raise TransportClosed(
|
|
|
|
|
message=(
|
2025-04-06 17:54:10 +00:00
|
|
|
f'{tpt_name} already gracefully closed\n'
|
2025-03-22 18:29:48 +00:00
|
|
|
),
|
|
|
|
|
loglevel='transport',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
size: int
|
|
|
|
|
size, = struct.unpack("<I", header)
|
|
|
|
|
|
|
|
|
|
log.transport(f'received header {size}') # type: ignore
|
|
|
|
|
msg_bytes: bytes = await self.recv_stream.receive_exactly(size)
|
|
|
|
|
|
2026-08-12 17:48:40 +00:00
|
|
|
if log.at_least_level('transport'):
|
|
|
|
|
log.transport( # type: ignore
|
|
|
|
|
f'received {msg_bytes}'
|
|
|
|
|
)
|
2025-03-22 18:29:48 +00:00
|
|
|
try:
|
|
|
|
|
# NOTE: lookup the `trio.Task.context`'s var for
|
|
|
|
|
# the current `MsgCodec`.
|
|
|
|
|
codec: MsgCodec = _ctxvar_MsgCodec.get()
|
|
|
|
|
|
|
|
|
|
# XXX for ctxvar debug only!
|
|
|
|
|
# if self._codec.pld_spec != codec.pld_spec:
|
|
|
|
|
# assert (
|
|
|
|
|
# task := trio.lowlevel.current_task()
|
|
|
|
|
# ) is not self._task
|
|
|
|
|
# self._task = task
|
|
|
|
|
# self._codec = codec
|
|
|
|
|
# log.runtime(
|
|
|
|
|
# f'Using new codec in {self}.recv()\n'
|
|
|
|
|
# f'codec: {self._codec}\n\n'
|
|
|
|
|
# f'msg_bytes: {msg_bytes}\n'
|
|
|
|
|
# )
|
|
|
|
|
yield codec.decode(msg_bytes)
|
|
|
|
|
|
|
|
|
|
# XXX NOTE: since the below error derives from
|
|
|
|
|
# `DecodeError` we need to catch is specially
|
|
|
|
|
# and always raise such that spec violations
|
|
|
|
|
# are never allowed to be caught silently!
|
|
|
|
|
except msgspec.ValidationError as verr:
|
|
|
|
|
msgtyperr: MsgTypeError = _mk_recv_mte(
|
|
|
|
|
msg=msg_bytes,
|
|
|
|
|
codec=codec,
|
|
|
|
|
src_validation_error=verr,
|
|
|
|
|
)
|
|
|
|
|
# XXX deliver up to `Channel.recv()` where
|
|
|
|
|
# a re-raise and `Error`-pack can inject the far
|
|
|
|
|
# end actor `.uid`.
|
|
|
|
|
yield msgtyperr
|
|
|
|
|
|
|
|
|
|
except (
|
|
|
|
|
msgspec.DecodeError,
|
|
|
|
|
UnicodeDecodeError,
|
|
|
|
|
):
|
|
|
|
|
if decodes_failed < 4:
|
|
|
|
|
# ignore decoding errors for now and assume they have to
|
|
|
|
|
# do with a channel drop - hope that receiving from the
|
|
|
|
|
# channel will raise an expected error and bubble up.
|
|
|
|
|
try:
|
|
|
|
|
msg_str: str|bytes = msg_bytes.decode()
|
|
|
|
|
except UnicodeDecodeError:
|
|
|
|
|
msg_str = msg_bytes
|
|
|
|
|
|
|
|
|
|
log.exception(
|
|
|
|
|
'Failed to decode msg?\n'
|
|
|
|
|
f'{codec}\n\n'
|
|
|
|
|
'Rxed bytes from wire:\n\n'
|
|
|
|
|
f'{msg_str!r}\n'
|
|
|
|
|
)
|
|
|
|
|
decodes_failed += 1
|
|
|
|
|
else:
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
async def send(
|
|
|
|
|
self,
|
|
|
|
|
msg: msgtypes.MsgType,
|
|
|
|
|
|
|
|
|
|
strict_types: bool = True,
|
2025-04-11 03:37:16 +00:00
|
|
|
hide_tb: bool = True,
|
2026-08-21 02:52:20 +00:00
|
|
|
send_deadline: float = float('inf'),
|
2025-03-22 18:29:48 +00:00
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Send a msgpack encoded py-object-blob-as-msg over TCP.
|
|
|
|
|
|
|
|
|
|
If `strict_types == True` then a `MsgTypeError` will be raised on any
|
|
|
|
|
invalid msg type
|
|
|
|
|
|
2026-08-21 02:52:20 +00:00
|
|
|
`send_deadline` bounds publication of this complete frame. A
|
|
|
|
|
timeout destroys the stream because a partial prefix may have
|
|
|
|
|
reached the wire.
|
|
|
|
|
|
2025-03-22 18:29:48 +00:00
|
|
|
'''
|
|
|
|
|
__tracebackhide__: bool = hide_tb
|
|
|
|
|
|
|
|
|
|
# XXX see `trio._sync.AsyncContextManagerMixin` for details
|
|
|
|
|
# on the `.acquire()`/`.release()` sequencing..
|
|
|
|
|
async with self._send_lock:
|
|
|
|
|
|
|
|
|
|
# NOTE: lookup the `trio.Task.context`'s var for
|
|
|
|
|
# the current `MsgCodec`.
|
|
|
|
|
codec: MsgCodec = _ctxvar_MsgCodec.get()
|
|
|
|
|
|
|
|
|
|
# XXX for ctxvar debug only!
|
|
|
|
|
# if self._codec.pld_spec != codec.pld_spec:
|
|
|
|
|
# self._codec = codec
|
|
|
|
|
# log.runtime(
|
|
|
|
|
# f'Using new codec in {self}.send()\n'
|
|
|
|
|
# f'codec: {self._codec}\n\n'
|
|
|
|
|
# f'msg: {msg}\n'
|
|
|
|
|
# )
|
|
|
|
|
|
|
|
|
|
if type(msg) not in msgtypes.__msg_types__:
|
|
|
|
|
if strict_types:
|
|
|
|
|
raise _mk_send_mte(
|
|
|
|
|
msg,
|
|
|
|
|
codec=codec,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
log.warning(
|
|
|
|
|
'Sending non-`Msg`-spec msg?\n\n'
|
|
|
|
|
f'{msg}\n'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
bytes_data: bytes = codec.encode(msg)
|
|
|
|
|
except TypeError as _err:
|
|
|
|
|
typerr = _err
|
|
|
|
|
msgtyperr: MsgTypeError = _mk_send_mte(
|
|
|
|
|
msg,
|
|
|
|
|
codec=codec,
|
|
|
|
|
message=(
|
|
|
|
|
f'IPC-msg-spec violation in\n\n'
|
|
|
|
|
f'{pretty_struct.Struct.pformat(msg)}'
|
|
|
|
|
),
|
|
|
|
|
src_type_error=typerr,
|
|
|
|
|
)
|
|
|
|
|
raise msgtyperr from typerr
|
|
|
|
|
|
|
|
|
|
# supposedly the fastest says,
|
|
|
|
|
# https://stackoverflow.com/a/54027962
|
|
|
|
|
size: bytes = struct.pack("<I", len(bytes_data))
|
2025-04-01 16:56:28 +00:00
|
|
|
try:
|
2026-08-20 02:01:53 +00:00
|
|
|
# Every IPC msg is length-prefixed and all contexts
|
|
|
|
|
# on this actor pair share one transport stream. If
|
|
|
|
|
# cancellation interrupts `send_all()`, an unknown
|
|
|
|
|
# frame prefix may already be on the wire; allowing
|
|
|
|
|
# the next sender to append would corrupt framing.
|
|
|
|
|
# Closing the stream avoids that corruption but lets
|
|
|
|
|
# one context-local cancellation destroy every sibling
|
|
|
|
|
# context using the channel.
|
|
|
|
|
#
|
|
|
|
|
# Keep the `._send_lock` and defer cancellation only
|
|
|
|
|
# for complete frame publication. Broken/closed stream
|
|
|
|
|
# failures still escape to the handlers below. Once
|
|
|
|
|
# the frame is complete, the explicit checkpoint
|
|
|
|
|
# immediately delivers any pending cancellation.
|
|
|
|
|
#
|
2026-08-25 05:38:17 +00:00
|
|
|
# Ordinary sends may delay cancellation while the remote
|
|
|
|
|
# peer actor is not reading. Bounded actor/context cancel
|
|
|
|
|
# requests pass an absolute deadline so this operation
|
|
|
|
|
# can close a stalled stream.
|
2026-08-21 02:52:20 +00:00
|
|
|
with trio.CancelScope(
|
|
|
|
|
deadline=send_deadline,
|
|
|
|
|
shield=True,
|
|
|
|
|
) as send_cs:
|
2026-08-20 02:01:53 +00:00
|
|
|
await self.stream.send_all(size + bytes_data)
|
|
|
|
|
|
2026-08-21 02:52:20 +00:00
|
|
|
if send_cs.cancelled_caught:
|
|
|
|
|
# This frame may be partial. Destroy the stream
|
|
|
|
|
# before releasing `_send_lock` so no later sender
|
|
|
|
|
# can append bytes to a corrupted frame.
|
|
|
|
|
await trio.aclose_forcefully(self.stream)
|
|
|
|
|
await trio.lowlevel.checkpoint_if_cancelled()
|
|
|
|
|
raise TransportClosed(
|
|
|
|
|
'IPC frame publication exceeded its '
|
|
|
|
|
f'deadline of {send_deadline!r}'
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-20 02:01:53 +00:00
|
|
|
await trio.lowlevel.checkpoint_if_cancelled()
|
|
|
|
|
return None
|
|
|
|
|
|
2025-04-01 16:56:28 +00:00
|
|
|
except (
|
|
|
|
|
trio.BrokenResourceError,
|
2025-08-12 14:29:56 +00:00
|
|
|
trio.ClosedResourceError,
|
2025-07-29 19:07:43 +00:00
|
|
|
) as _re:
|
2026-08-20 15:58:39 +00:00
|
|
|
# A shielded send can race outer cancellation with
|
|
|
|
|
# stream teardown. If teardown closes the stream, let
|
|
|
|
|
# the pending cancellation retain precedence instead
|
|
|
|
|
# of converting that close into `TransportClosed`.
|
|
|
|
|
await trio.lowlevel.checkpoint_if_cancelled()
|
|
|
|
|
|
2025-07-29 19:07:43 +00:00
|
|
|
trans_err = _re
|
2025-04-11 03:37:16 +00:00
|
|
|
tpt_name: str = f'{type(self).__name__!r}'
|
2025-07-29 19:07:43 +00:00
|
|
|
|
2026-08-13 22:30:44 +00:00
|
|
|
trans_err_msg: str = (
|
|
|
|
|
str(trans_err.args[0])
|
|
|
|
|
if trans_err.args
|
|
|
|
|
else ''
|
|
|
|
|
)
|
2026-02-19 00:36:45 +00:00
|
|
|
by_whom: str = {
|
|
|
|
|
'another task closed this fd': 'locally',
|
|
|
|
|
'this socket was already closed': 'by peer',
|
|
|
|
|
}.get(trans_err_msg)
|
2025-04-01 16:56:28 +00:00
|
|
|
match trans_err:
|
2025-07-29 19:07:43 +00:00
|
|
|
|
2026-08-13 22:30:44 +00:00
|
|
|
# UDS peers can disconnect before handshake.
|
|
|
|
|
# Linux normally reports `EPIPE`; Darwin reports
|
|
|
|
|
# `ECONNRESET` for the same expected closure.
|
2025-04-01 16:56:28 +00:00
|
|
|
case trio.BrokenResourceError() if (
|
2026-08-13 22:30:44 +00:00
|
|
|
_peer_closed_errno(trans_err)
|
|
|
|
|
is not None
|
2025-04-01 16:56:28 +00:00
|
|
|
):
|
2025-07-29 19:07:43 +00:00
|
|
|
tpt_closed = TransportClosed.from_src_exc(
|
2025-04-01 16:56:28 +00:00
|
|
|
message=(
|
2025-04-11 03:37:16 +00:00
|
|
|
f'{tpt_name} already closed by peer\n'
|
2025-08-12 14:29:56 +00:00
|
|
|
),
|
2026-02-19 00:36:45 +00:00
|
|
|
body=f'{self}',
|
2025-08-12 14:29:56 +00:00
|
|
|
src_exc=trans_err,
|
|
|
|
|
raise_on_report=True,
|
|
|
|
|
loglevel='transport',
|
|
|
|
|
)
|
|
|
|
|
raise tpt_closed from trans_err
|
|
|
|
|
|
2026-02-11 18:49:52 +00:00
|
|
|
# ??TODO??, what case in piker does this and HOW
|
|
|
|
|
# CAN WE RE-PRODUCE IT?!?!?
|
|
|
|
|
case trio.ClosedResourceError() if (
|
2026-02-19 00:36:45 +00:00
|
|
|
by_whom
|
2026-02-11 18:49:52 +00:00
|
|
|
):
|
|
|
|
|
tpt_closed = TransportClosed.from_src_exc(
|
|
|
|
|
message=(
|
2026-02-19 00:36:45 +00:00
|
|
|
f'{tpt_name} was already closed {by_whom!r}?\n'
|
2026-02-11 18:49:52 +00:00
|
|
|
),
|
2026-02-19 00:36:45 +00:00
|
|
|
body=f'{self}',
|
2026-02-11 18:49:52 +00:00
|
|
|
src_exc=trans_err,
|
|
|
|
|
raise_on_report=True,
|
|
|
|
|
loglevel='transport',
|
|
|
|
|
)
|
2026-02-19 00:36:45 +00:00
|
|
|
|
|
|
|
|
# await tractor.devx._trace.maybe_pause_bp()
|
2026-02-11 18:49:52 +00:00
|
|
|
raise tpt_closed from trans_err
|
2025-04-01 16:56:28 +00:00
|
|
|
|
2026-02-19 21:18:39 +00:00
|
|
|
# XXX, unless the disconnect condition falls
|
|
|
|
|
# under "a normal/expected operating breakage"
|
|
|
|
|
# (per the `trans_err_msg` guards in the cases
|
|
|
|
|
# above) we usualy console-error about it and
|
|
|
|
|
# raise-thru. about it.
|
2025-04-01 16:56:28 +00:00
|
|
|
case _:
|
2025-04-02 02:01:51 +00:00
|
|
|
log.exception(
|
2025-07-29 19:07:43 +00:00
|
|
|
f'{tpt_name} layer failed pre-send ??\n'
|
2025-04-02 02:01:51 +00:00
|
|
|
)
|
2025-04-01 16:56:28 +00:00
|
|
|
raise trans_err
|
2025-03-22 18:29:48 +00:00
|
|
|
|
|
|
|
|
# ?TODO? does it help ever to dynamically show this
|
|
|
|
|
# frame?
|
|
|
|
|
# try:
|
|
|
|
|
# <the-above_code>
|
|
|
|
|
# except BaseException as _err:
|
|
|
|
|
# err = _err
|
|
|
|
|
# if not isinstance(err, MsgTypeError):
|
|
|
|
|
# __tracebackhide__: bool = False
|
|
|
|
|
# raise
|
|
|
|
|
|
2025-03-22 19:17:50 +00:00
|
|
|
async def recv(self) -> msgtypes.MsgType:
|
2025-03-22 18:29:48 +00:00
|
|
|
return await self._aiter_pkts.asend(None)
|
|
|
|
|
|
|
|
|
|
async def drain(self) -> AsyncIterator[dict]:
|
|
|
|
|
'''
|
|
|
|
|
Drain the stream's remaining messages sent from
|
|
|
|
|
the far end until the connection is closed by
|
|
|
|
|
the peer.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
try:
|
|
|
|
|
async for msg in self._iter_packets():
|
|
|
|
|
self.drained.append(msg)
|
|
|
|
|
except TransportClosed:
|
|
|
|
|
for msg in self.drained:
|
|
|
|
|
yield msg
|
|
|
|
|
|
|
|
|
|
def __aiter__(self):
|
|
|
|
|
return self._aiter_pkts
|
|
|
|
|
|
|
|
|
|
@property
|
2025-03-23 03:14:04 +00:00
|
|
|
def laddr(self) -> Address:
|
2025-03-22 18:29:48 +00:00
|
|
|
return self._laddr
|
|
|
|
|
|
|
|
|
|
@property
|
2025-03-23 03:14:04 +00:00
|
|
|
def raddr(self) -> Address:
|
2025-03-22 18:29:48 +00:00
|
|
|
return self._raddr
|
2025-04-02 02:01:51 +00:00
|
|
|
|
|
|
|
|
def pformat(self) -> str:
|
|
|
|
|
return (
|
|
|
|
|
f'<{type(self).__name__}(\n'
|
2025-07-29 19:07:43 +00:00
|
|
|
f' |_peers: 1\n'
|
2025-04-02 02:01:51 +00:00
|
|
|
f' laddr: {self._laddr}\n'
|
|
|
|
|
f' raddr: {self._raddr}\n'
|
2025-04-11 03:37:16 +00:00
|
|
|
# f'\n'
|
|
|
|
|
f' |_task: {self._task}\n'
|
2025-04-02 02:01:51 +00:00
|
|
|
f')>\n'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
__repr__ = __str__ = pformat
|