Reconcile tpt plans with runtime contracts
Bring the shared backend contract and TIPC plan in line with current runtime behavior and downstream implementation evidence. Rework the QUIC plan around actor-owned endpoint, bootstrap, stream, listener and cleanup lifecycles, with explicit validation gates for the still-unverified UniFFI details. (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))ng_tpts_planning
parent
83e4af169c
commit
7c2de6c359
|
|
@ -33,19 +33,35 @@ doc in the same PR.
|
|||
|
||||
## 1. The backend duck-type (empirical, from `_tcp.py`/`_uds.py`)
|
||||
|
||||
A transport backend is **one module** under `tractor/ipc/`
|
||||
exposing exactly four things. There is no ABC to subclass and no
|
||||
plugin entrypoint; wiring is by explicit table registration
|
||||
(§2) plus one piece of reflection (§1.3).
|
||||
A transport backend is **one module** under `tractor/ipc/`.
|
||||
There is no ABC to subclass and no plugin entrypoint; wiring is by
|
||||
explicit table registration (§2) plus one piece of reflection
|
||||
(§1.3).
|
||||
|
||||
Keep two contracts distinct:
|
||||
|
||||
- `tractor.discovery._addr.Address` is a static `Protocol`. It
|
||||
declares address-wrapper members including `namespace`,
|
||||
`open_listener()` and `close_listener()`.
|
||||
- the runtime's empirical contract is what `_tcp.py`, `_uds.py`
|
||||
and `_server.py` actually call. The current address classes do
|
||||
not implement every declared `Address` member: listener
|
||||
lifecycle is module-level, `def_bindspace` is used despite not
|
||||
being declared by the `Protocol`, and `namespace` remains
|
||||
aspirational.
|
||||
|
||||
Until those surfaces are deliberately reconciled, implement the
|
||||
empirical module contract below and update the static `Protocol`
|
||||
only when the runtime really consumes the new member. Do not claim
|
||||
that structural conformance alone defines a backend.
|
||||
|
||||
### 1.1 `class <Proto>Address(msgspec.Struct, frozen=True)`
|
||||
|
||||
Structurally conforms to the `Address` `Protocol` in
|
||||
`tractor/discovery/_addr.py:82`. Required surface:
|
||||
The runtime-consumed address-wrapper surface is:
|
||||
|
||||
| member | kind | notes |
|
||||
| --- | --- | --- |
|
||||
| `proto_key` | `ClassVar[str]` | the wire/registry key, e.g. `'tcp'`, `'uds'` |
|
||||
| `proto_key` | `ClassVar[str]` | internal transport key, e.g. `'tcp'`, `'uds'` |
|
||||
| `unwrapped_type` | `ClassVar[type]` | the primitive tuple shape |
|
||||
| `def_bindspace` | `ClassVar` | default bindspace value |
|
||||
| `is_valid` | `@property -> bool` | "is this a *dialable/bindable* addr" |
|
||||
|
|
@ -81,22 +97,32 @@ Hard constraints learned from the existing two:
|
|||
paper over it at best.
|
||||
|
||||
**The fix, and the recommended prerequisite for all three
|
||||
backends: make the unwrapped form carry an explicit
|
||||
proto-key, using the `multiaddr` protocol name as the
|
||||
canonical spelling** — `('tcp', host, port)`,
|
||||
`('unix', path)`, `('udp', ...)`, `('tipc', stype, inst,
|
||||
scope)`. Then `wrap_address()` collapses from an
|
||||
order-sensitive `match` to `_address_types[addr[0]]`, and the
|
||||
whole collision class stops existing. Note this *also* aligns
|
||||
the on-wire form with `mk_maddr()`/`parse_maddr()`, so the two
|
||||
representations stop being independent inventions.
|
||||
backends: make the unwrapped form carry an explicit internal
|
||||
proto-key** — `('tcp', host, port)`,
|
||||
`('uds', filedir, filename)`, `('tipc', stype, inst, scope)`.
|
||||
The tag must be a `TransportProtocolKey`/registry key. In
|
||||
particular it is **`'uds'`, not the external multiaddr spelling
|
||||
`'unix'`**. If a wire or display format uses a different name,
|
||||
name that translation explicitly; today `_multiaddr.py` maps
|
||||
internal `uds` to external `/unix/`. Then `wrap_address()` can
|
||||
dispatch through `_address_types[addr[0]]` without an
|
||||
order-sensitive shape match.
|
||||
|
||||
Two consequences to plan for:
|
||||
- it's a **wire-format change** (`SpawnSpec`,
|
||||
`_root_mailbox`, `_registry_addrs`) plus every test fixture
|
||||
and downstream config (`piker`'s `[network]` table). It
|
||||
wants its **own migration commit, landed before any new
|
||||
backend**, not smuggled into one.
|
||||
- it's a **wire-format change**. Widen and keep synchronized
|
||||
`discovery._addr.UnwrappedAddress` and the duplicate wire
|
||||
alias in `msg.types`; change `SpawnSpec.reg_addrs` and
|
||||
`.bind_addrs`, not only `_root_mailbox` and
|
||||
`_registry_addrs`. Audit the related `RuntimeVars`
|
||||
`_root_mailbox`/`_root_addrs` annotations, `Actor.reg_addrs`
|
||||
and accept-address annotations, channel/spawn signatures,
|
||||
fixtures, and downstream config (`piker`'s `[network]`
|
||||
table). `msgspec` rejects a union containing multiple
|
||||
array-like tuple shapes, so #493 used
|
||||
`tuple[str|int, ...]` as the truthful transitional wire type;
|
||||
the complete proto-key migration can restore per-proto
|
||||
validation. This wants its **own migration commit, landed
|
||||
before any new backend**, not smuggled into one.
|
||||
- it's the moment to **stop handing raw unwrapped tuples to
|
||||
users at all.** The long-term shape is: `Address` subtypes
|
||||
are the public currency and `UnwrappedAddress` becomes an
|
||||
|
|
@ -104,8 +130,8 @@ Hard constraints learned from the existing two:
|
|||
`ipaddress` uses (you pass `IPv4Address`, not a 4-tuple).
|
||||
Public API should accept `Address|maddr-str` and treat bare
|
||||
tuples as legacy-tolerated input, ideally deprecated.
|
||||
- **`.get_random()` must be collision-free without a live
|
||||
runtime.** See the `UDSAddress.get_random()` uuid-token
|
||||
- **`.get_random()` must not deterministically alias without a
|
||||
live runtime.** See the `UDSAddress.get_random()` uuid-token
|
||||
comment (`_uds.py:207-220`): with no `current_actor()` the
|
||||
sockname degenerates to a pure fn of `(prefix, pid)` and two
|
||||
calls in one proc alias. Mix in a `uuid4().hex[:8]` token.
|
||||
|
|
@ -162,10 +188,11 @@ if (unwrapped := lstnr.socket.getsockname()) != self.addr.unwrap():
|
|||
```
|
||||
|
||||
i.e. it assumes `lstnr.socket.getsockname()` exists and that its
|
||||
return value is a valid `from_addr()` input. This is fine for
|
||||
TIPC (§3 of plan 01) and **is the main integration hazard for
|
||||
iroh** (§3 of plan 02) — plans that break it must say so
|
||||
explicitly and propose the upstream `_server.py` patch.
|
||||
return value is a valid `from_addr()` input. That is false for
|
||||
TIPC, whose listener sockname is an undialable port ID, and for
|
||||
non-socket iroh. Both plans must use the explicit backend rebind
|
||||
policy added at this integration point rather than pretending a
|
||||
sockname is always an address replacement.
|
||||
|
||||
### 1.4 `class Msgpack<Proto>Stream(MsgpackTransport)`
|
||||
|
||||
|
|
@ -224,20 +251,26 @@ path.** This is why plan 01 is small and plan 02 is not.
|
|||
|
||||
---
|
||||
|
||||
## 2. Registration tables (the full wiring checklist)
|
||||
## 2. Registration and policy wiring
|
||||
|
||||
Adding a backend touches these and only these:
|
||||
Adding a backend requires this complete audit. Not every item
|
||||
changes for every backend, but none may be assumed from the others:
|
||||
|
||||
1. `tractor/runtime/_state.py:46`
|
||||
`TransportProtocolKey = Literal['tcp', 'uds', ...]` — add the
|
||||
key. This `Literal` is the canonical set; `_testing/pytest.py`
|
||||
drives `--tpt-proto` validation off `_addr._address_types`,
|
||||
and the spawn-backend fixture already models the
|
||||
"drive-the-set-from-the-Literal" pattern
|
||||
(`pytest.py:870-880`) — do the same rather than hardcoding.
|
||||
2. `tractor/discovery/_addr.py:173` `_address_types: bidict` —
|
||||
`{'<key>': <Proto>Address}`. Note it is a **`bidict`**, so
|
||||
the mapping must stay 1:1.
|
||||
internal key. This `Literal` is the **declared protocol-key
|
||||
set**, not proof that a backend is usable on this host.
|
||||
2. `tractor/discovery/_addr.py` `_address_protos` and
|
||||
`_address_types: dict[str, Type[Address]]` — register
|
||||
`'<key>': <Proto>Address`. `_address_types` is a plain
|
||||
**`dict`, not a `bidict`**, and represents the backends this
|
||||
build registers for import and dispatch. UDS is conditional on
|
||||
`HAS_UDS`, while TIPC can remain registered on a host where its
|
||||
kernel support is unavailable. An importable backend with a
|
||||
runtime capability requirement therefore needs a separate
|
||||
availability check. Never conflate this dispatch registry with
|
||||
either host usability or the declared `TransportProtocolKey`
|
||||
universe.
|
||||
3. `tractor/discovery/_addr.py:181` `_default_lo_addrs` —
|
||||
`'<key>': <Proto>Address.get_root().unwrap()`.
|
||||
⚠️ this dict is built at **import time**, so
|
||||
|
|
@ -250,7 +283,7 @@ Adding a backend touches these and only these:
|
|||
add a case iff your `unwrapped_type` isn't already uniquely
|
||||
matched. **Preferably do the proto-key migration in §1.1
|
||||
first**, after which this step becomes a one-line
|
||||
`_address_types` entry instead of an order-sensitive `case`.
|
||||
`_address_types` lookup instead of an order-sensitive `case`.
|
||||
5. `tractor/ipc/_types.py` — `Address` union alias,
|
||||
`_msg_transports` list, `_key_to_transport[('msgpack', key)]`,
|
||||
`_addr_to_transport[<Proto>Address]`.
|
||||
|
|
@ -262,9 +295,17 @@ Adding a backend touches these and only these:
|
|||
`parse_maddr()`.
|
||||
8. `tractor/ipc/__init__.py` — re-export if the backend has a
|
||||
public surface.
|
||||
9. `tractor/_testing/addr.py::get_rando_addr()` — per-proto
|
||||
9. `tractor/discovery/_api.py::_is_local_addr()` and
|
||||
`prefer_addr()` — define and test the backend's locality and
|
||||
selection tier. The current order is UDS, local TCP, then
|
||||
remote. A new backend must not silently fall into `remote` by
|
||||
accident: for example TIPC node scope is local, cluster scope
|
||||
is not known-local, and an observed address with unknown scope
|
||||
must not be promoted. Preserve the last-registered tie-break
|
||||
unless intentionally changing policy.
|
||||
10. `tractor/_testing/addr.py::get_rando_addr()` — per-proto
|
||||
branch so the whole suite can run under `--tpt-proto <key>`.
|
||||
10. `pyproject.toml` — new deps go in an **optional extra**, never
|
||||
11. `pyproject.toml` — new deps go in an **optional extra**, never
|
||||
in `[project].dependencies`. See §5.
|
||||
|
||||
## 3. Where the `trio.SocketListener` assumption is load-bearing
|
||||
|
|
@ -339,7 +380,10 @@ dep-free, or make that table lazy.
|
|||
`_state._def_tpt_proto` + `_runtime_vars['_enable_tpts']`
|
||||
(`pytest.py:807-835`). Adding the key to `_address_types` is
|
||||
what makes `--tpt-proto <key>` legal (`pytest.py:795-800`
|
||||
asserts the lookup).
|
||||
asserts the lookup). Thus CLI acceptance follows the
|
||||
build-registered `_address_types`, while type-level declarations
|
||||
follow `TransportProtocolKey` and host usability follows each
|
||||
backend's capability probe; test all three layers separately.
|
||||
- The **acceptance bar** for every backend is: the *entire*
|
||||
existing suite passes under `--tpt-proto <key>`, unmodified.
|
||||
That is the whole point of the abstraction. Backend-specific
|
||||
|
|
@ -353,8 +397,11 @@ dep-free, or make that table lazy.
|
|||
`OSError(97, 'Address family not supported by protocol')`
|
||||
because the `tipc` module isn't loaded. Put the predicate in
|
||||
the backend module (so apps can use it too), not in the test.
|
||||
- New pytest marks must be registered in `pyproject.toml`, per
|
||||
the project's fix-warnings-at-source rule (gh #469).
|
||||
- New pytest marks must be registered in
|
||||
`_testing/pytest.py::pytest_configure()` with
|
||||
`config.addinivalue_line()`, alongside the existing custom
|
||||
marks. The repo has no `pyproject.toml` marker table. This is
|
||||
still part of the fix-warnings-at-source rule (gh #469).
|
||||
|
||||
## 7. Code style (non-negotiable, matches the repo)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,18 @@ Tracks gh [#378]. Prereq reading:
|
|||
[`00_shared_backend_contract.md`](./00_shared_backend_contract.md).
|
||||
|
||||
**Thesis**: TIPC is the *cheapest* new backend we can add and
|
||||
simultaneously the only one that gives us cluster-wide service
|
||||
discovery **for free, in the kernel**, replacing (for
|
||||
TIPC-capable deployments) the whole `tractor.discovery`
|
||||
registrar round-trip with a `bind()`/`connect()` on a
|
||||
*service name*. It is stdlib-only: zero new dependencies.
|
||||
gives us kernel-native service-name publication, known-address
|
||||
dialling, and topology events. Those are primitives for reducing
|
||||
registrar traffic; they do **not** by themselves replace
|
||||
`tractor.discovery`, derive an actor's address from its name, or
|
||||
elect one registrar. It is stdlib-only: zero new dependencies.
|
||||
|
||||
This plan is reconciled against downstream PR [#493]'s code and
|
||||
tests. Treat that implementation as prior art without mistaking
|
||||
implemented transport primitives for completed discovery policy.
|
||||
|
||||
[#378]: https://github.com/goodboy/tractor/issues/378
|
||||
[#493]: https://github.com/goodboy/tractor/pull/493
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -74,10 +79,12 @@ The design decision that makes this backend coherent:
|
|||
> ever an *observed* address (`getpeername()`), never a
|
||||
> user-facing one.**
|
||||
|
||||
This is exactly the "leverage the built-in discovery machinery"
|
||||
ask in #378: publishing a bind *is* registration, and
|
||||
`connect()` on a name *is* a lookup, with no registrar actor in
|
||||
the loop.
|
||||
This is the "leverage the built-in discovery machinery" part of
|
||||
#378: publishing a bind is kernel name-table registration and
|
||||
`connect()` on an already-known name is a kernel lookup, with no
|
||||
registrar actor on that **dial** path. Mapping an application name
|
||||
to that address and maintaining Tractor's actor registry remain
|
||||
separate work (§5).
|
||||
|
||||
### 2.2 the struct
|
||||
|
||||
|
|
@ -89,12 +96,12 @@ class TIPCAddress(
|
|||
_stype: int # TIPC "type" == service class
|
||||
_instance: int # service instance within the type
|
||||
_scope: int = TIPC_CLUSTER_SCOPE
|
||||
# observed-only, never part of identity/equality-by-intent
|
||||
# observed-only, excluded from the unwrapped service identity
|
||||
maybe_node: int|None = None # from TIPC_ADDR_ID getpeername()
|
||||
maybe_ref: int|None = None
|
||||
|
||||
proto_key: ClassVar[str] = 'tipc'
|
||||
unwrapped_type: ClassVar[type] = tuple[str, int]
|
||||
unwrapped_type: ClassVar[type] = tuple[str, int, int, int]
|
||||
def_bindspace: ClassVar[int] = TIPC_CLUSTER_SCOPE
|
||||
```
|
||||
|
||||
|
|
@ -106,8 +113,8 @@ shape as `TCPAddress`*, so `wrap_address()`'s
|
|||
`case (str(), int())` steals it. This backend is therefore the
|
||||
forcing function for the contract-doc's conclusion (§1.1):
|
||||
|
||||
> **make the unwrapped form carry an explicit proto-key, spelled
|
||||
> with the `multiaddr` protocol name.**
|
||||
> **make the unwrapped form carry the explicit internal
|
||||
> `TransportProtocolKey`.**
|
||||
|
||||
```python
|
||||
def unwrap(self) -> tuple[str, int, int, int]:
|
||||
|
|
@ -115,11 +122,31 @@ def unwrap(self) -> tuple[str, int, int, int]:
|
|||
```
|
||||
|
||||
`wrap_address()` then dispatches `_address_types[addr[0]]` and
|
||||
the collision class disappears. **This is a prerequisite
|
||||
migration commit, not part of this backend** — see contract §1.1
|
||||
for its blast radius (wire format + every fixture + `piker`
|
||||
config) and for the follow-on "stop handing raw tuples to users
|
||||
at all, à la `ipaddress`" direction.
|
||||
the collision class disappears. The complete all-backend change
|
||||
is a prerequisite migration; #493 necessarily carried the
|
||||
transitional `UnwrappedAddress`/`SpawnSpec.reg_addrs`/
|
||||
`.bind_addrs` widening needed for TIPC. See contract §1.1 for the
|
||||
remaining runtime annotations, fixtures and `piker` config. Here
|
||||
`tipc` is both the internal and external spelling; UDS remains
|
||||
internally `uds` and translates explicitly to external `/unix/`.
|
||||
|
||||
`msgpack` decodes tuples as lists, so both forms are part of the
|
||||
round-trip contract. Match only the exact three- or four-element
|
||||
tagged shapes and test all four routes:
|
||||
|
||||
```python
|
||||
case (
|
||||
('tipc', int() as stype, int() as inst, int() as scope)
|
||||
|
|
||||
['tipc', int() as stype, int() as inst, int() as scope]
|
||||
):
|
||||
...
|
||||
```
|
||||
|
||||
Also test the scope-defaulted three-element form through
|
||||
`TIPCAddress.from_addr()`, and tuple/list forms through the global
|
||||
`wrap_address()`. A normal two-element TCP/UDS address whose first
|
||||
element happens to be `'tipc'` must retain its classic dispatch.
|
||||
|
||||
⚠️ an earlier revision of this plan proposed a self-tagging
|
||||
`('tipc:<stype>:<scope>', instance)` string-prefix hack with an
|
||||
|
|
@ -150,25 +177,28 @@ treatment (`_uds.py:242`).
|
|||
- `_instance` for `get_random()`: TIPC gives us no
|
||||
kernel-assigned-instance analogue of `port=0`, so we must
|
||||
choose. Use a *pure* fn of the actor identity so it is
|
||||
reproducible and collision-free:
|
||||
reproducible and well-distributed, **not collision-free**:
|
||||
```python
|
||||
# 32-bit instance derived from the actor's uuid4 (+ pid when
|
||||
# there's no live runtime, per the UDS precedent).
|
||||
# 32-bit instance derived from the actor's Aid.uid, or from a
|
||||
# per-call token + pid when there is no live runtime.
|
||||
inst: int = int.from_bytes(
|
||||
blake2b(seed.encode(), digest_size=4).digest(),
|
||||
'big',
|
||||
)
|
||||
```
|
||||
where `seed = f'{actor.aid.name}@{pid}'` if
|
||||
where `seed = '.'.join(actor.aid.uid)` if
|
||||
`current_actor(err_on_no_runtime=False)` else
|
||||
`f'{prefix}.{uuid4().hex[:8]}@{pid}'`. Must avoid the reserved
|
||||
low range: `inst = 64 + (inst % (2**32 - 64))`.
|
||||
The UUID is load-bearing because TIPC names are cluster-wide
|
||||
while PIDs are only host-local: `(actor name, pid)` can alias on
|
||||
different hosts.
|
||||
⚠️ *unlike* `port=0`, a collision here surfaces as a
|
||||
successful-but-shared publication (TIPC allows multiple
|
||||
binders on the same name and round-robins!) rather than
|
||||
`EADDRINUSE`. That is a silent-crosstalk failure mode; §7 has
|
||||
the test that proves the 4-byte digest is enough and §9 has
|
||||
the mitigation if it isn't.
|
||||
a statistical test and §9 records the unresolved recovery work
|
||||
in [#501].
|
||||
- `_scope`: `TIPC_NODE_SCOPE` for a same-host-only actor (the
|
||||
UDS-equivalent), `TIPC_CLUSTER_SCOPE` (default) for
|
||||
cluster-visible. **This is `.bindspace`**:
|
||||
|
|
@ -189,7 +219,9 @@ treatment (`_uds.py:242`).
|
|||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return (
|
||||
self._instance != 0
|
||||
self._instance > 0
|
||||
and
|
||||
self._stype > 0
|
||||
and
|
||||
self._stype not in _tipc_reserved_stypes # {0, 1, ...}
|
||||
and
|
||||
|
|
@ -236,10 +268,11 @@ Notes / hazards:
|
|||
- **no `close_listener()` needed** — nothing to unlink. Omit the
|
||||
function entirely (contract §1.2: absence means implicit).
|
||||
Withdrawal of the published name happens on socket close.
|
||||
- ⚠️ `SocketListener.__init__` will try
|
||||
`getsockopt(SOL_SOCKET, SO_ACCEPTCONN)`. If TIPC rejects it,
|
||||
trio's `except OSError: pass` covers us. Assert this in a
|
||||
unit test rather than assuming.
|
||||
- `SocketListener.__init__` calls
|
||||
`getsockopt(SOL_SOCKET, SO_ACCEPTCONN)`. The live-kernel probe
|
||||
used by #493 answers `1`; retain the unit test so a kernel-side
|
||||
change is visible rather than relying on trio's suppressed-
|
||||
`OSError` carve-out.
|
||||
- Wrap the bind in a `_reraise_as_connerr()`-style `@cm` (copy
|
||||
the `_uds.py:256` pattern) so `EADDRINUSE`-ish and
|
||||
`EAFNOSUPPORT` become `ConnectionError` with the addr in the
|
||||
|
|
@ -258,33 +291,31 @@ returns a `TIPC_ADDR_ID`-flavoured 5-tuple (the port id), *not*
|
|||
the name-seq we bound. So the `!=` is **always true** and
|
||||
`from_addr()` will be handed a 5-tuple.
|
||||
|
||||
Handle it inside `TIPCAddress.from_addr()` — do **not** patch
|
||||
`_server.py`:
|
||||
`TIPCAddress.from_addr()` must accept only proto-keyed service
|
||||
names. It must reject a bare port ID because no conversion can
|
||||
recover `(stype, instance)`:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def from_addr(cls, addr) -> TIPCAddress:
|
||||
match addr:
|
||||
# our own unwrapped form
|
||||
case (str() as tag, int() as inst) if tag.startswith('tipc:'):
|
||||
_, stype, scope = tag.split(':')
|
||||
return TIPCAddress(int(stype), inst, int(scope))
|
||||
# our proto-keyed tuple or decoded-list wire form
|
||||
case (
|
||||
('tipc', int() as stype, int() as inst, int() as scope)
|
||||
|
|
||||
['tipc', int() as stype, int() as inst, int() as scope]
|
||||
):
|
||||
return TIPCAddress(stype, inst, _norm_scope(scope))
|
||||
|
||||
# a kernel-observed TIPC_ADDR_ID 5-tuple: keep the
|
||||
# *service* identity we already know and only annotate
|
||||
# the observed port-id.
|
||||
# a bare kernel-observed TIPC_ADDR_ID 5-tuple has no
|
||||
# service identity to annotate.
|
||||
case (int() as atype, *rest) if atype == socket.TIPC_ADDR_ID:
|
||||
...
|
||||
raise ValueError(...)
|
||||
```
|
||||
|
||||
The `TIPC_ADDR_ID` case cannot reconstruct `(stype, instance)`
|
||||
— that info isn't in a port id. So `from_addr()` alone is
|
||||
insufficient for the reconciliation path. **Resolution**: make
|
||||
`from_addr()` raise a clear `ValueError` for the bare
|
||||
`TIPC_ADDR_ID` case, and instead prevent the reconciliation
|
||||
from firing by having `start_listener()` return a listener
|
||||
whose `getsockname()` we never need — i.e. land this two-line
|
||||
upstream fix in `_server.py:664`:
|
||||
The `TIPC_ADDR_ID` case cannot reconstruct `(stype, instance)`.
|
||||
The resolution is the explicit listener-rebind policy added ahead
|
||||
of the backend in #493:
|
||||
|
||||
```python
|
||||
if (
|
||||
|
|
@ -300,12 +331,13 @@ behaviour exactly). Rationale: the reconciliation exists *only*
|
|||
to learn the kernel-assigned port for `port=0` TCP binds (its
|
||||
own comment says so, `_server.py:662`); TIPC has no such
|
||||
late-binding, so opting out is semantically right rather than a
|
||||
hack. **Land this as its own commit, ahead of the backend**,
|
||||
with a test that `tcp`'s `port=0` behaviour is unchanged.
|
||||
hack. Keep the guard test that TCP's `port=0` behaviour is
|
||||
unchanged.
|
||||
|
||||
Keep the observed port-id available anyway: annotate
|
||||
`ep.addr = ep.addr.with_port_id(*getsockname()[1:3])` (a pure
|
||||
`msgspec.structs.replace()` helper) purely for logging/repr.
|
||||
Do **not** annotate `Endpoint.addr` from `getsockname()`: the
|
||||
listener endpoint must remain the dialable service name. Port IDs
|
||||
are observed only on connected streams and may annotate a copy via
|
||||
`with_port_id()` purely for logging/repr.
|
||||
|
||||
### 3.3 `MsgpackTIPCStream`
|
||||
|
||||
|
|
@ -340,8 +372,9 @@ class MsgpackTIPCStream(MsgpackTransport):
|
|||
0, # domain: 0 == "anywhere in scope"
|
||||
destaddr._scope,
|
||||
))
|
||||
stream = trio.SocketStream(sock)
|
||||
return cls(
|
||||
trio.SocketStream(sock),
|
||||
stream,
|
||||
prefix_size=prefix_size,
|
||||
codec=codec,
|
||||
)
|
||||
|
|
@ -363,11 +396,11 @@ class MsgpackTIPCStream(MsgpackTransport):
|
|||
leave at default, we have `trio` cancel scopes.
|
||||
- `TIPC_DEST_DROPPABLE = 0` on the connection so undeliverable
|
||||
msgs come back as errors rather than being silently dropped.
|
||||
- **`connect_to()` on a name with no publisher**: TIPC returns
|
||||
`ECONNREFUSED`/`EHOSTUNREACH` promptly (no SYN-timeout wait),
|
||||
which is *better* discovery-ping behaviour than TCP. Confirm
|
||||
the errno and make sure it surfaces as `ConnectionError`
|
||||
(contract §4 — the registrar ping path depends on it).
|
||||
- **`connect_to()` on a name with no publisher**: the live-kernel
|
||||
result is immediate `EHOSTUNREACH`. Python exposes that as a
|
||||
bare `OSError`, not a `ConnectionError` subtype, so
|
||||
`_reraise_as_connerr()` is load-bearing for contract §4. Keep
|
||||
the exact errno and normalization under test.
|
||||
|
||||
### 3.4 `get_stream_addrs()`
|
||||
|
||||
|
|
@ -385,29 +418,27 @@ Problem: neither end's port-id tells us the *service name*. The
|
|||
`laddr`/`raddr` are used for logging, `Channel.raddr`,
|
||||
`Server._peers` keying-adjacent repr, and `maddr`. Design:
|
||||
|
||||
- the **connecting** side knows the destaddr it dialled →
|
||||
`connect_to()` overrides `_raddr` after construction with the
|
||||
known-good `TIPCAddress`, exactly as
|
||||
`MsgpackUDSStream.connect_to()` does for the peer-pid case
|
||||
(`_uds.py:539-543`).
|
||||
- the **accepting** side does not know the peer's service name
|
||||
from the socket. Two honest options:
|
||||
- **(a) accept it: `raddr` carries only `(node, ref)`** via
|
||||
`maybe_node`/`maybe_ref`, `_stype/_instance` set to a
|
||||
sentinel `-1`, and `__repr__` renders
|
||||
`TIPCAddress[<peer-node:0x...>:<ref>]`. The `Aid` from the
|
||||
handshake already gives us the peer's logical identity, so
|
||||
nothing in the runtime actually *needs* the peer's service
|
||||
name. **Recommended.**
|
||||
- (b) piggyback the peer's own bound name in the handshake.
|
||||
Rejected for this PR: touches `Aid`/msg-spec.
|
||||
- `laddr` on the accepting side: the `Endpoint` knows its own
|
||||
`addr`; but `get_stream_addrs()` is a `@classmethod` with only
|
||||
the stream. Use `TIPC_ADDR_ID` for `laddr` too and let
|
||||
`Endpoint.peer_tpts` keying (which is by *peer* addr) still
|
||||
work. Verify nothing asserts `laddr == ep.addr` — grep for
|
||||
`.laddr` uses before committing (`_server.py`'s
|
||||
`con_status` logging, `Channel.pformat()`).
|
||||
- `get_stream_addrs()` converts both socket results into
|
||||
**observed-only** addresses: `_stype`/`_instance` use the
|
||||
`TIPC_NAME_UNKNOWN = -1` sentinel and `maybe_node`/`maybe_ref`
|
||||
carry the port ID. Such addresses are invalid for dialling.
|
||||
- the **connecting** side knows the service name it dialled, so
|
||||
`connect_to()` replaces `_raddr` after construction with that
|
||||
known `TIPCAddress` while retaining the constructor's one
|
||||
tolerant port-ID observation. Do not call `getpeername()` a
|
||||
second time: the peer can withdraw between the two calls.
|
||||
- the **accepting** side genuinely cannot recover the peer's
|
||||
service name from a port ID. Keep the observed-only `raddr`;
|
||||
the handshake's `Aid` supplies logical identity. Piggybacking a
|
||||
bound name in the handshake is outside this backend.
|
||||
- `laddr` is observed-only as well. It is used for repr/logging,
|
||||
not to replace the endpoint's known service name.
|
||||
- unlike TCP/UDS, TIPC can answer `ENOTCONN` from
|
||||
`getpeername()` after a connect-then-drop. This lookup happens
|
||||
during `MsgpackTransport` construction, before handshake error
|
||||
tolerance. Wrap `getsockname()` and `getpeername()` in a
|
||||
tolerant helper and degrade to a port-ID-less observed address;
|
||||
a dropped peer must cost an observation, not kill the actor.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -444,43 +475,47 @@ the maddr stays 2-segment like `/unix/...`.
|
|||
|
||||
---
|
||||
|
||||
## 5. Discovery: the actually-interesting part
|
||||
## 5. Discovery primitives and explicit limits
|
||||
|
||||
Two independently-shippable layers. **Layer A is in scope for
|
||||
the first PR; layer B is a fast-follow.**
|
||||
The backend provides independently-shippable kernel primitives.
|
||||
Neither primitive alone implements Tractor's actor-name discovery,
|
||||
registry ownership, or registrar election.
|
||||
|
||||
### 5.1 Layer A — "discovery by bind" (free)
|
||||
|
||||
Because `bind(TIPC_ADDR_NAMESEQ)` publishes and
|
||||
`connect(TIPC_ADDR_NAME)` resolves, a `tractor` tree whose
|
||||
`registry_addrs` are TIPC service names needs **no registrar
|
||||
liveness at all** for the connect path: `find_actor()`'s
|
||||
"connect to the registrar and ask" becomes "connect to the
|
||||
service name directly". Concretely:
|
||||
`connect(TIPC_ADDR_NAME)` resolves, a caller that **already knows**
|
||||
a TIPC service address can dial it without a registrar lookup.
|
||||
This is narrower than registrar-less `find_actor(name)`:
|
||||
|
||||
- `tractor.discovery._api.find_actor()` etc. keep working
|
||||
unchanged (they go through the registrar), *and*
|
||||
- a new, TIPC-only fast path becomes possible: derive an actor's
|
||||
service name from its `(name, uuid)` and dial it without any
|
||||
registrar hop.
|
||||
- `tractor.discovery._api.find_actor()` and peers still query a
|
||||
registrar; #493 does not change them.
|
||||
- deriving a stable service address from `(name, uuid)` and
|
||||
dialling it directly is follow-up [#499]. The mapping must be
|
||||
documented and cross-language stable.
|
||||
- `registry_addrs` still identify registrars. Connecting to a
|
||||
known registrar by TIPC name removes no registrar bookkeeping
|
||||
or ownership semantics.
|
||||
|
||||
Do **not** build the fast path in PR 1. Instead, prove the
|
||||
property with a test (§7.4) and file the follow-up: it changes
|
||||
`discovery` semantics (name→instance derivation must be a
|
||||
documented, stable, cross-language-able hash) and deserves its
|
||||
own design.
|
||||
There is also an unresolved **split-brain election** problem.
|
||||
Duplicate TIPC name publication succeeds and round-robins, so two
|
||||
roots can both probe an unoccupied registrar name, both bind it,
|
||||
and both believe they won. The backend provides no atomic
|
||||
compare-and-publish, lease, quorum, or deterministic winner. A
|
||||
topology subscription can reveal multiple publisher port IDs but
|
||||
does not elect or fence one. Do not describe registrar election as
|
||||
solved until a separate protocol closes this race.
|
||||
|
||||
### 5.2 Layer B — the topology service (`TIPC_TOP_SRV`)
|
||||
|
||||
This is what makes #378's "end game cluster proto" claim real:
|
||||
a *subscription* to name-table events, i.e. push-based
|
||||
`register`/`deregister` for free, replacing the registrar's
|
||||
polled `find_actor()`.
|
||||
This is the push primitive behind #378's "end game cluster proto"
|
||||
direction: a subscription to kernel name-table publish/withdraw
|
||||
events. #493 implements `open_topology_events()`; consuming that
|
||||
feed in `discovery._registry` is follow-up [#496]. Until then it
|
||||
does not replace registrar state or `find_actor()`.
|
||||
|
||||
Mechanics (verify each field against
|
||||
`linux/include/uapi/linux/tipc.h` + `net/tipc/topsrv.c` at
|
||||
implementation time — the struct layout below is from the uapi
|
||||
header and the byte-order caveat is real):
|
||||
Mechanics, verified against `linux/include/uapi/linux/tipc.h`,
|
||||
`net/tipc/topsrv.c` and #493's live-kernel probe:
|
||||
|
||||
```python
|
||||
# SOCK_SEQPACKET connected to the topology server
|
||||
|
|
@ -498,22 +533,25 @@ await sock.connect((
|
|||
# __u32 filter; /* TIPC_SUB_{PORTS,SERVICE,CANCEL} */
|
||||
# char usr_handle[8];
|
||||
# } /* == 28 bytes */
|
||||
_SUBSCR_FMT: str = '=IIIII8s' # ⚠ 5*I is 20 -> use '=5I8s'
|
||||
_SUBSCR_FMT: str = '=5I8s'
|
||||
```
|
||||
|
||||
- **byte order**: the topology server historically accepts both
|
||||
host and swapped order and auto-detects; modern kernels are
|
||||
strict-ish. Pack native (`'='`) first, and if the server
|
||||
closes the connection immediately, retry with `'>'`. Encode
|
||||
that as a one-time probe helper
|
||||
`_detect_topsrv_endianness()` cached at module level — and
|
||||
put a `# ?TODO` pointing at `net/tipc/topsrv.c` for someone
|
||||
to make it deterministic.
|
||||
- **byte order**: #493's live-kernel probe verified native
|
||||
standard-size (`'='`) packing for publish and withdraw events.
|
||||
Use `'=5I8s'` for the 28-byte subscription. Do not retain the
|
||||
speculative `'>'` retry/probe as if it were required. Preserve
|
||||
the earlier `# ?TODO` to verify the deterministic rule directly
|
||||
against `net/tipc/topsrv.c`; it is source-audit work, not a
|
||||
runtime retry requirement.
|
||||
- **events**: `struct tipc_event` is `event: u32`,
|
||||
`found_lower: u32`, `found_upper: u32`,
|
||||
`port: {ref: u32, node: u32}`, then the 28-byte subscription
|
||||
echo → 40 bytes. `event ∈ {TIPC_PUBLISHED, TIPC_WITHDRAWN,
|
||||
TIPC_SUBSCR_TIMEOUT}`.
|
||||
echo: **48 bytes** (`4 + 4 + 4 + 8 + 28`), not 40. Use
|
||||
`'=10I8s'` and assert `struct.calcsize(...) == 48`.
|
||||
`event ∈ {TIPC_PUBLISHED, TIPC_WITHDRAWN,
|
||||
TIPC_SUBSCR_TIMEOUT}`. Python exposes `TIPC_WAIT_FOREVER` as
|
||||
`-1`, so mask it with `& 0xFFFF_FFFF` before packing an
|
||||
unsigned `I`.
|
||||
- **trio shape** — this is where the "nearly-functional,
|
||||
modern-async" style pays off; expose it as an `@acm` yielding
|
||||
a `trio` receive-channel of typed events, *not* a class:
|
||||
|
|
@ -538,14 +576,22 @@ async def open_topology_events(
|
|||
`kind: Literal['published','withdrawn','timeout']`,
|
||||
`addr: TIPCAddress`, `node: int`, `ref: int`. One
|
||||
`trio.lowlevel`-free implementation: a nursery-spawned reader
|
||||
task doing `await sock.recv(40)` in a loop and
|
||||
`send_nowait()`ing decoded events, with the `@acm` closing the
|
||||
socket on exit → reader gets `ClosedResourceError` → cancel
|
||||
scope collapses. Standard `tractor` `@acm` discipline.
|
||||
- **consumer**: `tractor/discovery/_registry.py` gains an
|
||||
optional "watch" mode so a registrar (or any actor) can keep
|
||||
a live view of the actor set without polling. Sketch the
|
||||
integration in the follow-up issue; do not wire it in PR 1.
|
||||
task doing `await sock.recv(48)` in a loop. The feed is
|
||||
authoritative and may neither block the socket reader nor drop
|
||||
transitions silently. Use `send_nowait()` and, on
|
||||
`trio.WouldBlock`, raise a dedicated
|
||||
`TIPCNameEventOverflow` that aborts the subscription and tells
|
||||
the consumer to resubscribe and rebuild its view. A timeout
|
||||
event is delivered once and then closes the channel. The
|
||||
`@acm` cancels its reader before closing the fd so teardown
|
||||
cannot race a retried `recv()` into `EBADF`.
|
||||
- **scope**: topology events carry no publication scope. Use an
|
||||
explicit unknown-scope sentinel and keep the resulting address
|
||||
non-dialable; never copy caller/subscription context into
|
||||
supposedly observed data.
|
||||
- **consumer**: [#496] owns the optional watch mode and the
|
||||
decision whether the feed subsumes or merely accelerates
|
||||
existing registrar bookkeeping.
|
||||
- **`SOCK_SEQPACKET` is fine here** because this socket never
|
||||
goes through `MsgpackTransport` — it's a plain trio socket
|
||||
used with `recv()`. The contract's "`SOCK_STREAM` only"
|
||||
|
|
@ -561,14 +607,16 @@ async def open_topology_events(
|
|||
2. `tractor/ipc/_tipc.py`: `TIPCAddress` + `is_tipc_available()`
|
||||
predicate + `start_listener()`. No transport yet.
|
||||
Tests: address round-trip (`unwrap`/`from_addr`/`wrap_address`),
|
||||
`get_random()` uniqueness, bind/listen + `SO_ACCEPTCONN`
|
||||
`get_random()` distribution, bind/listen + `SO_ACCEPTCONN`
|
||||
tolerance, `EAFNOSUPPORT` → actionable `ConnectionError`.
|
||||
3. `MsgpackTIPCStream` + `connect_to()` + `get_stream_addrs()`.
|
||||
Test: two `trio` tasks in one proc exchange a msg over
|
||||
`Msgpack` framing (no `tractor` runtime).
|
||||
4. registration tables (contract §2 items 1-6, 9) +
|
||||
4. registration tables (contract §2 items 1-8 and 10) +
|
||||
`pyproject.toml` mark/extra. Test: full suite under
|
||||
`--tpt-proto tipc` (§7.3).
|
||||
`--tpt-proto tipc` (§7.3). Keep TIPC in the conservative remote
|
||||
preference tier until a follow-up implements and tests contract
|
||||
item 9's node-scope locality policy.
|
||||
5. maddr support (`str` form + prefix special-case) + docs.
|
||||
6. `open_topology_events()` @acm + its tests (layer B).
|
||||
7. docs page + `docs/` example.
|
||||
|
|
@ -589,6 +637,8 @@ def is_tipc_available() -> bool:
|
|||
the `tipc` module is loaded.
|
||||
|
||||
'''
|
||||
if sys.platform != 'linux':
|
||||
return False
|
||||
try:
|
||||
socket.socket(socket.AF_TIPC, socket.SOCK_STREAM).close()
|
||||
return True
|
||||
|
|
@ -596,17 +646,22 @@ def is_tipc_available() -> bool:
|
|||
return False
|
||||
```
|
||||
|
||||
Cache it in a module global (it can't change without a
|
||||
`modprobe`, and a cold call costs a syscall). Pure predicate, no
|
||||
side effects, no logging.
|
||||
Do not permanently memoize the result: `modprobe tipc` and module
|
||||
removal can change it during a long-lived process. Probe once per
|
||||
runtime startup, or use an explicitly refreshable cache whose
|
||||
owner invalidates it after module-management operations. The
|
||||
predicate itself remains side-effect-free and silent.
|
||||
|
||||
### 7.2 gating
|
||||
|
||||
- `pytest.mark.tipc` registered in `pyproject.toml`.
|
||||
- module-level
|
||||
`pytestmark = pytest.mark.skipif(not is_tipc_available(),
|
||||
reason='`tipc` kernel module not loaded (`modprobe tipc`)')`
|
||||
in `tests/ipc/test_tipc.py`.
|
||||
- `pytest.mark.tipc` registered in
|
||||
`_testing/pytest.py::pytest_configure()` via
|
||||
`config.addinivalue_line()`, where this repo declares its other
|
||||
custom marks. Do not invent a `pyproject.toml` marker table.
|
||||
- keep pure address, serialization, and topology-codec tests
|
||||
runnable on every host. Apply a shared `requires_tipc` marker
|
||||
only to tests that create sockets or otherwise touch the kernel;
|
||||
do not module-skip `tests/ipc/test_tipc.py`.
|
||||
- `--tpt-proto tipc` with no module must fail **loudly and
|
||||
early** with the actionable message, not with 400 confusing
|
||||
timeouts. Add the check to the `tpt_protos` fixture's existing
|
||||
|
|
@ -622,23 +677,29 @@ side effects, no logging.
|
|||
`sudo modprobe tipc` in a `before` step. GH's
|
||||
`ubuntu-latest` runners do allow `modprobe tipc` (the module
|
||||
ships with the standard Ubuntu kernel package); verify in a
|
||||
throwaway workflow before wiring the matrix. If it turns out
|
||||
to be unavailable, fall back to a container job with
|
||||
`--privileged`/`--cap-add NET_ADMIN`, and mark the job
|
||||
`continue-on-error` until it's proven stable.
|
||||
throwaway workflow before wiring the matrix. #493's TIPC leg
|
||||
is now blocking. If runners cease permitting the module load,
|
||||
fix the environment or use a suitable container rather than
|
||||
silently restoring `continue-on-error`.
|
||||
- cross-node TIPC (bearer) cannot be CI'd; cover it with a
|
||||
documented manual smoke test in the docs page, in the style
|
||||
of gh #482's LAN examples.
|
||||
|
||||
### 7.4 backend-specific tests worth writing
|
||||
|
||||
- **name-publication is discovery**: bind a listener on
|
||||
- **known-name publication/resolution**: bind a listener on
|
||||
`(stype, inst)`, then from a second task `connect()` by name
|
||||
and assert it lands — *without* any `tractor` registrar.
|
||||
- **`get_random()` collision resistance**: 10k `get_random()`
|
||||
calls with no live runtime → 10k distinct `_instance`s.
|
||||
(This is the silent-crosstalk risk from §2.3; if the 4-byte
|
||||
digest ever collides in this test, escalate to §9.)
|
||||
- **`get_random()` distribution**: 10k `get_random()` calls with
|
||||
no live runtime. Do **not** assert 10k distinct values: the
|
||||
no-runtime seeds and outputs are both only 32 bits. Including
|
||||
duplicate seeds plus distinct-seed hash collisions puts the
|
||||
modeled chance of at least one duplicate near 2.3% for 10k
|
||||
calls. #493 uses `>= n - 2` (modeled probability of more than
|
||||
two collisions around `2e-6`) and separately proves
|
||||
`instance_from_seed()` is a pure function. Also hold actor
|
||||
name/PID fixed while varying only `Aid.uuid` to prove live
|
||||
actors seed from `Aid.uid`.
|
||||
- **round-robin surprise**: two listeners bound to the *same*
|
||||
`(stype, inst)` both succeed (TIPC allows it) and connects
|
||||
distribute. Assert the observed behaviour and reference it
|
||||
|
|
@ -678,14 +739,61 @@ single best demo this backend has; lead with it.
|
|||
|
||||
## 9. Known risks + escalations
|
||||
|
||||
| risk | mitigation |
|
||||
| --- | --- |
|
||||
| `_instance` hash collision → silent crosstalk (two actors share a service name, TIPC round-robins connects between them) | §7.4 test; if it bites, add a post-bind verification handshake, or bump to a 6-byte digest folded into `(stype_low, instance)` |
|
||||
| kernel/module unavailability everywhere (dev boxes, macOS, CI) | hard gating (§7.2); TIPC is explicitly an *opt-in cluster* transport, never a default |
|
||||
| `getsockname()` returns port-id not name | the `rebind_from_sockname` opt-out (§3.2), landed first |
|
||||
| unregistered `/tipc` multiaddr proto | `str` maddr fallback (§4) + upstream track gh #483 |
|
||||
| stale docs (#378 notes tipc.io docs may be out of date) | treat `include/uapi/linux/tipc.h` + `net/tipc/` as the only normative source; cite file+symbol in code comments |
|
||||
| `SOCK_SEQPACKET` topology framing byte-order | probe helper + `?TODO` (§5.2) |
|
||||
- **Instance collision / silent crosstalk remains unresolved.**
|
||||
`Aid.uid` seeding and §7.4 tests reduce and measure risk, but
|
||||
the instance field is still a hard 32 bits. [#501] owns
|
||||
post-bind verification and recovery. Do not fold bits into
|
||||
`_stype`: topology can watch only one service type.
|
||||
- **Concurrent registrar startup can split brain.** Topology can
|
||||
observe duplicate publisher port IDs but cannot elect or fence
|
||||
a winner; a separate election protocol is required (§5.1).
|
||||
- **Kernel/module availability is opt-in.** Keep the hard gate in
|
||||
§7.2; TIPC is never the default transport.
|
||||
- **A listener sockname is a port ID, not its service name.** Keep
|
||||
the `rebind_from_sockname` opt-out (§3.2).
|
||||
- **`/tipc` is not yet a registered multiaddr protocol.** Keep
|
||||
the interim `str` maddr fallback (§4) and upstream gh #483.
|
||||
- **The public TIPC docs can be stale.** Treat
|
||||
`include/uapi/linux/tipc.h` and `net/tipc/` as normative and
|
||||
cite file/symbol names in code comments.
|
||||
- **A slow topology consumer loses continuity.** Fail fast with
|
||||
`TIPCNameEventOverflow`; resubscribe and rebuild rather than
|
||||
block the reader or retain stale state (§5.2).
|
||||
- **TIPC locality preference is not implemented.** Current
|
||||
`_is_local_addr()` handles only UDS and TCP, so node- and
|
||||
cluster-scope TIPC both remain in the conservative remote tier.
|
||||
Add explicit scope-aware policy and multihomed selection tests
|
||||
before claiming node-scope preference (contract §2.9).
|
||||
|
||||
### 9.1 remaining constructor/error cleanup
|
||||
|
||||
#493 closes the peer-withdrawal race in transport construction,
|
||||
but it is not a blanket error-path cleanup. Keep these gaps
|
||||
explicit rather than reporting the backend as fully hardened:
|
||||
|
||||
- direct `TIPCAddress(...)` construction bypasses
|
||||
`from_addr()` scope normalization; `is_valid` is queried later
|
||||
rather than enforcing validity at construction. Decide whether
|
||||
constructors should reject bad service types/instances/scopes
|
||||
or document direct construction as trusted-internal.
|
||||
- `maybe_node`/`maybe_ref` are excluded from `.unwrap()` but, as
|
||||
`msgspec.Struct` fields, still participate in structural
|
||||
equality/hash. If service-name identity must ignore observation
|
||||
metadata, represent or compare it explicitly instead of relying
|
||||
on the current "observed-only" description.
|
||||
- `start_listener()` must keep ownership of the raw socket through
|
||||
`bind()`, `listen()` and `SocketListener(...)`. The downstream
|
||||
implementation normalizes bind errors but does not yet wrap the
|
||||
complete listener-construction sequence in close-on-error, so a
|
||||
later setup failure can leak the fd.
|
||||
- `_maybe_sockaddr()` currently degrades every `OSError` to an
|
||||
unknown observed address. Narrow that tolerance to expected
|
||||
peer-withdrawal errors (notably `ENOTCONN`) so unrelated bad-fd
|
||||
or programming failures remain visible.
|
||||
- error normalization is intentionally required for an
|
||||
unpublished-name `EHOSTUNREACH`, but setup `setsockopt`,
|
||||
listener-constructor, and topology setup failures still need a
|
||||
consistent policy and focused regression tests.
|
||||
|
||||
## 10. Follow-up issue seeds
|
||||
|
||||
|
|
@ -695,9 +803,11 @@ single best demo this backend has; lead with it.
|
|||
`py-multiaddr`, then drop our `str`-maddr fallback (§4). Worth
|
||||
filing *alongside* the `wg` spec-submission issue so both
|
||||
proposals go up together rather than as one-offs.
|
||||
- registrar-less discovery fast path via name derivation (§5.1)
|
||||
- registrar-less discovery fast path via name derivation ([#499],
|
||||
§5.1)
|
||||
- `TIPC_TOP_SRV`-driven push registry in
|
||||
`discovery/_registry.py` (§5.2)
|
||||
`discovery/_registry.py` ([#496], §5.2)
|
||||
- post-bind collision verification and recovery ([#501], §9)
|
||||
- `TIPC_IMPORTANCE` for the parent<->child lifetime channel
|
||||
(§3.3) — genuinely novel supervision QoS, no other backend
|
||||
can do it
|
||||
|
|
@ -705,3 +815,7 @@ single best demo this backend has; lead with it.
|
|||
for `tractor.trionics` fan-out (explicitly not `MsgTransport`)
|
||||
- dual-link resiliency / multi-homing (#378's "hybrid dual link")
|
||||
once bearers are scripted in the docs
|
||||
|
||||
[#496]: https://github.com/goodboy/tractor/issues/496
|
||||
[#499]: https://github.com/goodboy/tractor/issues/499
|
||||
[#501]: https://github.com/goodboy/tractor/issues/501
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@
|
|||
Tracks gh [#353]. Prereq reading:
|
||||
[`00_shared_backend_contract.md`](./00_shared_backend_contract.md).
|
||||
|
||||
**External-fact rule**: every claim here about `iroh`, UniFFI,
|
||||
generated bindings, QUIC wire/security behavior, or multiaddr
|
||||
support is provisional until the step-0 API-truth pass records a
|
||||
source or probe. Tractor/Trio behavior read from this checkout is
|
||||
the only locally proven basis for the plan.
|
||||
|
||||
**Thesis**: the value of `iroh` over "just QUIC" is
|
||||
`NodeId`-addressed, NAT-traversing, relay-fallback endpoints —
|
||||
i.e. a `tractor` actor tree that spans hosts *without* a
|
||||
|
|
@ -49,20 +55,22 @@ relitigate:
|
|||
**But**: build it first as the throwaway spike (§6 step 0) to
|
||||
de-risk the iroh API surface before writing the bridge.
|
||||
|
||||
Version pinning: `iroh` moves fast and has had breaking
|
||||
API renames across minors. Pin `iroh>=X.Y,<X.Y+1` in a `quic`
|
||||
extra, and **write down the exact resolved version + the
|
||||
generated `iroh/_uniffi*` module layout** in the module
|
||||
docstring, because §2 depends on generated-code internals.
|
||||
Version pinning: treat API stability across `iroh` minors as an
|
||||
**unverified external constraint** until step 0. Pin the version
|
||||
exercised by the spike to `iroh>=X.Y,<X.Y+1` in a `quic` extra,
|
||||
and **write down the exact resolved version + generated
|
||||
`iroh/_uniffi*` module layout** in the module docstring, because
|
||||
§2 depends on generated-code internals.
|
||||
|
||||
**Step 0 of implementation is an API-truth pass**: install the
|
||||
pinned `iroh`, `python -c "import iroh; help(iroh)"`, and record
|
||||
in this doc's §1.1 the real names of: endpoint builder, secret
|
||||
key type, `connect`/`accept`, bi-stream open/accept, the
|
||||
send/recv methods and their exact signatures/return types, and
|
||||
whether they're `async def`. Everything below uses *provisional*
|
||||
names and must be reconciled. Do not skip this; do not guess
|
||||
from memory.
|
||||
pinned `iroh`, inspect both its generated Python and loaded FFI
|
||||
symbols, and run the throwaway two-process spike. Record in
|
||||
§1.1 the real names and observed contracts. Every statement
|
||||
below about `iroh`, UniFFI, Rust callbacks, or generated symbols
|
||||
is a **step-0 hypothesis**, not a locally proven fact, unless it
|
||||
is copied into the completed API-truth table with a source or
|
||||
probe. Tractor and Trio behavior cited from this checkout is not
|
||||
subject to that qualifier.
|
||||
|
||||
### 1.1 API-truth table (fill in during step 0)
|
||||
|
||||
|
|
@ -79,122 +87,120 @@ from memory.
|
|||
| send | `await send_stream.write_all(b)` | |
|
||||
| recv | `await recv_stream.read(n) -> bytes\|None` | |
|
||||
| half-close | `await send_stream.finish()` | |
|
||||
| endpoint close + completion | `close()` / `await closed()` | |
|
||||
| resolved node address | relay URL + direct socket addrs | |
|
||||
| future start/poll callback ABI | generated symbols + args | |
|
||||
| future cancel/complete/free | generated symbols + ordering | |
|
||||
| callback quiescence guarantee | after poll/complete/free? | |
|
||||
| cancellation terminal poll code | generated enum/value | |
|
||||
| iroh exception/status taxonomy | per operation | |
|
||||
|
||||
---
|
||||
|
||||
## 2. The `trio`-native uniffi future bridge (`tractor/ipc/_uniffi_trio.py`)
|
||||
|
||||
### 2.1 what uniffi actually generates
|
||||
### 2.1 Step-0 generated-ABI gate
|
||||
|
||||
`uniffi`'s async support does not use asyncio *semantically* —
|
||||
it uses asyncio only as the *executor* for a poll loop. The
|
||||
generated python for an `async fn` is, in shape:
|
||||
The expected generated shape is: start an opaque Rust future,
|
||||
poll it with a C callback, cancel through a generated cancel
|
||||
symbol, consume its terminal value/status through `complete`,
|
||||
then call `free`. The expected callback may arrive on a foreign
|
||||
Rust thread. **All of that is external and provisional.** Step 0
|
||||
must identify the exact generated driver and prove, from its
|
||||
template/source plus probes:
|
||||
|
||||
1. call `_uniffi_..._<method>(...)` → returns an opaque
|
||||
`RustFuture` handle (a `void*`/`u64`).
|
||||
2. loop: call
|
||||
`ffi_..._rust_future_poll_<T>(handle, callback, callback_data)`.
|
||||
The callback is a C-ABI fn pointer invoked **from an
|
||||
arbitrary rust thread** with a poll-result code
|
||||
(`READY`/`MAYBE_READY`).
|
||||
3. the generated glue's callback resolves an
|
||||
`asyncio.Future` via `loop.call_soon_threadsafe(...)`; the
|
||||
coroutine awaits it, then re-polls.
|
||||
4. on ready: `ffi_..._rust_future_complete_<T>(handle,
|
||||
&call_status)` → the value; then
|
||||
`ffi_..._rust_future_free_<T>(handle)`.
|
||||
1. the start, poll, cancel, complete, and free signatures for
|
||||
every return-type family used by `iroh`;
|
||||
2. poll result values and whether callbacks can be synchronous,
|
||||
concurrent, repeated, or late;
|
||||
3. which terminal state permits `complete`, when `free` is
|
||||
legal, and when no callback can still reference Python;
|
||||
4. whether generated callback-data and call-status objects must
|
||||
remain alive, and how generated lifting/errors are applied;
|
||||
5. whether one narrow generated async-driver entrypoint can be
|
||||
replaced without importing or requiring an asyncio loop.
|
||||
|
||||
**The asyncio dependency is confined to step 3.** That is the
|
||||
whole insight: the bridge is ~40 lines.
|
||||
Do not implement from a remembered UniFFI version. If cancel
|
||||
does not have a documented path to a terminal, safely freeable
|
||||
state, the native Trio bridge fails the spike gate and the first
|
||||
backend uses the infected-asyncio fallback.
|
||||
|
||||
### 2.2 the trio version
|
||||
### 2.2 Cancellation-safe ownership
|
||||
|
||||
```python
|
||||
async def await_rust_future(
|
||||
poll: Callable, # ffi_..._rust_future_poll_<T>
|
||||
complete: Callable, # ffi_..._rust_future_complete_<T>
|
||||
free: Callable, # ffi_..._rust_future_free_<T>
|
||||
handle: int,
|
||||
lift: Callable[[Any], Any],
|
||||
) -> Any:
|
||||
'''
|
||||
Drive a `uniffi` rust-future to completion on the current
|
||||
`trio` task, bridging rust-thread wakeups via
|
||||
`TrioToken.run_sync_soon()`.
|
||||
Do not let the caller task own a raw handle across an `await`.
|
||||
Introduce an actor-scoped `UniffiFutureSupervisor` running in the
|
||||
dedicated transport nursery specified in §3.2.1. That nursery
|
||||
must span parent bootstrap, the service nurseries, and final
|
||||
deregistration. For each call, its operation task owns the
|
||||
**entire** generated lifecycle:
|
||||
|
||||
'''
|
||||
token = trio.lowlevel.current_trio_token()
|
||||
while True:
|
||||
wake = trio.Event()
|
||||
# NOTE, invoked from a *rust* thread!
|
||||
def _cb(_data, poll_code):
|
||||
token.run_sync_soon(wake.set)
|
||||
|
||||
cb = _UNIFFI_FUTURE_CALLBACK(_cb) # keep a strong ref!
|
||||
poll(handle, cb, 0)
|
||||
await wake.wait()
|
||||
if <poll_code was READY>:
|
||||
break
|
||||
try:
|
||||
status = _UniffiRustCallStatus.default()
|
||||
res = complete(handle, status)
|
||||
_uniffi_check_call_status(status) # reuse generated helper
|
||||
return lift(res)
|
||||
finally:
|
||||
free(handle)
|
||||
```text
|
||||
create handle -> poll/callback loop -> complete -> lift/status
|
||||
-> free -> publish result
|
||||
^
|
||||
cancel request uses generated cancel, then follows
|
||||
the verified terminal poll/complete/free protocol
|
||||
```
|
||||
|
||||
Critical details, each a real bug if missed:
|
||||
The operation task, not the awaiting caller, creates the handle.
|
||||
Creation and insertion in the supervisor's live-operation set
|
||||
must have no cancellation checkpoint between them. The operation
|
||||
retains strong references to the C callback trampoline, callback
|
||||
data, wake state, call status, and handle until step 0 proves all
|
||||
callbacks are quiescent and `free` has returned. Use one stable
|
||||
callback per operation unless the verified ABI requires a fresh
|
||||
one per poll; in either case, retain every potentially callable
|
||||
trampoline. Capture `current_trio_token()` in the Trio owner and
|
||||
schedule the wake into Trio with `token.run_sync_soon(...)`; the
|
||||
foreign callback only stores its poll result and schedules that
|
||||
wake.
|
||||
|
||||
- **`token.run_sync_soon()` is the only trio API callable from a
|
||||
foreign thread**, and it is documented as such. Use it; do
|
||||
*not* use `trio.from_thread.run_sync` (requires a trio thread
|
||||
context) and do not touch the `Event` directly from the
|
||||
callback.
|
||||
- **the poll code must reach the trio side.** Capture it in a
|
||||
`nonlocal`/1-slot list written by the callback *before*
|
||||
`run_sync_soon`, since the callback owns the value. Handle
|
||||
`MAYBE_READY` by re-polling (the loop above does).
|
||||
- **keep the `ctypes` callback object alive** across the await —
|
||||
a GC'd `CFUNCTYPE` trampoline is a segfault. Bind it to a
|
||||
local *and* make sure the local outlives the `poll()` call
|
||||
window.
|
||||
- **cancellation.** `await wake.wait()` is a trio checkpoint, so
|
||||
a `Cancelled` can fire while rust still owns the future. On
|
||||
cancel we must still `free(handle)` — and per uniffi, the
|
||||
correct sequence is to call the generated
|
||||
`ffi_..._rust_future_cancel_<T>(handle)` then continue
|
||||
polling to completion before `free`. Wrap the whole thing so
|
||||
the cancel path does:
|
||||
`with trio.CancelScope(shield=True): cancel(handle); <drain
|
||||
poll loop>; free(handle)`. **Bounded** shield (add a
|
||||
`trio.move_on_after()` with a module-level constant) so a
|
||||
wedged rust future can't make an actor un-cancellable —
|
||||
`tractor` is SC-first and an unbounded shield here would
|
||||
violate that.
|
||||
- **`trio.lowlevel.current_trio_token()`** must be captured on
|
||||
the trio side (not in the callback).
|
||||
Caller cancellation is a request, not handle ownership transfer:
|
||||
|
||||
1. the caller sends an idempotent cancel request and waits under
|
||||
a short shield for the operation to acknowledge it;
|
||||
2. the owner invokes the generated cancel function exactly once
|
||||
and continues the **verified** poll/complete/free sequence;
|
||||
3. once caller cancellation is observed, cleanup completion never
|
||||
wins the race by returning a value. After acknowledgement the
|
||||
caller continues propagating its original Trio cancellation;
|
||||
if cleanup outlives the grace period it first abandons its
|
||||
result channel while the actor supervisor keeps ownership;
|
||||
4. actor endpoint teardown stops accepting new calls, requests
|
||||
cancellation of all live operations, and joins the supervisor
|
||||
before destroying endpoint/key state.
|
||||
|
||||
There is deliberately no `move_on_after(...): free(handle)`
|
||||
path. A timeout proves only that cleanup is slow; it does not
|
||||
prove that callbacks are quiescent or that `free` is legal. A
|
||||
wedged operation therefore remains visible in the supervisor and
|
||||
can delay graceful actor shutdown; process-level termination is
|
||||
the final escalation, not an unsafe FFI free.
|
||||
|
||||
Structured-concurrency race to test: caller cancellation may land
|
||||
after handle creation, after each poll, during callback delivery,
|
||||
after terminal readiness, during `complete`, and before result
|
||||
publication. At every checkpoint exactly one operation task owns
|
||||
the handle, exactly one `free` is possible, and the supervisor
|
||||
cannot exit while that task or a callable trampoline remains.
|
||||
|
||||
### 2.3 how to apply it to the generated bindings
|
||||
|
||||
Do **not** fork/vendor the generated `iroh` python. Instead ship
|
||||
a *narrow* re-dispatch shim:
|
||||
Do **not** fork/vendor the generated `iroh` Python. Subject to the
|
||||
step-0 gate, ship a *narrow* re-dispatch shim:
|
||||
|
||||
- write `tractor/ipc/_uniffi_trio.py` with `await_rust_future()`
|
||||
plus a `@cm patch_uniffi_for_trio()` that monkey-patches the
|
||||
generated module's single async-driver entrypoint (in current
|
||||
uniffi that's `_uniffi_rust_call_async` / `_rust_call_async`,
|
||||
one function) to the trio implementation.
|
||||
- write `tractor/ipc/_uniffi_trio.py` with the supervisor and a
|
||||
`@cm patch_uniffi_for_trio()` that patches only the generated
|
||||
async-driver entrypoint recorded in §1.1;
|
||||
- verify at import time that the expected symbol exists and
|
||||
raise a clear, actionable error naming the pinned `iroh`
|
||||
version if not. A silent fallback to asyncio would be a
|
||||
nightmare to debug.
|
||||
- **plan for this to break on `iroh`/`uniffi` upgrades.** Mitigate
|
||||
with (a) a unit test that drives one trivial `iroh` async call
|
||||
under bare `trio.run()` and asserts no event loop was ever
|
||||
created (`asyncio.get_event_loop_policy()` untouched /
|
||||
`asyncio._get_running_loop() is None`), and (b) a docstring
|
||||
pointing at the uniffi codegen template this mirrors.
|
||||
- treat every `iroh`/UniFFI upgrade as requiring the step-0 ABI
|
||||
gate again. Keep a test that drives one trivial call under bare
|
||||
`trio.run()`, asserts no asyncio loop, and injects cancellation
|
||||
at every lifecycle checkpoint. Point the module docstring at
|
||||
the exact generated template/revision mirrored by the shim.
|
||||
|
||||
If step 0 reveals the generated code is *structurally* hostile
|
||||
to this (e.g. `asyncio` imported and used at module scope for
|
||||
|
|
@ -228,18 +234,52 @@ iroh bi-stream == one `Channel`/`MsgTransport` -> 1:1
|
|||
- `layer_key: int = 4` still (QUIC is L4-ish); note in a comment
|
||||
that this backend is really 4+security+multiplex.
|
||||
|
||||
**Connection pooling** is the one place we add state the other
|
||||
backends don't have: dialing the same peer twice should reuse
|
||||
the `Connection` and open a second bi-stream. Implement as a
|
||||
module-level `dict[NodeId, Connection]` guarded by a
|
||||
`trio.Lock`... **no** — that's a per-process cache with
|
||||
lifetime/teardown hazards. Instead reuse the codebase's existing
|
||||
idiom: `tractor.trionics.maybe_open_context()` keyed on the
|
||||
node-id, which already solves exactly this (one-cached-resource-
|
||||
per-key, refcounted, teardown-on-last-exit) and whose teardown
|
||||
semantics were just hardened (gh #488). Use it; do not hand-roll
|
||||
a cache. Anything concurrency-subtle here should get the
|
||||
`conc-anal` skill run over it.
|
||||
**Connection pooling** is actor-endpoint state, never module
|
||||
state. Its key is exactly
|
||||
`(local_endpoint_identity, remote_node_id, alpn)`, where local
|
||||
endpoint identity is the local NodeId derived from the actor key.
|
||||
Remote NodeId alone would incorrectly share connections across
|
||||
local keys or protocol epochs. Build it over the codebase's
|
||||
`maybe_open_context()` idiom only after a concurrency review of
|
||||
its actual last-user teardown behavior in the implementation
|
||||
revision. Do not assume an issue reference proves the required
|
||||
ordering.
|
||||
|
||||
`acquire_connection()` returns a `ConnectionLease`, not a bare
|
||||
connection. An outgoing `QuicMsgStream` owns that entered lease
|
||||
for its whole lifetime; `connect_to()` must not exit the cached
|
||||
context immediately after `open_bi()`. Exact transfer paths:
|
||||
|
||||
- dial/acquire or `open_bi()` failure releases the lease in a
|
||||
shielded `finally` before raising;
|
||||
- successful stream construction atomically transfers the lease
|
||||
to `QuicMsgStream` before the first cancellation checkpoint;
|
||||
- `send_eof()` closes only the send half and does not release;
|
||||
- clean receive EOF closes only the receive half and does not
|
||||
release while the send half remains usable;
|
||||
- one guarded terminal-state transition releases exactly once
|
||||
when both halves have become terminal, in either order;
|
||||
- `aclose()`, reset, or terminal connection failure closes both
|
||||
halves as applicable and idempotently releases exactly once;
|
||||
- a stream queued by `QuicListener` already owns its lease; if
|
||||
never accepted, listener draining closes it and releases it.
|
||||
|
||||
After `accept()` returns, the server dispatch path owns the stream
|
||||
until a handler task starts and must close it if task start fails.
|
||||
The handler then takes ownership, with an outer `finally` that
|
||||
calls `stream.aclose()` on normal return, handshake failure, and
|
||||
cancellation. Lease release itself is an idempotent pool state
|
||||
transition; if last-user connection teardown awaits FFI, the actor
|
||||
endpoint's pool supervisor owns that await so cancellation of the
|
||||
handler cannot strand the lease.
|
||||
|
||||
For inbound connections, the connection-feeder owns a base lease
|
||||
while accepting streams and each queued/returned stream gets a
|
||||
child lease. The base lease is released only after the accept
|
||||
loop ends; the pool closes the connection after the base and all
|
||||
stream leases are gone. Reject or deterministically reconcile a
|
||||
simultaneous inbound/outbound duplicate for the same full key;
|
||||
record the chosen iroh-compatible rule during step 0.
|
||||
|
||||
### 3.2 `IrohAddress`
|
||||
|
||||
|
|
@ -248,29 +288,29 @@ class IrohAddress(
|
|||
msgspec.Struct,
|
||||
frozen=True,
|
||||
):
|
||||
_node_id: str # 32B ed25519 pubkey, hex or z32
|
||||
_alpn: str = 'tractor/0' # the bindspace!
|
||||
# optional dial hints; NOT part of identity
|
||||
maybe_relay_url: str|None = None
|
||||
maybe_direct_addrs: tuple[str, ...] = ()
|
||||
_node_id: str
|
||||
_alpn: str
|
||||
_relay_url: str|None
|
||||
_direct_addrs: tuple[str, ...]
|
||||
|
||||
proto_key: ClassVar[str] = 'iroh' # ?or 'quic'; see §3.2.1
|
||||
unwrapped_type: ClassVar[type] = tuple[str, str]
|
||||
proto_key: ClassVar[str] = 'quic'
|
||||
unwrapped_type: ClassVar[type] = tuple
|
||||
def_bindspace: ClassVar[str] = 'tractor/0'
|
||||
```
|
||||
|
||||
- **`.unwrap() -> (node_id_str, alpn_str)`** — a `(str, str)`
|
||||
tuple, which is *unambiguously distinct* from
|
||||
`TCPAddress`'s `(str, int)`. But careful:
|
||||
`wrap_address()`'s UDS case is
|
||||
`case (_, filename) if type(filename) is str` — which
|
||||
**already catches `(str, str)`**. So the iroh `case` MUST be
|
||||
ordered *before* the UDS case and guarded, e.g.
|
||||
`case (str() as nid, str() as alpn) if _is_node_id(nid):`
|
||||
with `_is_node_id()` a cheap length+alphabet check. Add a
|
||||
regression test asserting a UDS `(dir, filename)` pair still
|
||||
wraps to `UDSAddress` — this is the exact "wrong transport
|
||||
loaded" hazard `_addr.py:214` warns about.
|
||||
- **`.unwrap()` is the complete, tagged wire descriptor**:
|
||||
`('quic', node_id, alpn, relay_url, direct_addrs)`. All values
|
||||
are msgpack-native and `direct_addrs` is canonicalized to a
|
||||
tuple. `from_addr()` requires that exact tag and shape; never
|
||||
infer QUIC from a `(str, str)` pair. This depends on the shared
|
||||
contract's tagged-address migration and removes the UDS
|
||||
collision rather than ordering around it.
|
||||
- The descriptor always carries NodeId, ALPN, and both route-hint
|
||||
fields. For this discovery-free first backend, `.is_valid`
|
||||
requires a parseable NodeId, non-empty ALPN, and at least one
|
||||
relay URL or direct address. Whether NodeId-only dialing works
|
||||
through optional iroh discovery is a step-0 API check and is
|
||||
not part of the first implementation.
|
||||
- `.bindspace` → `self._alpn`. This is the honest analogue:
|
||||
the ALPN is the set of endpoints willing to talk to you, and
|
||||
two `tractor` deployments sharing an iroh network are
|
||||
|
|
@ -278,39 +318,80 @@ class IrohAddress(
|
|||
separated by directory. Include a `tractor` version/proto
|
||||
epoch in the default ALPN so incompatible runtimes can't
|
||||
handshake.
|
||||
- `.is_valid` → node-id parses, alpn non-empty.
|
||||
- **`get_root()` is the hard one.** There is no
|
||||
well-known-port analogue: an iroh node id is a *keypair*, so
|
||||
"the host's default registrar addr" requires a *persisted
|
||||
secret key*. Design:
|
||||
- the root/registrar's secret key lives at
|
||||
`get_rt_dir() / 'iroh_registrar.key'` (0600), created on
|
||||
first use.
|
||||
- `get_root()` must stay **pure and import-time-safe**
|
||||
(contract §2.3: `_default_lo_addrs` is built at import!).
|
||||
So `get_root()` *reads* the key file if present and
|
||||
otherwise returns an `IrohAddress` with
|
||||
`_node_id=''`/sentinel, and the **generation** happens in
|
||||
an explicit sibling — `ensure_registrar_key() ->
|
||||
IrohAddress` — called from the listen path. Pure getter,
|
||||
explicit setter; do not smuggle key generation into
|
||||
`get_root()`.
|
||||
- this almost certainly means `_default_lo_addrs` must become
|
||||
lazy for this backend. **Land that refactor as its own prep
|
||||
commit** (a `default_lo_addrs()` that computes per-call
|
||||
instead of the import-time dict) — it also unblocks plan
|
||||
03's netns-scoped defaults.
|
||||
- `get_random()`: generate a fresh `SecretKey` per subactor and
|
||||
return its node-id. Note this runs post-fork pre-listen
|
||||
(contract §4) and costs an ed25519 keygen (~µs, fine). The
|
||||
*secret* can't live in a frozen `Address`, so it must be
|
||||
stashed where the listen path can find it: a module-level
|
||||
`dict[node_id, SecretKey]` populated by `get_random()` and
|
||||
consumed+popped by `start_listener()`. Ugly but honest;
|
||||
document it and note the alternative (thread the key through
|
||||
`Endpoint`) as a follow-up.
|
||||
|
||||
#### 3.2.1 `proto_key`: `'iroh'` vs `'quic'`
|
||||
### 3.2.1 One actor endpoint and key
|
||||
|
||||
Add an actor-scoped `QuicActorEndpoint` resource containing the
|
||||
secret key, one bound iroh endpoint, the UniFFI supervisor, the
|
||||
connection pool, and its latest resolved `IrohAddress`. It cannot
|
||||
live in `_service_tn`: a child dials its parent before that nursery
|
||||
opens, while final deregistration may dial after it closes.
|
||||
|
||||
Add a dedicated `transport_tn` around the complete actor runtime:
|
||||
the task that opens this nursery must start the complete
|
||||
`async_main` sequence as a **child** of it and wait for that child.
|
||||
That makes `transport_tn` an ancestor of every parent-dial,
|
||||
service, and deregistration caller, satisfying
|
||||
`maybe_open_context(tn=transport_tn)` rather than asking the
|
||||
nursery-opening task to use its own child nursery. The child keeps
|
||||
the nursery around `_root_tn` and `_service_tn`, performs final
|
||||
deregistration while it remains open, then returns so the owner can
|
||||
close the transport resource and nursery. Root startup needs the
|
||||
equivalent outer owner around actor construction, service, and
|
||||
teardown. If this shape cannot be preserved, the connection pool
|
||||
must stop depending on `maybe_open_context()`'s ancestor-nursery
|
||||
contract. No path creates a second endpoint for the actor.
|
||||
|
||||
The child currently receives transport configuration only in the
|
||||
`SpawnSpec` sent over its already-open parent channel. QUIC cannot
|
||||
derive its local key, ALPN, or requested bind policy from that late
|
||||
message. Add a small msgpack/pickle-native
|
||||
`ChildTransportBootstrap` to every process-launch path. It carries
|
||||
the selected protocol and the QUIC-local key reference/generation
|
||||
policy, ALPN, relay policy, and requested bind constraints. It is
|
||||
available before `_from_parent()`; the later `SpawnSpec` repeats
|
||||
the public configuration and startup rejects any mismatch. Root
|
||||
actors derive the same bootstrap record directly from
|
||||
`open_root_actor()` inputs before address selection.
|
||||
|
||||
With that prep in place, the order is:
|
||||
|
||||
1. consume the launch-time bootstrap record, select one key
|
||||
(persisted and explicitly provisioned for a registrar, fresh
|
||||
for an ordinary actor), and construct/bind the endpoint in the
|
||||
transport owner task;
|
||||
2. await the step-0-verified address-ready API and build a valid
|
||||
descriptor from the endpoint's NodeId, ALPN, relay URL, and
|
||||
direct addresses;
|
||||
3. only then dial `_from_parent()` through this endpoint;
|
||||
4. start `QuicListener` over this endpoint's accept API;
|
||||
5. publish the resolved descriptor as `Endpoint.addr` and
|
||||
`Actor.accept_addrs` before parent/registrar registration;
|
||||
6. after service nurseries close, keep the endpoint available for
|
||||
deregistration; then close listeners and streams, drain
|
||||
connection leases and FFI operations, close/join the endpoint,
|
||||
and release key state.
|
||||
|
||||
`IrohAddress.get_random()` is therefore a descriptor lookup on
|
||||
the active actor transport resource, not key generation. Broaden
|
||||
the shared `get_random()` contract for resource-backed transports
|
||||
and make root/subactor address selection consume the bootstrap
|
||||
resource instead of calling it before that resource exists. Do not
|
||||
hide a secret in a module-level side table. Calls without an active
|
||||
resource fail clearly rather than allocating an unowned key.
|
||||
|
||||
`get_root()` never returns an empty/sentinel NodeId. Make default
|
||||
addresses lazy, and have QUIC load a provisioned public registrar
|
||||
descriptor. Registrar provisioning writes its secret separately
|
||||
with mode 0600 and writes the matching complete public descriptor
|
||||
atomically; endpoint startup verifies the derived NodeId. If no
|
||||
descriptor exists, default QUIC registrar discovery fails with an
|
||||
actionable configuration error. Automatic first-process election
|
||||
is deferred until a safe key-file locking and endpoint-binding
|
||||
protocol is proven; key generation never occurs in the listen
|
||||
path.
|
||||
|
||||
#### 3.2.2 `proto_key`: `'iroh'` vs `'quic'`
|
||||
|
||||
Use **`'quic'`** for the `proto_key`/`--tpt-proto` name and
|
||||
name the module `_quic.py`, with `iroh` as the *implementation*.
|
||||
|
|
@ -357,7 +438,7 @@ class QuicMsgStream(trio.abc.HalfCloseableStream):
|
|||
'''
|
||||
tpt_key: ClassVar[MsgTransportKey] = ('msgpack', 'quic')
|
||||
|
||||
def __init__(self, conn, send, recv) -> None: ...
|
||||
def __init__(self, conn, send, recv, lease) -> None: ...
|
||||
async def send_all(self, data: bytes) -> None: ...
|
||||
async def wait_send_all_might_not_block(self) -> None: ...
|
||||
async def receive_some(self, max_bytes: int|None = None) -> bytes: ...
|
||||
|
|
@ -379,17 +460,38 @@ already exists in `_transport.py` and must keep working):
|
|||
absent, so the `raise_on_report` branch at
|
||||
`_transport.py:290` stays quiet).
|
||||
- `send_all()` on a closed peer → `trio.BrokenResourceError`.
|
||||
- honour `trio`'s one-task-per-direction rule: guard with
|
||||
`trio._util.ConflictDetector` equivalents (or just document +
|
||||
assert), because `MsgpackTransport` already serializes sends
|
||||
with a `StrictFIFOLock` but recvs are single-task by
|
||||
construction.
|
||||
- honour Trio's one-task-per-direction rule with public,
|
||||
implementation-local guards that raise
|
||||
`trio.BusyResourceError`; do not depend on `trio._util`.
|
||||
`MsgpackTransport` already serializes sends, while receives are
|
||||
single-task by construction.
|
||||
- **buffering**: if iroh's `read()` doesn't support
|
||||
"read up to n", `receive_some()` must maintain an internal
|
||||
leftover buffer. Note `MsgpackTransport` wraps us in
|
||||
`tricycle.BufferedReceiveStream` anyway, so `receive_some()`
|
||||
just needs *some* nonzero-progress contract.
|
||||
|
||||
Centralize exception translation at every iroh/UniFFI boundary;
|
||||
no generated exception may escape into `Channel` or server code.
|
||||
Step 0 must record actual exception classes/status payloads and
|
||||
build an exhaustive operation-specific mapping:
|
||||
|
||||
| observed condition | adapter result |
|
||||
| --- | --- |
|
||||
| receive clean EOF | `b''` |
|
||||
| local stream/listener/endpoint already closed | `trio.ClosedResourceError` |
|
||||
| concurrent same-direction operation | `trio.BusyResourceError` |
|
||||
| peer reset, stopped stream, lost connection | `trio.BrokenResourceError` |
|
||||
| dial rejected or no usable route | `ConnectionRefusedError` or `ConnectionError` |
|
||||
| caller's Trio deadline/cancellation | preserve Trio cancellation semantics |
|
||||
| unexpected FFI status/panic | chained `RuntimeError` identifying operation and pinned version |
|
||||
|
||||
Preserve the original exception as `__cause__`, but sanitize
|
||||
messages so `_transport.py` sees stable Trio/Tractor categories,
|
||||
not version-specific iroh text. Endpoint accept failure becomes a
|
||||
listener `BrokenResourceError`; normal endpoint shutdown becomes
|
||||
`ClosedResourceError`. Add one test per observed step-0 status.
|
||||
|
||||
```python
|
||||
class QuicListener(trio.abc.Listener):
|
||||
'''
|
||||
|
|
@ -402,41 +504,63 @@ class QuicListener(trio.abc.Listener):
|
|||
async def aclose(self) -> None: ...
|
||||
```
|
||||
|
||||
The accept-side subtlety: `trio.abc.Listener.accept()` yields
|
||||
one stream per call, but iroh gives us *connections* which then
|
||||
yield *streams*. So `QuicListener` needs an internal
|
||||
`trio.MemoryReceiveChannel[QuicMsgStream]` fed by a background
|
||||
task-pair (one task accepting connections, one per connection
|
||||
accepting bi-streams). `trio.abc.Listener` has no nursery, so:
|
||||
make the listener **constructed by an `@acm`** that owns the
|
||||
nursery, and have `start_listener()` be that `@acm`'s driver.
|
||||
The accept-side subtlety is fan-out: one actor transport accepts
|
||||
connections and each connection accepts streams, while
|
||||
`Listener.accept()` returns one stream. Give **each** listener a
|
||||
supervisor task started with
|
||||
`await server_ep.listen_tn.start(...)`.
|
||||
That task creates and owns a cancel scope, a child nursery for the
|
||||
endpoint feeder plus per-connection feeders, a guarded stream
|
||||
queue, and a completion event.
|
||||
`start_listener(addr=, server_ep=, actor_tpt=)` does not return
|
||||
until the supervisor has reported all of those ready.
|
||||
Do not borrow an implicit parent nursery or spawn feeders lazily
|
||||
from `accept()`.
|
||||
|
||||
⚠️ this collides with `Endpoint.start_listener()` being a plain
|
||||
`async def` returning a listener. Two options:
|
||||
- **(a)** hang the nursery off the `Endpoint`'s existing
|
||||
`listen_tn` — `_serve_ipc_eps()` already creates `listen_tn`
|
||||
and passes it into every `Endpoint` (`_server.py:1063-1074`),
|
||||
and `Endpoint.listen_tn` is right there. So
|
||||
`start_listener()` can `self.listen_tn.start_soon(...)` the
|
||||
acceptor tasks. **Recommended**: no upstream signature change,
|
||||
correct lifetime (dies with the ep group), and it's why
|
||||
`listen_tn` is on the struct in the first place.
|
||||
- (b) change `start_listener()` to a `@acm`. Bigger blast
|
||||
radius; only if (a) proves insufficient.
|
||||
The queue is a guarded `deque`, not an unowned memory-channel
|
||||
buffer. A feeder transfers a fully constructed, lease-owning
|
||||
stream into it only while the listener is open; if close wins the
|
||||
race, the feeder closes the stream itself. `accept()` atomically
|
||||
pops one item or waits on the queue condition. Once close is
|
||||
marked and the queue is empty, it raises
|
||||
`trio.ClosedResourceError`.
|
||||
|
||||
Since `start_listener()` is called via
|
||||
`inspect.getmodule(addr)` with only `addr=` (contract §1.3),
|
||||
option (a) needs the `Endpoint` itself. Either add `ep=` to the
|
||||
module-level `start_listener()` call signature (all backends
|
||||
ignore it except quic → small upstream change, do it as part of
|
||||
the prep PR and make it keyword-only with a default) or have
|
||||
`QuicListener.accept()` lazily spawn via
|
||||
`trio.lowlevel.current_task().parent_nursery` (**rejected** —
|
||||
fragile, implicit). Do the explicit `ep=` kwarg.
|
||||
`QuicListener.aclose()` is idempotent and has this exact order:
|
||||
|
||||
1. under the queue guard, mark closed and wake all `accept()`
|
||||
waiters without a checkpoint between the state change and
|
||||
notification;
|
||||
2. cancel the listener-owned supervisor scope;
|
||||
3. the supervisor's shielded `finally` joins the endpoint and all
|
||||
connection feeders, atomically detaches the queue, closes every
|
||||
queued stream, releases their leases, and closes the queue;
|
||||
4. only after that finalizer finishes, the supervisor sets its
|
||||
completion event;
|
||||
5. `aclose()` waits under a shield for that event and returns;
|
||||
concurrent closers wait for the same event.
|
||||
|
||||
The same supervisor finalizer runs if its parent nursery is
|
||||
cancelled before someone calls `aclose()`. This makes the
|
||||
supervisor, not an arbitrarily cancelled caller, the sole final
|
||||
cleanup owner. Test cancellation at feeder accept, stream
|
||||
construction, queue transfer, `accept()` wakeup, and each close
|
||||
checkpoint; no feeder may outlive the listener and no queued
|
||||
lease may survive completion.
|
||||
|
||||
This needs two explicit, typed references in the module-level
|
||||
listener call: `server_ep=` is the IPC server `Endpoint` that owns
|
||||
`listen_tn`, while `actor_tpt=` is the already-open
|
||||
`QuicActorEndpoint` whose iroh accept API supplies connections.
|
||||
Store `actor_tpt` on the server endpoint during actor transport
|
||||
bootstrap and pass both keyword-only arguments; socket backends
|
||||
ignore `actor_tpt`. `Endpoint.start_listener()` then stores the
|
||||
listener's already-resolved address instead of calling
|
||||
`getsockname()`.
|
||||
|
||||
### 3.4 `maddr`
|
||||
|
||||
Multiaddr already standardizes the pieces:
|
||||
Expected multiaddr spellings for direct QUIC and relay routes are
|
||||
**step-0 verification items**, not assumptions:
|
||||
|
||||
```
|
||||
/ip4/<h>/udp/<p>/quic-v1 # direct
|
||||
|
|
@ -444,19 +568,16 @@ Multiaddr already standardizes the pieces:
|
|||
/dns/<relay-host>/tcp/443/tls/ws/p2p/<node> # relay-ish
|
||||
```
|
||||
|
||||
- primary form: `/p2p/<node-id>` alone is a legal maddr and is
|
||||
the *only* required component for iroh dialling — relay +
|
||||
direct addrs are discovery hints. So `mk_maddr()` emits
|
||||
`/p2p/<node_id>` and, when known, prefixes the direct
|
||||
`/ip4/../udp/../quic-v1/`.
|
||||
- `/p2p/` values are multihash-encoded peer ids; an iroh node-id
|
||||
is a raw ed25519 key. Converting requires the identity
|
||||
multihash + libp2p key protobuf wrapper. **Decide**: emit the
|
||||
raw node-id under a *tractor-local* `/iroh/<node-id>` segment
|
||||
(needs upstream registration, same track as `wg`/`tipc`,
|
||||
gh #483) rather than pretending to be a libp2p peer-id we
|
||||
can't round-trip. Return the `str` form until upstream lands
|
||||
(`MsgTransport.maddr` is `Multiaddr|str`).
|
||||
- Do not emit NodeId alone in the first backend: without enabled
|
||||
discovery it would discard the route required by the complete
|
||||
`IrohAddress`. `mk_maddr()` must preserve NodeId, ALPN, relay
|
||||
URL, and all direct addresses, or return a canonical Tractor
|
||||
string form that does until a multiaddr grammar can round-trip
|
||||
every field.
|
||||
- Verify whether an iroh NodeId can losslessly map to `/p2p/`.
|
||||
If not, use a tractor-local `/iroh/<node-id>` segment rather
|
||||
than pretending to be a libp2p peer-id. This needs upstream
|
||||
registration, on the same track as `wg`/`tipc` (gh #483).
|
||||
- this backend is the strongest argument for gh #443's
|
||||
**tunnelled/composed maddr** item: `/ip4/../udp/../quic-v1/..`
|
||||
*is* a composed stack. Cross-reference plan 03 §5 so the two
|
||||
|
|
@ -466,24 +587,25 @@ Multiaddr already standardizes the pieces:
|
|||
|
||||
## 4. Discovery integration
|
||||
|
||||
- iroh's node-id addressing means the `tractor` registrar can
|
||||
hold `IrohAddress`es that are **reachable from anywhere** with
|
||||
no port-forwarding — that is the headline feature. The
|
||||
registrar itself works unchanged.
|
||||
- iroh has its own discovery (DNS/pkarr/mdns). **Out of scope**;
|
||||
note in the follow-up that `tractor.discovery` could
|
||||
eventually delegate to it, which would be the direct analogue
|
||||
of plan 01's TIPC-topology idea.
|
||||
- relay servers: default to n0's public relays for the demo,
|
||||
document self-hosting (docs.iroh.computer's dedicated-infra
|
||||
page is linked from #353), and make the relay set a
|
||||
`start_listener()` kwarg.
|
||||
- The registrar stores the complete `IrohAddress`, not only a
|
||||
NodeId. Registration is forbidden until endpoint address
|
||||
resolution has produced that descriptor. If route hints change
|
||||
later, dynamic re-registration is a follow-up; the spike uses
|
||||
the pre-registration snapshot.
|
||||
- Optional iroh discovery mechanisms and their names/capabilities
|
||||
are step-0 verification items and out of scope for the first
|
||||
backend. No NodeId-only reachability claim is made.
|
||||
- Relay configuration belongs to `QuicActorEndpoint` creation,
|
||||
not `start_listener()`, because dialing and listening reuse the
|
||||
same endpoint. The demo's relay choice and self-hosted option
|
||||
are selected only after step 0 verifies the pinned API.
|
||||
|
||||
## 5. Security note
|
||||
|
||||
QUIC is TLS-1.3-always and iroh authenticates by node-id, so
|
||||
this backend is the first `tractor` transport with real
|
||||
transport security and peer authentication. Two things follow:
|
||||
The transport-security and NodeId-authentication properties of the
|
||||
pinned iroh stack are **step-0 documentation-verification items**.
|
||||
Claim only the properties supported by that version's source and
|
||||
docs. Two design consequences remain:
|
||||
1. an **allowlist hook** — an actor should be able to reject
|
||||
inbound connections from unknown node-ids *before* the
|
||||
`Aid` handshake. Natural home: a predicate kwarg on
|
||||
|
|
@ -497,59 +619,72 @@ transport security and peer authentication. Two things follow:
|
|||
|
||||
0. **spike (throwaway, not committed)**: drive iroh under
|
||||
`trio-asyncio`/`tractor.to_asyncio`, echo bytes over a
|
||||
bi-stream between two procs. Fills in §1.1. Timebox it.
|
||||
1. prep PR: annotation widening + `rebind_from_sockname` gate +
|
||||
`transport_from_stream()` `tpt_key` dispatch + `ep=` kwarg on
|
||||
`start_listener()` + lazy `default_lo_addrs()`. **No new
|
||||
backend.** Full suite green on tcp *and* uds.
|
||||
2. `_uniffi_trio.py` + its tests (drive one iroh async call
|
||||
under bare `trio.run()`; assert no asyncio loop; assert
|
||||
cancellation frees the future).
|
||||
3. `QuicMsgStream` + tests against a *loopback* iroh endpoint
|
||||
pair in one process (no `tractor` runtime): send/recv, clean
|
||||
EOF → `b''`, reset → `BrokenResourceError`, use-after-close
|
||||
→ `ClosedResourceError`.
|
||||
4. `QuicListener` + `start_listener()` + `IrohAddress` +
|
||||
key-file mgmt.
|
||||
5. `MsgpackQuicStream(MsgpackTransport)` + `connect_to()` +
|
||||
`maybe_open_context()` connection pooling.
|
||||
6. registration tables + `--tpt-proto quic` + full suite.
|
||||
7. maddr + docs + a two-host example (pairs with #482's format).
|
||||
bi-stream between two processes. Fill §1.1 with generated ABI,
|
||||
endpoint resolution, close/join, and error observations. Probe
|
||||
cancel at every generated lifecycle phase. Timebox it and use
|
||||
the fallback if any mandatory ownership fact stays unknown.
|
||||
1. prep PR: tagged address migration, annotation widening,
|
||||
non-socket listener reconciliation, `tpt_key` dispatch,
|
||||
typed `server_ep=`/`actor_tpt=` listener inputs, and lazy
|
||||
default addresses. **No new backend.** Keep tcp and uds
|
||||
behavior unchanged.
|
||||
2. bootstrap prep: pass `ChildTransportBootstrap` through every
|
||||
process-launch path and add the transport nursery around child
|
||||
parent-dial, service, deregistration, and teardown. Resolve the
|
||||
endpoint address before registration. Add no iroh-specific
|
||||
global state.
|
||||
3. `_uniffi_trio.py` supervisor + lifecycle fault-injection tests:
|
||||
no asyncio loop, one owner/complete/free, callback retention,
|
||||
bounded caller handoff, and joined durable cleanup.
|
||||
4. `QuicActorEndpoint` + provisioned registrar descriptor +
|
||||
loopback direct-address tests; prove one endpoint handles dial,
|
||||
listen, address lookup, and ordered teardown.
|
||||
5. `QuicMsgStream` + exhaustive error-normalization and lease
|
||||
release tests against the loopback endpoint pair.
|
||||
6. `QuicListener` supervisor + cancellation-at-every-checkpoint
|
||||
tests, including queued-stream draining and feeder joins.
|
||||
7. `MsgpackQuicStream`, full-key connection pooling, registration
|
||||
tables, and `--tpt-proto quic`; then run the full suite.
|
||||
8. routable maddr/string form + docs + a two-host example (pairs
|
||||
with #482's format).
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- capability predicate `is_quic_available()` → `iroh` importable
|
||||
*and* the uniffi driver symbol present at the pinned version.
|
||||
Same `pytest.fail`-early hook as plan 01 §7.2.
|
||||
*and* every step-0-recorded driver symbol present at the pinned
|
||||
version. Same `pytest.fail`-early hook as plan 01 §7.2.
|
||||
- **the acceptance bar is the same**: whole suite green under
|
||||
`--tpt-proto quic`. Expect this to shake out real bugs in the
|
||||
adapters (esp. teardown ordering and `TransportClosed`
|
||||
classification) — that's the point.
|
||||
- expect to need **timeout headroom**: iroh endpoint bind +
|
||||
first connect (relay discovery) is orders of magnitude slower
|
||||
than a UDS bind. Before touching any test deadline, rule out
|
||||
the CPU-throttle false-positive (see the project's
|
||||
`env_cpu_throttle_masquerades_as_regression` note); then, if
|
||||
real, add a per-proto timeout multiplier to the test harness
|
||||
rather than editing individual tests.
|
||||
- a no-network test mode: iroh with relays disabled +
|
||||
loopback direct addrs only, so CI doesn't depend on n0's
|
||||
infra. **Make this the default in CI**; mark the relay tests
|
||||
`pytest.mark.net` and keep them out of the default run.
|
||||
- leak checks: assert every `SecretKey`/`Endpoint` is closed on
|
||||
actor teardown (an `Endpoint` left open holds UDP sockets and
|
||||
relay connections; a leak here shows up as hung tests, not
|
||||
errors).
|
||||
- Measure endpoint bind and first-connect latency in step 0; do
|
||||
not assume a multiplier. Before changing a deadline, rule out
|
||||
the project's CPU-throttle false-positive, then prefer one
|
||||
per-proto harness multiplier over individual-test edits.
|
||||
- Use the step-0-verified relay-disable configuration with direct
|
||||
loopback addresses for default CI. Mark separately verified
|
||||
relay tests `pytest.mark.net` and keep them out of default CI.
|
||||
- leak checks: assert the actor has one key/endpoint, every FFI
|
||||
operation completed/freed once, all listener feeders joined,
|
||||
all queued streams closed, every connection lease released,
|
||||
and endpoint close completion observed before actor teardown.
|
||||
- address-ordering check: block registration until a descriptor
|
||||
with NodeId, ALPN, and at least one route is published; reject
|
||||
sentinel, NodeId-only, and post-registration mutation cases.
|
||||
|
||||
## 8. Risks
|
||||
|
||||
| risk | mitigation |
|
||||
| --- | --- |
|
||||
| uniffi codegen internals shift on upgrade | pinned minor, symbol assertion at import, the "no asyncio loop" test, documented fallback to `to_asyncio` |
|
||||
| rust-thread callback → trio wakeup mishandled (segfault / lost wakeup / un-cancellable task) | strong ref on the ctypes trampoline; `run_sync_soon` only; **bounded** shielded cancel-drain; run the `conc-anal` skill over the bridge |
|
||||
| callback wakeup/lifetime semantics differ from the hypothesis | step-0 source + probe gate; retain callback/data through verified quiescence; durable owner; never timeout-free |
|
||||
| cancelled foreign future never reaches a freeable state | bounded caller handoff to visible actor supervisor; joined graceful shutdown or process-level escalation; never speculative free |
|
||||
| `iroh` wheel availability for 3.13/3.14 on linux+macos | verify in step 0; if missing, that alone may force the `aioquic` fallback |
|
||||
| QUIC latency/jitter destabilizes the existing suite's timing assumptions | per-proto timeout multiplier, relay-less CI mode |
|
||||
| `(str, str)` unwrapped form collides with UDS in `wrap_address()` | guarded case ordered first + explicit regression test (§3.2) |
|
||||
| endpoint or route resolution is not ready before parent dial/registration | actor endpoint bootstrap barrier; publish only a complete resolved descriptor |
|
||||
| connection closes while a stream still uses it | full-key pool + stream-held leases + exact-once release tests |
|
||||
| listener close strands feeder tasks or queued streams | listener-owned scope/completion event; cancel, join, drain, then return |
|
||||
| QUIC latency/jitter destabilizes suite timing assumptions | measure first; per-proto multiplier only if demonstrated; relay-less CI mode |
|
||||
| address tuple collides with another backend | required `'quic'` tag and exact-shape dispatch |
|
||||
| scope creep into iroh's docs/blobs/gossip crates | this backend is `Endpoint`+`Connection`+bi-streams only; anything else is a separate issue |
|
||||
|
||||
## 9. Follow-up issue seeds
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ different model/provider) without design or lib-selection drift.
|
|||
**Read [`00_shared_backend_contract.md`](./00_shared_backend_contract.md)
|
||||
first** — it is the normative description of what a `tractor`
|
||||
transport backend *is* as of `main@83b34884` (the backend
|
||||
duck-type, the 10-item registration checklist, the test-harness
|
||||
plumbing, the code-style rules). The three plans assume it and
|
||||
document only their own deltas.
|
||||
duck-type, registration and address-selection wiring, the
|
||||
test-harness plumbing, the code-style rules). The three plans
|
||||
assume it and document only their own deltas.
|
||||
|
||||
| plan | issue | dep | size | lands |
|
||||
| --- | --- | --- | --- | --- |
|
||||
|
|
@ -23,10 +23,12 @@ Headline conclusions:
|
|||
`trio.SocketListener` are address-family agnostic (only
|
||||
`SOCK_STREAM` + a trio socket), and CPython ships `AF_TIPC` +
|
||||
23 `TIPC_*` constants. So the backend is ~one module of
|
||||
contract boilerplate, zero new deps, and it buys
|
||||
*kernel-native* service discovery: `bind()` publishes,
|
||||
`connect()`-by-name resolves — no registrar in the loop.
|
||||
(`modprobe tipc` is required; hard-gate everything.)
|
||||
contract boilerplate, zero new deps, and it buys kernel-native
|
||||
service primitives: `bind()` publishes, known-address
|
||||
`connect()` resolves, and topology events report publication
|
||||
changes. Actor-name lookup, registrar state and split-brain-safe
|
||||
election remain separate work. (`modprobe tipc` is required;
|
||||
hard-gate everything.)
|
||||
- **QUIC's cost is entirely in two adapters**, not in QUIC. The
|
||||
`iroh` python bindings are `uniffi`-generated asyncio, but the
|
||||
asyncio dependency is confined to *one* future-poll callback —
|
||||
|
|
|
|||
Loading…
Reference in New Issue