Compare commits
No commits in common. "e4e27ec71defdec300bee17ba55d61d2bfdd0cc4" and "768b531662a88037e3988885102d2ff24a7ce34e" have entirely different histories.
e4e27ec71d
...
768b531662
|
|
@ -3,8 +3,8 @@ model: claude-opus-5
|
||||||
service: claude
|
service: claude
|
||||||
session: 7b9c97c4-fff7-4ac4-97fb-35720453308e
|
session: 7b9c97c4-fff7-4ac4-97fb-35720453308e
|
||||||
timestamp: 2026-08-13T00:11:02Z
|
timestamp: 2026-08-13T00:11:02Z
|
||||||
git_ref: 27c34aebb615c30d4039fa399f4ce2766ed7ba2c
|
git_ref: 27c34aeb
|
||||||
scope: code
|
scope: docs+code
|
||||||
substantive: true
|
substantive: true
|
||||||
raw_file: 20260813T001102Z_27c34aeb_prompt_io.raw.md
|
raw_file: 20260813T001102Z_27c34aeb_prompt_io.raw.md
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@
|
||||||
model: claude-opus-5
|
model: claude-opus-5
|
||||||
service: claude
|
service: claude
|
||||||
timestamp: 2026-08-13T00:11:02Z
|
timestamp: 2026-08-13T00:11:02Z
|
||||||
git_ref: 27c34aebb615c30d4039fa399f4ce2766ed7ba2c
|
git_ref: 27c34aeb
|
||||||
diff_cmd: git diff 3ad7e7e5dc28aa336499725a03744fc10c6c0026..27c34aebb615c30d4039fa399f4ce2766ed7ba2c
|
diff_cmd: git diff main..ng_tpts_planning
|
||||||
---
|
---
|
||||||
|
|
||||||
# Raw output — next-gen tpt-backend implementation plans
|
# Raw output — next-gen tpt-backend implementation plans
|
||||||
|
|
||||||
## Generated planning docs
|
## Generated planning docs
|
||||||
|
|
||||||
> `git diff 3ad7e7e5dc28aa336499725a03744fc10c6c0026..27c34aebb615c30d4039fa399f4ce2766ed7ba2c -- ai/tpt-backends/`
|
> `git diff main..ng_tpts_planning -- ai/tpt-backends/`
|
||||||
|
|
||||||
Five markdown docs. `00_shared_backend_contract.md` is normative
|
Five markdown docs. `00_shared_backend_contract.md` is normative
|
||||||
and the other three are written against it so they can be worked
|
and the other three are written against it so they can be worked
|
||||||
|
|
@ -43,7 +43,7 @@ independently:
|
||||||
|
|
||||||
## Generated example code
|
## Generated example code
|
||||||
|
|
||||||
> `git diff 3ad7e7e5dc28aa336499725a03744fc10c6c0026..27c34aebb615c30d4039fa399f4ce2766ed7ba2c -- examples/multihost/wg_lan/`
|
> `git diff main..ng_tpts_planning -- examples/multihost/wg_lan/`
|
||||||
|
|
||||||
- `wg_maddr.py` — `WGTunnelledAddr(msgspec.Struct, frozen=True)`
|
- `wg_maddr.py` — `WGTunnelledAddr(msgspec.Struct, frozen=True)`
|
||||||
carrying `bearer: tuple[str, int]`, `peer_pubkey: str`,
|
carrying `bearer: tuple[str, int]`, `peer_pubkey: str`,
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
---
|
|
||||||
model: gpt-5.6-sol
|
|
||||||
service: opencode
|
|
||||||
session: f3a5eef0-9695-48ab-a336-861187473f8a
|
|
||||||
timestamp: 2026-08-31T02:23:17Z
|
|
||||||
git_ref: ng_tpts_planning
|
|
||||||
diff_cmd: git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD
|
|
||||||
scope: code
|
|
||||||
substantive: true
|
|
||||||
raw_file: 20260831T022317Z_768b5316_prompt_io.raw.md
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
The human asked to continue the retained PR landing sequence and
|
|
||||||
selected "Repair all blockers" after a read-only review of PR #492.
|
|
||||||
They then corrected the TIPC assessment:
|
|
||||||
|
|
||||||
> oh one thing on the tipc findings, they may have already been
|
|
||||||
> addressed in the downstream patch branch no?
|
|
||||||
|
|
||||||
The repair therefore had to use downstream #493 as implementation
|
|
||||||
evidence, avoid duplicating solved TIPC work, preserve the dirty
|
|
||||||
downstream worktree, and repair the remaining shared, QUIC,
|
|
||||||
WireGuard, lock, and provenance defects without committing,
|
|
||||||
pushing, or publishing.
|
|
||||||
|
|
||||||
## Response summary
|
|
||||||
|
|
||||||
Reworked PR #492's plans and executable WireGuard example into a
|
|
||||||
consistent landing candidate. The shared and TIPC documents now
|
|
||||||
match current runtime/downstream behavior while naming remaining
|
|
||||||
risks. The QUIC plan now defines transport ownership across the
|
|
||||||
whole actor lifecycle. The WireGuard example validates keys and
|
|
||||||
binds both hosts and the service actor correctly without elevating
|
|
||||||
Tractor. The dependency lock and historical provenance pointers
|
|
||||||
are synchronized.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `ai/tpt-backends/00_shared_backend_contract.md` - current backend
|
|
||||||
contract, dispatch, capability, and locality rules.
|
|
||||||
- `ai/tpt-backends/01_tipc_backend.md` - #493 reconciliation and
|
|
||||||
bounded unresolved work.
|
|
||||||
- `ai/tpt-backends/02_quic_iroh_backend.md` - lifecycle-safe QUIC
|
|
||||||
and UniFFI implementation plan.
|
|
||||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - corrected address
|
|
||||||
registry and privilege semantics.
|
|
||||||
- `ai/tpt-backends/README.md` - refreshed plan summaries.
|
|
||||||
- `examples/multihost/wg_lan/README.md` - unprivileged two-host
|
|
||||||
setup and accurate verification limits.
|
|
||||||
- `examples/multihost/wg_lan/host_a_srv.py` - local key check,
|
|
||||||
overlay child bind, and stable RPC exposure.
|
|
||||||
- `examples/multihost/wg_lan/host_b_client.py` - peer key check,
|
|
||||||
host-B bind, and explicit missing-service failure.
|
|
||||||
- `examples/multihost/wg_lan/wg_maddr.py` - strict parsing and
|
|
||||||
asynchronous role-specific key inspection.
|
|
||||||
- `uv.lock` - exact `py-multiaddr` Git source resolution.
|
|
||||||
- `ai/prompt-io/claude/20260813T001102Z_27c34aeb_prompt_io.md` -
|
|
||||||
valid scope and immutable Git reference.
|
|
||||||
- `ai/prompt-io/claude/20260813T001102Z_27c34aeb_prompt_io.raw.md`
|
|
||||||
- immutable historical diff pointers.
|
|
||||||
|
|
||||||
## Human edits
|
|
||||||
|
|
||||||
The human chose the full repair path rather than reducing the PR
|
|
||||||
to planning documents or publishing the initial review. They also
|
|
||||||
identified that the initial TIPC review had not accounted for the
|
|
||||||
downstream implementation branch. That correction materially
|
|
||||||
changed the work: solved TIPC items were backported into the plan,
|
|
||||||
remaining defects were separated from implemented behavior, and
|
|
||||||
the dirty downstream worktree was kept read-only.
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
---
|
|
||||||
model: gpt-5.6-sol
|
|
||||||
service: opencode
|
|
||||||
timestamp: 2026-08-31T02:23:17Z
|
|
||||||
git_ref: ng_tpts_planning
|
|
||||||
diff_cmd: git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD
|
|
||||||
---
|
|
||||||
|
|
||||||
# Raw output - repair PR #492 for landing
|
|
||||||
|
|
||||||
## Generated changes
|
|
||||||
|
|
||||||
> `git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD -- ai/tpt-backends/`
|
|
||||||
|
|
||||||
- Reconciled the shared backend contract with current address,
|
|
||||||
dispatch, capability, locality, and listener-rebind APIs.
|
|
||||||
- Updated the TIPC plan from downstream #493 implementation and
|
|
||||||
tests, preserving unresolved registrar election, collision,
|
|
||||||
locality, and socket-cleanup work as explicit follow-ups.
|
|
||||||
- Reworked the QUIC plan around a launch-time transport bootstrap,
|
|
||||||
one actor-owned endpoint, a transport nursery spanning parent
|
|
||||||
dial through deregistration, supervised UniFFI cleanup, complete
|
|
||||||
routable addresses, connection leases, and listener-owned tasks.
|
|
||||||
- Corrected the WireGuard bindspace plan to distinguish the
|
|
||||||
build-registered address registry from runtime capability.
|
|
||||||
|
|
||||||
> `git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD -- examples/multihost/wg_lan/`
|
|
||||||
|
|
||||||
- Hardened WireGuard key conversion and parsing with strict
|
|
||||||
32-byte validation, lazy protocol lookup, and explicit rejection
|
|
||||||
of unsupported nested tunnel descriptors.
|
|
||||||
- Made interface inspection asynchronous, bounded at its
|
|
||||||
cancellation request, role-specific, and separable from
|
|
||||||
privileged preflight commands.
|
|
||||||
- Corrected host-local binds, subactor overlay publication, stable
|
|
||||||
RPC module exposure, and missing-actor handling in the two-host
|
|
||||||
example.
|
|
||||||
- Updated the README so Tractor remains unprivileged and the local
|
|
||||||
versus remote overlay roles are explicit.
|
|
||||||
|
|
||||||
> `git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD -- pyproject.toml uv.lock ai/prompt-io/`
|
|
||||||
|
|
||||||
- Regenerated `uv.lock` for the exact merged `py-multiaddr` WireGuard
|
|
||||||
codec revision.
|
|
||||||
- Replaced mutable historical prompt diff pointers with immutable
|
|
||||||
refs and normalized the substantive scope without rewriting the
|
|
||||||
historical raw response.
|
|
||||||
|
|
||||||
## Verification output
|
|
||||||
|
|
||||||
- `git diff --check`: passed.
|
|
||||||
- `uv lock --check`: passed.
|
|
||||||
- Ruff on the WireGuard example directory: passed.
|
|
||||||
- Python compilation of the WireGuard example directory: passed.
|
|
||||||
- Tractor imported from the PR worktree's existing environment.
|
|
||||||
- WireGuard address round-trip, local/peer role checks, malformed
|
|
||||||
base64 rejection, and nested-tunnel rejection: passed.
|
|
||||||
- Three independent final re-reviews reported no actionable
|
|
||||||
findings in the shared/TIPC, QUIC, or WireGuard slices.
|
|
||||||
- No live TIPC, iroh/UniFFI, or WireGuard network test was run.
|
|
||||||
|
|
@ -33,35 +33,19 @@ doc in the same PR.
|
||||||
|
|
||||||
## 1. The backend duck-type (empirical, from `_tcp.py`/`_uds.py`)
|
## 1. The backend duck-type (empirical, from `_tcp.py`/`_uds.py`)
|
||||||
|
|
||||||
A transport backend is **one module** under `tractor/ipc/`.
|
A transport backend is **one module** under `tractor/ipc/`
|
||||||
There is no ABC to subclass and no plugin entrypoint; wiring is by
|
exposing exactly four things. There is no ABC to subclass and no
|
||||||
explicit table registration (§2) plus one piece of reflection
|
plugin entrypoint; wiring is by explicit table registration
|
||||||
(§1.3).
|
(§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)`
|
### 1.1 `class <Proto>Address(msgspec.Struct, frozen=True)`
|
||||||
|
|
||||||
The runtime-consumed address-wrapper surface is:
|
Structurally conforms to the `Address` `Protocol` in
|
||||||
|
`tractor/discovery/_addr.py:82`. Required surface:
|
||||||
|
|
||||||
| member | kind | notes |
|
| member | kind | notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `proto_key` | `ClassVar[str]` | internal transport key, e.g. `'tcp'`, `'uds'` |
|
| `proto_key` | `ClassVar[str]` | the wire/registry key, e.g. `'tcp'`, `'uds'` |
|
||||||
| `unwrapped_type` | `ClassVar[type]` | the primitive tuple shape |
|
| `unwrapped_type` | `ClassVar[type]` | the primitive tuple shape |
|
||||||
| `def_bindspace` | `ClassVar` | default bindspace value |
|
| `def_bindspace` | `ClassVar` | default bindspace value |
|
||||||
| `is_valid` | `@property -> bool` | "is this a *dialable/bindable* addr" |
|
| `is_valid` | `@property -> bool` | "is this a *dialable/bindable* addr" |
|
||||||
|
|
@ -97,32 +81,22 @@ Hard constraints learned from the existing two:
|
||||||
paper over it at best.
|
paper over it at best.
|
||||||
|
|
||||||
**The fix, and the recommended prerequisite for all three
|
**The fix, and the recommended prerequisite for all three
|
||||||
backends: make the unwrapped form carry an explicit internal
|
backends: make the unwrapped form carry an explicit
|
||||||
proto-key** — `('tcp', host, port)`,
|
proto-key, using the `multiaddr` protocol name as the
|
||||||
`('uds', filedir, filename)`, `('tipc', stype, inst, scope)`.
|
canonical spelling** — `('tcp', host, port)`,
|
||||||
The tag must be a `TransportProtocolKey`/registry key. In
|
`('unix', path)`, `('udp', ...)`, `('tipc', stype, inst,
|
||||||
particular it is **`'uds'`, not the external multiaddr spelling
|
scope)`. Then `wrap_address()` collapses from an
|
||||||
`'unix'`**. If a wire or display format uses a different name,
|
order-sensitive `match` to `_address_types[addr[0]]`, and the
|
||||||
name that translation explicitly; today `_multiaddr.py` maps
|
whole collision class stops existing. Note this *also* aligns
|
||||||
internal `uds` to external `/unix/`. Then `wrap_address()` can
|
the on-wire form with `mk_maddr()`/`parse_maddr()`, so the two
|
||||||
dispatch through `_address_types[addr[0]]` without an
|
representations stop being independent inventions.
|
||||||
order-sensitive shape match.
|
|
||||||
|
|
||||||
Two consequences to plan for:
|
Two consequences to plan for:
|
||||||
- it's a **wire-format change**. Widen and keep synchronized
|
- it's a **wire-format change** (`SpawnSpec`,
|
||||||
`discovery._addr.UnwrappedAddress` and the duplicate wire
|
`_root_mailbox`, `_registry_addrs`) plus every test fixture
|
||||||
alias in `msg.types`; change `SpawnSpec.reg_addrs` and
|
and downstream config (`piker`'s `[network]` table). It
|
||||||
`.bind_addrs`, not only `_root_mailbox` and
|
wants its **own migration commit, landed before any new
|
||||||
`_registry_addrs`. Audit the related `RuntimeVars`
|
backend**, not smuggled into one.
|
||||||
`_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
|
- it's the moment to **stop handing raw unwrapped tuples to
|
||||||
users at all.** The long-term shape is: `Address` subtypes
|
users at all.** The long-term shape is: `Address` subtypes
|
||||||
are the public currency and `UnwrappedAddress` becomes an
|
are the public currency and `UnwrappedAddress` becomes an
|
||||||
|
|
@ -130,8 +104,8 @@ Hard constraints learned from the existing two:
|
||||||
`ipaddress` uses (you pass `IPv4Address`, not a 4-tuple).
|
`ipaddress` uses (you pass `IPv4Address`, not a 4-tuple).
|
||||||
Public API should accept `Address|maddr-str` and treat bare
|
Public API should accept `Address|maddr-str` and treat bare
|
||||||
tuples as legacy-tolerated input, ideally deprecated.
|
tuples as legacy-tolerated input, ideally deprecated.
|
||||||
- **`.get_random()` must not deterministically alias without a
|
- **`.get_random()` must be collision-free without a live
|
||||||
live runtime.** See the `UDSAddress.get_random()` uuid-token
|
runtime.** See the `UDSAddress.get_random()` uuid-token
|
||||||
comment (`_uds.py:207-220`): with no `current_actor()` the
|
comment (`_uds.py:207-220`): with no `current_actor()` the
|
||||||
sockname degenerates to a pure fn of `(prefix, pid)` and two
|
sockname degenerates to a pure fn of `(prefix, pid)` and two
|
||||||
calls in one proc alias. Mix in a `uuid4().hex[:8]` token.
|
calls in one proc alias. Mix in a `uuid4().hex[:8]` token.
|
||||||
|
|
@ -188,11 +162,10 @@ if (unwrapped := lstnr.socket.getsockname()) != self.addr.unwrap():
|
||||||
```
|
```
|
||||||
|
|
||||||
i.e. it assumes `lstnr.socket.getsockname()` exists and that its
|
i.e. it assumes `lstnr.socket.getsockname()` exists and that its
|
||||||
return value is a valid `from_addr()` input. That is false for
|
return value is a valid `from_addr()` input. This is fine for
|
||||||
TIPC, whose listener sockname is an undialable port ID, and for
|
TIPC (§3 of plan 01) and **is the main integration hazard for
|
||||||
non-socket iroh. Both plans must use the explicit backend rebind
|
iroh** (§3 of plan 02) — plans that break it must say so
|
||||||
policy added at this integration point rather than pretending a
|
explicitly and propose the upstream `_server.py` patch.
|
||||||
sockname is always an address replacement.
|
|
||||||
|
|
||||||
### 1.4 `class Msgpack<Proto>Stream(MsgpackTransport)`
|
### 1.4 `class Msgpack<Proto>Stream(MsgpackTransport)`
|
||||||
|
|
||||||
|
|
@ -251,26 +224,20 @@ path.** This is why plan 01 is small and plan 02 is not.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Registration and policy wiring
|
## 2. Registration tables (the full wiring checklist)
|
||||||
|
|
||||||
Adding a backend requires this complete audit. Not every item
|
Adding a backend touches these and only these:
|
||||||
changes for every backend, but none may be assumed from the others:
|
|
||||||
|
|
||||||
1. `tractor/runtime/_state.py:46`
|
1. `tractor/runtime/_state.py:46`
|
||||||
`TransportProtocolKey = Literal['tcp', 'uds', ...]` — add the
|
`TransportProtocolKey = Literal['tcp', 'uds', ...]` — add the
|
||||||
internal key. This `Literal` is the **declared protocol-key
|
key. This `Literal` is the canonical set; `_testing/pytest.py`
|
||||||
set**, not proof that a backend is usable on this host.
|
drives `--tpt-proto` validation off `_addr._address_types`,
|
||||||
2. `tractor/discovery/_addr.py` `_address_protos` and
|
and the spawn-backend fixture already models the
|
||||||
`_address_types: dict[str, Type[Address]]` — register
|
"drive-the-set-from-the-Literal" pattern
|
||||||
`'<key>': <Proto>Address`. `_address_types` is a plain
|
(`pytest.py:870-880`) — do the same rather than hardcoding.
|
||||||
**`dict`, not a `bidict`**, and represents the backends this
|
2. `tractor/discovery/_addr.py:173` `_address_types: bidict` —
|
||||||
build registers for import and dispatch. UDS is conditional on
|
`{'<key>': <Proto>Address}`. Note it is a **`bidict`**, so
|
||||||
`HAS_UDS`, while TIPC can remain registered on a host where its
|
the mapping must stay 1:1.
|
||||||
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` —
|
3. `tractor/discovery/_addr.py:181` `_default_lo_addrs` —
|
||||||
`'<key>': <Proto>Address.get_root().unwrap()`.
|
`'<key>': <Proto>Address.get_root().unwrap()`.
|
||||||
⚠️ this dict is built at **import time**, so
|
⚠️ this dict is built at **import time**, so
|
||||||
|
|
@ -283,7 +250,7 @@ changes for every backend, but none may be assumed from the others:
|
||||||
add a case iff your `unwrapped_type` isn't already uniquely
|
add a case iff your `unwrapped_type` isn't already uniquely
|
||||||
matched. **Preferably do the proto-key migration in §1.1
|
matched. **Preferably do the proto-key migration in §1.1
|
||||||
first**, after which this step becomes a one-line
|
first**, after which this step becomes a one-line
|
||||||
`_address_types` lookup instead of an order-sensitive `case`.
|
`_address_types` entry instead of an order-sensitive `case`.
|
||||||
5. `tractor/ipc/_types.py` — `Address` union alias,
|
5. `tractor/ipc/_types.py` — `Address` union alias,
|
||||||
`_msg_transports` list, `_key_to_transport[('msgpack', key)]`,
|
`_msg_transports` list, `_key_to_transport[('msgpack', key)]`,
|
||||||
`_addr_to_transport[<Proto>Address]`.
|
`_addr_to_transport[<Proto>Address]`.
|
||||||
|
|
@ -295,17 +262,9 @@ changes for every backend, but none may be assumed from the others:
|
||||||
`parse_maddr()`.
|
`parse_maddr()`.
|
||||||
8. `tractor/ipc/__init__.py` — re-export if the backend has a
|
8. `tractor/ipc/__init__.py` — re-export if the backend has a
|
||||||
public surface.
|
public surface.
|
||||||
9. `tractor/discovery/_api.py::_is_local_addr()` and
|
9. `tractor/_testing/addr.py::get_rando_addr()` — per-proto
|
||||||
`prefer_addr()` — define and test the backend's locality and
|
branch so the whole suite can run under `--tpt-proto <key>`.
|
||||||
selection tier. The current order is UDS, local TCP, then
|
10. `pyproject.toml` — new deps go in an **optional extra**, never
|
||||||
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>`.
|
|
||||||
11. `pyproject.toml` — new deps go in an **optional extra**, never
|
|
||||||
in `[project].dependencies`. See §5.
|
in `[project].dependencies`. See §5.
|
||||||
|
|
||||||
## 3. Where the `trio.SocketListener` assumption is load-bearing
|
## 3. Where the `trio.SocketListener` assumption is load-bearing
|
||||||
|
|
@ -380,10 +339,7 @@ dep-free, or make that table lazy.
|
||||||
`_state._def_tpt_proto` + `_runtime_vars['_enable_tpts']`
|
`_state._def_tpt_proto` + `_runtime_vars['_enable_tpts']`
|
||||||
(`pytest.py:807-835`). Adding the key to `_address_types` is
|
(`pytest.py:807-835`). Adding the key to `_address_types` is
|
||||||
what makes `--tpt-proto <key>` legal (`pytest.py:795-800`
|
what makes `--tpt-proto <key>` legal (`pytest.py:795-800`
|
||||||
asserts the lookup). Thus CLI acceptance follows the
|
asserts the lookup).
|
||||||
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*
|
- The **acceptance bar** for every backend is: the *entire*
|
||||||
existing suite passes under `--tpt-proto <key>`, unmodified.
|
existing suite passes under `--tpt-proto <key>`, unmodified.
|
||||||
That is the whole point of the abstraction. Backend-specific
|
That is the whole point of the abstraction. Backend-specific
|
||||||
|
|
@ -397,11 +353,8 @@ dep-free, or make that table lazy.
|
||||||
`OSError(97, 'Address family not supported by protocol')`
|
`OSError(97, 'Address family not supported by protocol')`
|
||||||
because the `tipc` module isn't loaded. Put the predicate in
|
because the `tipc` module isn't loaded. Put the predicate in
|
||||||
the backend module (so apps can use it too), not in the test.
|
the backend module (so apps can use it too), not in the test.
|
||||||
- New pytest marks must be registered in
|
- New pytest marks must be registered in `pyproject.toml`, per
|
||||||
`_testing/pytest.py::pytest_configure()` with
|
the project's fix-warnings-at-source rule (gh #469).
|
||||||
`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)
|
## 7. Code style (non-negotiable, matches the repo)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,13 @@ Tracks gh [#378]. Prereq reading:
|
||||||
[`00_shared_backend_contract.md`](./00_shared_backend_contract.md).
|
[`00_shared_backend_contract.md`](./00_shared_backend_contract.md).
|
||||||
|
|
||||||
**Thesis**: TIPC is the *cheapest* new backend we can add and
|
**Thesis**: TIPC is the *cheapest* new backend we can add and
|
||||||
gives us kernel-native service-name publication, known-address
|
simultaneously the only one that gives us cluster-wide service
|
||||||
dialling, and topology events. Those are primitives for reducing
|
discovery **for free, in the kernel**, replacing (for
|
||||||
registrar traffic; they do **not** by themselves replace
|
TIPC-capable deployments) the whole `tractor.discovery`
|
||||||
`tractor.discovery`, derive an actor's address from its name, or
|
registrar round-trip with a `bind()`/`connect()` on a
|
||||||
elect one registrar. It is stdlib-only: zero new dependencies.
|
*service name*. 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
|
[#378]: https://github.com/goodboy/tractor/issues/378
|
||||||
[#493]: https://github.com/goodboy/tractor/pull/493
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -79,12 +74,10 @@ The design decision that makes this backend coherent:
|
||||||
> ever an *observed* address (`getpeername()`), never a
|
> ever an *observed* address (`getpeername()`), never a
|
||||||
> user-facing one.**
|
> user-facing one.**
|
||||||
|
|
||||||
This is the "leverage the built-in discovery machinery" part of
|
This is exactly the "leverage the built-in discovery machinery"
|
||||||
#378: publishing a bind is kernel name-table registration and
|
ask in #378: publishing a bind *is* registration, and
|
||||||
`connect()` on an already-known name is a kernel lookup, with no
|
`connect()` on a name *is* a lookup, with no registrar actor in
|
||||||
registrar actor on that **dial** path. Mapping an application name
|
the loop.
|
||||||
to that address and maintaining Tractor's actor registry remain
|
|
||||||
separate work (§5).
|
|
||||||
|
|
||||||
### 2.2 the struct
|
### 2.2 the struct
|
||||||
|
|
||||||
|
|
@ -96,12 +89,12 @@ class TIPCAddress(
|
||||||
_stype: int # TIPC "type" == service class
|
_stype: int # TIPC "type" == service class
|
||||||
_instance: int # service instance within the type
|
_instance: int # service instance within the type
|
||||||
_scope: int = TIPC_CLUSTER_SCOPE
|
_scope: int = TIPC_CLUSTER_SCOPE
|
||||||
# observed-only, excluded from the unwrapped service identity
|
# observed-only, never part of identity/equality-by-intent
|
||||||
maybe_node: int|None = None # from TIPC_ADDR_ID getpeername()
|
maybe_node: int|None = None # from TIPC_ADDR_ID getpeername()
|
||||||
maybe_ref: int|None = None
|
maybe_ref: int|None = None
|
||||||
|
|
||||||
proto_key: ClassVar[str] = 'tipc'
|
proto_key: ClassVar[str] = 'tipc'
|
||||||
unwrapped_type: ClassVar[type] = tuple[str, int, int, int]
|
unwrapped_type: ClassVar[type] = tuple[str, int]
|
||||||
def_bindspace: ClassVar[int] = TIPC_CLUSTER_SCOPE
|
def_bindspace: ClassVar[int] = TIPC_CLUSTER_SCOPE
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -113,8 +106,8 @@ shape as `TCPAddress`*, so `wrap_address()`'s
|
||||||
`case (str(), int())` steals it. This backend is therefore the
|
`case (str(), int())` steals it. This backend is therefore the
|
||||||
forcing function for the contract-doc's conclusion (§1.1):
|
forcing function for the contract-doc's conclusion (§1.1):
|
||||||
|
|
||||||
> **make the unwrapped form carry the explicit internal
|
> **make the unwrapped form carry an explicit proto-key, spelled
|
||||||
> `TransportProtocolKey`.**
|
> with the `multiaddr` protocol name.**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def unwrap(self) -> tuple[str, int, int, int]:
|
def unwrap(self) -> tuple[str, int, int, int]:
|
||||||
|
|
@ -122,31 +115,11 @@ def unwrap(self) -> tuple[str, int, int, int]:
|
||||||
```
|
```
|
||||||
|
|
||||||
`wrap_address()` then dispatches `_address_types[addr[0]]` and
|
`wrap_address()` then dispatches `_address_types[addr[0]]` and
|
||||||
the collision class disappears. The complete all-backend change
|
the collision class disappears. **This is a prerequisite
|
||||||
is a prerequisite migration; #493 necessarily carried the
|
migration commit, not part of this backend** — see contract §1.1
|
||||||
transitional `UnwrappedAddress`/`SpawnSpec.reg_addrs`/
|
for its blast radius (wire format + every fixture + `piker`
|
||||||
`.bind_addrs` widening needed for TIPC. See contract §1.1 for the
|
config) and for the follow-on "stop handing raw tuples to users
|
||||||
remaining runtime annotations, fixtures and `piker` config. Here
|
at all, à la `ipaddress`" direction.
|
||||||
`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
|
⚠️ an earlier revision of this plan proposed a self-tagging
|
||||||
`('tipc:<stype>:<scope>', instance)` string-prefix hack with an
|
`('tipc:<stype>:<scope>', instance)` string-prefix hack with an
|
||||||
|
|
@ -177,28 +150,25 @@ treatment (`_uds.py:242`).
|
||||||
- `_instance` for `get_random()`: TIPC gives us no
|
- `_instance` for `get_random()`: TIPC gives us no
|
||||||
kernel-assigned-instance analogue of `port=0`, so we must
|
kernel-assigned-instance analogue of `port=0`, so we must
|
||||||
choose. Use a *pure* fn of the actor identity so it is
|
choose. Use a *pure* fn of the actor identity so it is
|
||||||
reproducible and well-distributed, **not collision-free**:
|
reproducible and collision-free:
|
||||||
```python
|
```python
|
||||||
# 32-bit instance derived from the actor's Aid.uid, or from a
|
# 32-bit instance derived from the actor's uuid4 (+ pid when
|
||||||
# per-call token + pid when there is no live runtime.
|
# there's no live runtime, per the UDS precedent).
|
||||||
inst: int = int.from_bytes(
|
inst: int = int.from_bytes(
|
||||||
blake2b(seed.encode(), digest_size=4).digest(),
|
blake2b(seed.encode(), digest_size=4).digest(),
|
||||||
'big',
|
'big',
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
where `seed = '.'.join(actor.aid.uid)` if
|
where `seed = f'{actor.aid.name}@{pid}'` if
|
||||||
`current_actor(err_on_no_runtime=False)` else
|
`current_actor(err_on_no_runtime=False)` else
|
||||||
`f'{prefix}.{uuid4().hex[:8]}@{pid}'`. Must avoid the reserved
|
`f'{prefix}.{uuid4().hex[:8]}@{pid}'`. Must avoid the reserved
|
||||||
low range: `inst = 64 + (inst % (2**32 - 64))`.
|
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
|
⚠️ *unlike* `port=0`, a collision here surfaces as a
|
||||||
successful-but-shared publication (TIPC allows multiple
|
successful-but-shared publication (TIPC allows multiple
|
||||||
binders on the same name and round-robins!) rather than
|
binders on the same name and round-robins!) rather than
|
||||||
`EADDRINUSE`. That is a silent-crosstalk failure mode; §7 has
|
`EADDRINUSE`. That is a silent-crosstalk failure mode; §7 has
|
||||||
a statistical test and §9 records the unresolved recovery work
|
the test that proves the 4-byte digest is enough and §9 has
|
||||||
in [#501].
|
the mitigation if it isn't.
|
||||||
- `_scope`: `TIPC_NODE_SCOPE` for a same-host-only actor (the
|
- `_scope`: `TIPC_NODE_SCOPE` for a same-host-only actor (the
|
||||||
UDS-equivalent), `TIPC_CLUSTER_SCOPE` (default) for
|
UDS-equivalent), `TIPC_CLUSTER_SCOPE` (default) for
|
||||||
cluster-visible. **This is `.bindspace`**:
|
cluster-visible. **This is `.bindspace`**:
|
||||||
|
|
@ -219,9 +189,7 @@ treatment (`_uds.py:242`).
|
||||||
@property
|
@property
|
||||||
def is_valid(self) -> bool:
|
def is_valid(self) -> bool:
|
||||||
return (
|
return (
|
||||||
self._instance > 0
|
self._instance != 0
|
||||||
and
|
|
||||||
self._stype > 0
|
|
||||||
and
|
and
|
||||||
self._stype not in _tipc_reserved_stypes # {0, 1, ...}
|
self._stype not in _tipc_reserved_stypes # {0, 1, ...}
|
||||||
and
|
and
|
||||||
|
|
@ -268,11 +236,10 @@ Notes / hazards:
|
||||||
- **no `close_listener()` needed** — nothing to unlink. Omit the
|
- **no `close_listener()` needed** — nothing to unlink. Omit the
|
||||||
function entirely (contract §1.2: absence means implicit).
|
function entirely (contract §1.2: absence means implicit).
|
||||||
Withdrawal of the published name happens on socket close.
|
Withdrawal of the published name happens on socket close.
|
||||||
- `SocketListener.__init__` calls
|
- ⚠️ `SocketListener.__init__` will try
|
||||||
`getsockopt(SOL_SOCKET, SO_ACCEPTCONN)`. The live-kernel probe
|
`getsockopt(SOL_SOCKET, SO_ACCEPTCONN)`. If TIPC rejects it,
|
||||||
used by #493 answers `1`; retain the unit test so a kernel-side
|
trio's `except OSError: pass` covers us. Assert this in a
|
||||||
change is visible rather than relying on trio's suppressed-
|
unit test rather than assuming.
|
||||||
`OSError` carve-out.
|
|
||||||
- Wrap the bind in a `_reraise_as_connerr()`-style `@cm` (copy
|
- Wrap the bind in a `_reraise_as_connerr()`-style `@cm` (copy
|
||||||
the `_uds.py:256` pattern) so `EADDRINUSE`-ish and
|
the `_uds.py:256` pattern) so `EADDRINUSE`-ish and
|
||||||
`EAFNOSUPPORT` become `ConnectionError` with the addr in the
|
`EAFNOSUPPORT` become `ConnectionError` with the addr in the
|
||||||
|
|
@ -291,31 +258,33 @@ returns a `TIPC_ADDR_ID`-flavoured 5-tuple (the port id), *not*
|
||||||
the name-seq we bound. So the `!=` is **always true** and
|
the name-seq we bound. So the `!=` is **always true** and
|
||||||
`from_addr()` will be handed a 5-tuple.
|
`from_addr()` will be handed a 5-tuple.
|
||||||
|
|
||||||
`TIPCAddress.from_addr()` must accept only proto-keyed service
|
Handle it inside `TIPCAddress.from_addr()` — do **not** patch
|
||||||
names. It must reject a bare port ID because no conversion can
|
`_server.py`:
|
||||||
recover `(stype, instance)`:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_addr(cls, addr) -> TIPCAddress:
|
def from_addr(cls, addr) -> TIPCAddress:
|
||||||
match addr:
|
match addr:
|
||||||
# our proto-keyed tuple or decoded-list wire form
|
# our own unwrapped form
|
||||||
case (
|
case (str() as tag, int() as inst) if tag.startswith('tipc:'):
|
||||||
('tipc', int() as stype, int() as inst, int() as scope)
|
_, stype, scope = tag.split(':')
|
||||||
|
|
return TIPCAddress(int(stype), inst, int(scope))
|
||||||
['tipc', int() as stype, int() as inst, int() as scope]
|
|
||||||
):
|
|
||||||
return TIPCAddress(stype, inst, _norm_scope(scope))
|
|
||||||
|
|
||||||
# a bare kernel-observed TIPC_ADDR_ID 5-tuple has no
|
# a kernel-observed TIPC_ADDR_ID 5-tuple: keep the
|
||||||
# service identity to annotate.
|
# *service* identity we already know and only annotate
|
||||||
|
# the observed port-id.
|
||||||
case (int() as atype, *rest) if atype == socket.TIPC_ADDR_ID:
|
case (int() as atype, *rest) if atype == socket.TIPC_ADDR_ID:
|
||||||
raise ValueError(...)
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
The `TIPC_ADDR_ID` case cannot reconstruct `(stype, instance)`.
|
The `TIPC_ADDR_ID` case cannot reconstruct `(stype, instance)`
|
||||||
The resolution is the explicit listener-rebind policy added ahead
|
— that info isn't in a port id. So `from_addr()` alone is
|
||||||
of the backend in #493:
|
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`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
if (
|
if (
|
||||||
|
|
@ -331,13 +300,12 @@ behaviour exactly). Rationale: the reconciliation exists *only*
|
||||||
to learn the kernel-assigned port for `port=0` TCP binds (its
|
to learn the kernel-assigned port for `port=0` TCP binds (its
|
||||||
own comment says so, `_server.py:662`); TIPC has no such
|
own comment says so, `_server.py:662`); TIPC has no such
|
||||||
late-binding, so opting out is semantically right rather than a
|
late-binding, so opting out is semantically right rather than a
|
||||||
hack. Keep the guard test that TCP's `port=0` behaviour is
|
hack. **Land this as its own commit, ahead of the backend**,
|
||||||
unchanged.
|
with a test that `tcp`'s `port=0` behaviour is unchanged.
|
||||||
|
|
||||||
Do **not** annotate `Endpoint.addr` from `getsockname()`: the
|
Keep the observed port-id available anyway: annotate
|
||||||
listener endpoint must remain the dialable service name. Port IDs
|
`ep.addr = ep.addr.with_port_id(*getsockname()[1:3])` (a pure
|
||||||
are observed only on connected streams and may annotate a copy via
|
`msgspec.structs.replace()` helper) purely for logging/repr.
|
||||||
`with_port_id()` purely for logging/repr.
|
|
||||||
|
|
||||||
### 3.3 `MsgpackTIPCStream`
|
### 3.3 `MsgpackTIPCStream`
|
||||||
|
|
||||||
|
|
@ -372,12 +340,11 @@ class MsgpackTIPCStream(MsgpackTransport):
|
||||||
0, # domain: 0 == "anywhere in scope"
|
0, # domain: 0 == "anywhere in scope"
|
||||||
destaddr._scope,
|
destaddr._scope,
|
||||||
))
|
))
|
||||||
stream = trio.SocketStream(sock)
|
return cls(
|
||||||
return cls(
|
trio.SocketStream(sock),
|
||||||
stream,
|
prefix_size=prefix_size,
|
||||||
prefix_size=prefix_size,
|
codec=codec,
|
||||||
codec=codec,
|
)
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- reuse `trio._highlevel_open_unix_stream.close_on_error` (the
|
- reuse `trio._highlevel_open_unix_stream.close_on_error` (the
|
||||||
|
|
@ -396,11 +363,11 @@ class MsgpackTIPCStream(MsgpackTransport):
|
||||||
leave at default, we have `trio` cancel scopes.
|
leave at default, we have `trio` cancel scopes.
|
||||||
- `TIPC_DEST_DROPPABLE = 0` on the connection so undeliverable
|
- `TIPC_DEST_DROPPABLE = 0` on the connection so undeliverable
|
||||||
msgs come back as errors rather than being silently dropped.
|
msgs come back as errors rather than being silently dropped.
|
||||||
- **`connect_to()` on a name with no publisher**: the live-kernel
|
- **`connect_to()` on a name with no publisher**: TIPC returns
|
||||||
result is immediate `EHOSTUNREACH`. Python exposes that as a
|
`ECONNREFUSED`/`EHOSTUNREACH` promptly (no SYN-timeout wait),
|
||||||
bare `OSError`, not a `ConnectionError` subtype, so
|
which is *better* discovery-ping behaviour than TCP. Confirm
|
||||||
`_reraise_as_connerr()` is load-bearing for contract §4. Keep
|
the errno and make sure it surfaces as `ConnectionError`
|
||||||
the exact errno and normalization under test.
|
(contract §4 — the registrar ping path depends on it).
|
||||||
|
|
||||||
### 3.4 `get_stream_addrs()`
|
### 3.4 `get_stream_addrs()`
|
||||||
|
|
||||||
|
|
@ -418,27 +385,29 @@ Problem: neither end's port-id tells us the *service name*. The
|
||||||
`laddr`/`raddr` are used for logging, `Channel.raddr`,
|
`laddr`/`raddr` are used for logging, `Channel.raddr`,
|
||||||
`Server._peers` keying-adjacent repr, and `maddr`. Design:
|
`Server._peers` keying-adjacent repr, and `maddr`. Design:
|
||||||
|
|
||||||
- `get_stream_addrs()` converts both socket results into
|
- the **connecting** side knows the destaddr it dialled →
|
||||||
**observed-only** addresses: `_stype`/`_instance` use the
|
`connect_to()` overrides `_raddr` after construction with the
|
||||||
`TIPC_NAME_UNKNOWN = -1` sentinel and `maybe_node`/`maybe_ref`
|
known-good `TIPCAddress`, exactly as
|
||||||
carry the port ID. Such addresses are invalid for dialling.
|
`MsgpackUDSStream.connect_to()` does for the peer-pid case
|
||||||
- the **connecting** side knows the service name it dialled, so
|
(`_uds.py:539-543`).
|
||||||
`connect_to()` replaces `_raddr` after construction with that
|
- the **accepting** side does not know the peer's service name
|
||||||
known `TIPCAddress` while retaining the constructor's one
|
from the socket. Two honest options:
|
||||||
tolerant port-ID observation. Do not call `getpeername()` a
|
- **(a) accept it: `raddr` carries only `(node, ref)`** via
|
||||||
second time: the peer can withdraw between the two calls.
|
`maybe_node`/`maybe_ref`, `_stype/_instance` set to a
|
||||||
- the **accepting** side genuinely cannot recover the peer's
|
sentinel `-1`, and `__repr__` renders
|
||||||
service name from a port ID. Keep the observed-only `raddr`;
|
`TIPCAddress[<peer-node:0x...>:<ref>]`. The `Aid` from the
|
||||||
the handshake's `Aid` supplies logical identity. Piggybacking a
|
handshake already gives us the peer's logical identity, so
|
||||||
bound name in the handshake is outside this backend.
|
nothing in the runtime actually *needs* the peer's service
|
||||||
- `laddr` is observed-only as well. It is used for repr/logging,
|
name. **Recommended.**
|
||||||
not to replace the endpoint's known service name.
|
- (b) piggyback the peer's own bound name in the handshake.
|
||||||
- unlike TCP/UDS, TIPC can answer `ENOTCONN` from
|
Rejected for this PR: touches `Aid`/msg-spec.
|
||||||
`getpeername()` after a connect-then-drop. This lookup happens
|
- `laddr` on the accepting side: the `Endpoint` knows its own
|
||||||
during `MsgpackTransport` construction, before handshake error
|
`addr`; but `get_stream_addrs()` is a `@classmethod` with only
|
||||||
tolerance. Wrap `getsockname()` and `getpeername()` in a
|
the stream. Use `TIPC_ADDR_ID` for `laddr` too and let
|
||||||
tolerant helper and degrade to a port-ID-less observed address;
|
`Endpoint.peer_tpts` keying (which is by *peer* addr) still
|
||||||
a dropped peer must cost an observation, not kill the actor.
|
work. Verify nothing asserts `laddr == ep.addr` — grep for
|
||||||
|
`.laddr` uses before committing (`_server.py`'s
|
||||||
|
`con_status` logging, `Channel.pformat()`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -475,47 +444,43 @@ the maddr stays 2-segment like `/unix/...`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Discovery primitives and explicit limits
|
## 5. Discovery: the actually-interesting part
|
||||||
|
|
||||||
The backend provides independently-shippable kernel primitives.
|
Two independently-shippable layers. **Layer A is in scope for
|
||||||
Neither primitive alone implements Tractor's actor-name discovery,
|
the first PR; layer B is a fast-follow.**
|
||||||
registry ownership, or registrar election.
|
|
||||||
|
|
||||||
### 5.1 Layer A — "discovery by bind" (free)
|
### 5.1 Layer A — "discovery by bind" (free)
|
||||||
|
|
||||||
Because `bind(TIPC_ADDR_NAMESEQ)` publishes and
|
Because `bind(TIPC_ADDR_NAMESEQ)` publishes and
|
||||||
`connect(TIPC_ADDR_NAME)` resolves, a caller that **already knows**
|
`connect(TIPC_ADDR_NAME)` resolves, a `tractor` tree whose
|
||||||
a TIPC service address can dial it without a registrar lookup.
|
`registry_addrs` are TIPC service names needs **no registrar
|
||||||
This is narrower than registrar-less `find_actor(name)`:
|
liveness at all** for the connect path: `find_actor()`'s
|
||||||
|
"connect to the registrar and ask" becomes "connect to the
|
||||||
|
service name directly". Concretely:
|
||||||
|
|
||||||
- `tractor.discovery._api.find_actor()` and peers still query a
|
- `tractor.discovery._api.find_actor()` etc. keep working
|
||||||
registrar; #493 does not change them.
|
unchanged (they go through the registrar), *and*
|
||||||
- deriving a stable service address from `(name, uuid)` and
|
- a new, TIPC-only fast path becomes possible: derive an actor's
|
||||||
dialling it directly is follow-up [#499]. The mapping must be
|
service name from its `(name, uuid)` and dial it without any
|
||||||
documented and cross-language stable.
|
registrar hop.
|
||||||
- `registry_addrs` still identify registrars. Connecting to a
|
|
||||||
known registrar by TIPC name removes no registrar bookkeeping
|
|
||||||
or ownership semantics.
|
|
||||||
|
|
||||||
There is also an unresolved **split-brain election** problem.
|
Do **not** build the fast path in PR 1. Instead, prove the
|
||||||
Duplicate TIPC name publication succeeds and round-robins, so two
|
property with a test (§7.4) and file the follow-up: it changes
|
||||||
roots can both probe an unoccupied registrar name, both bind it,
|
`discovery` semantics (name→instance derivation must be a
|
||||||
and both believe they won. The backend provides no atomic
|
documented, stable, cross-language-able hash) and deserves its
|
||||||
compare-and-publish, lease, quorum, or deterministic winner. A
|
own design.
|
||||||
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`)
|
### 5.2 Layer B — the topology service (`TIPC_TOP_SRV`)
|
||||||
|
|
||||||
This is the push primitive behind #378's "end game cluster proto"
|
This is what makes #378's "end game cluster proto" claim real:
|
||||||
direction: a subscription to kernel name-table publish/withdraw
|
a *subscription* to name-table events, i.e. push-based
|
||||||
events. #493 implements `open_topology_events()`; consuming that
|
`register`/`deregister` for free, replacing the registrar's
|
||||||
feed in `discovery._registry` is follow-up [#496]. Until then it
|
polled `find_actor()`.
|
||||||
does not replace registrar state or `find_actor()`.
|
|
||||||
|
|
||||||
Mechanics, verified against `linux/include/uapi/linux/tipc.h`,
|
Mechanics (verify each field against
|
||||||
`net/tipc/topsrv.c` and #493's live-kernel probe:
|
`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):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# SOCK_SEQPACKET connected to the topology server
|
# SOCK_SEQPACKET connected to the topology server
|
||||||
|
|
@ -533,25 +498,22 @@ await sock.connect((
|
||||||
# __u32 filter; /* TIPC_SUB_{PORTS,SERVICE,CANCEL} */
|
# __u32 filter; /* TIPC_SUB_{PORTS,SERVICE,CANCEL} */
|
||||||
# char usr_handle[8];
|
# char usr_handle[8];
|
||||||
# } /* == 28 bytes */
|
# } /* == 28 bytes */
|
||||||
_SUBSCR_FMT: str = '=5I8s'
|
_SUBSCR_FMT: str = '=IIIII8s' # ⚠ 5*I is 20 -> use '=5I8s'
|
||||||
```
|
```
|
||||||
|
|
||||||
- **byte order**: #493's live-kernel probe verified native
|
- **byte order**: the topology server historically accepts both
|
||||||
standard-size (`'='`) packing for publish and withdraw events.
|
host and swapped order and auto-detects; modern kernels are
|
||||||
Use `'=5I8s'` for the 28-byte subscription. Do not retain the
|
strict-ish. Pack native (`'='`) first, and if the server
|
||||||
speculative `'>'` retry/probe as if it were required. Preserve
|
closes the connection immediately, retry with `'>'`. Encode
|
||||||
the earlier `# ?TODO` to verify the deterministic rule directly
|
that as a one-time probe helper
|
||||||
against `net/tipc/topsrv.c`; it is source-audit work, not a
|
`_detect_topsrv_endianness()` cached at module level — and
|
||||||
runtime retry requirement.
|
put a `# ?TODO` pointing at `net/tipc/topsrv.c` for someone
|
||||||
|
to make it deterministic.
|
||||||
- **events**: `struct tipc_event` is `event: u32`,
|
- **events**: `struct tipc_event` is `event: u32`,
|
||||||
`found_lower: u32`, `found_upper: u32`,
|
`found_lower: u32`, `found_upper: u32`,
|
||||||
`port: {ref: u32, node: u32}`, then the 28-byte subscription
|
`port: {ref: u32, node: u32}`, then the 28-byte subscription
|
||||||
echo: **48 bytes** (`4 + 4 + 4 + 8 + 28`), not 40. Use
|
echo → 40 bytes. `event ∈ {TIPC_PUBLISHED, TIPC_WITHDRAWN,
|
||||||
`'=10I8s'` and assert `struct.calcsize(...) == 48`.
|
TIPC_SUBSCR_TIMEOUT}`.
|
||||||
`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,
|
- **trio shape** — this is where the "nearly-functional,
|
||||||
modern-async" style pays off; expose it as an `@acm` yielding
|
modern-async" style pays off; expose it as an `@acm` yielding
|
||||||
a `trio` receive-channel of typed events, *not* a class:
|
a `trio` receive-channel of typed events, *not* a class:
|
||||||
|
|
@ -576,22 +538,14 @@ async def open_topology_events(
|
||||||
`kind: Literal['published','withdrawn','timeout']`,
|
`kind: Literal['published','withdrawn','timeout']`,
|
||||||
`addr: TIPCAddress`, `node: int`, `ref: int`. One
|
`addr: TIPCAddress`, `node: int`, `ref: int`. One
|
||||||
`trio.lowlevel`-free implementation: a nursery-spawned reader
|
`trio.lowlevel`-free implementation: a nursery-spawned reader
|
||||||
task doing `await sock.recv(48)` in a loop. The feed is
|
task doing `await sock.recv(40)` in a loop and
|
||||||
authoritative and may neither block the socket reader nor drop
|
`send_nowait()`ing decoded events, with the `@acm` closing the
|
||||||
transitions silently. Use `send_nowait()` and, on
|
socket on exit → reader gets `ClosedResourceError` → cancel
|
||||||
`trio.WouldBlock`, raise a dedicated
|
scope collapses. Standard `tractor` `@acm` discipline.
|
||||||
`TIPCNameEventOverflow` that aborts the subscription and tells
|
- **consumer**: `tractor/discovery/_registry.py` gains an
|
||||||
the consumer to resubscribe and rebuild its view. A timeout
|
optional "watch" mode so a registrar (or any actor) can keep
|
||||||
event is delivered once and then closes the channel. The
|
a live view of the actor set without polling. Sketch the
|
||||||
`@acm` cancels its reader before closing the fd so teardown
|
integration in the follow-up issue; do not wire it in PR 1.
|
||||||
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
|
- **`SOCK_SEQPACKET` is fine here** because this socket never
|
||||||
goes through `MsgpackTransport` — it's a plain trio socket
|
goes through `MsgpackTransport` — it's a plain trio socket
|
||||||
used with `recv()`. The contract's "`SOCK_STREAM` only"
|
used with `recv()`. The contract's "`SOCK_STREAM` only"
|
||||||
|
|
@ -607,16 +561,14 @@ async def open_topology_events(
|
||||||
2. `tractor/ipc/_tipc.py`: `TIPCAddress` + `is_tipc_available()`
|
2. `tractor/ipc/_tipc.py`: `TIPCAddress` + `is_tipc_available()`
|
||||||
predicate + `start_listener()`. No transport yet.
|
predicate + `start_listener()`. No transport yet.
|
||||||
Tests: address round-trip (`unwrap`/`from_addr`/`wrap_address`),
|
Tests: address round-trip (`unwrap`/`from_addr`/`wrap_address`),
|
||||||
`get_random()` distribution, bind/listen + `SO_ACCEPTCONN`
|
`get_random()` uniqueness, bind/listen + `SO_ACCEPTCONN`
|
||||||
tolerance, `EAFNOSUPPORT` → actionable `ConnectionError`.
|
tolerance, `EAFNOSUPPORT` → actionable `ConnectionError`.
|
||||||
3. `MsgpackTIPCStream` + `connect_to()` + `get_stream_addrs()`.
|
3. `MsgpackTIPCStream` + `connect_to()` + `get_stream_addrs()`.
|
||||||
Test: two `trio` tasks in one proc exchange a msg over
|
Test: two `trio` tasks in one proc exchange a msg over
|
||||||
`Msgpack` framing (no `tractor` runtime).
|
`Msgpack` framing (no `tractor` runtime).
|
||||||
4. registration tables (contract §2 items 1-8 and 10) +
|
4. registration tables (contract §2 items 1-6, 9) +
|
||||||
`pyproject.toml` mark/extra. Test: full suite under
|
`pyproject.toml` mark/extra. Test: full suite under
|
||||||
`--tpt-proto tipc` (§7.3). Keep TIPC in the conservative remote
|
`--tpt-proto tipc` (§7.3).
|
||||||
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.
|
5. maddr support (`str` form + prefix special-case) + docs.
|
||||||
6. `open_topology_events()` @acm + its tests (layer B).
|
6. `open_topology_events()` @acm + its tests (layer B).
|
||||||
7. docs page + `docs/` example.
|
7. docs page + `docs/` example.
|
||||||
|
|
@ -637,8 +589,6 @@ def is_tipc_available() -> bool:
|
||||||
the `tipc` module is loaded.
|
the `tipc` module is loaded.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if sys.platform != 'linux':
|
|
||||||
return False
|
|
||||||
try:
|
try:
|
||||||
socket.socket(socket.AF_TIPC, socket.SOCK_STREAM).close()
|
socket.socket(socket.AF_TIPC, socket.SOCK_STREAM).close()
|
||||||
return True
|
return True
|
||||||
|
|
@ -646,22 +596,17 @@ def is_tipc_available() -> bool:
|
||||||
return False
|
return False
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not permanently memoize the result: `modprobe tipc` and module
|
Cache it in a module global (it can't change without a
|
||||||
removal can change it during a long-lived process. Probe once per
|
`modprobe`, and a cold call costs a syscall). Pure predicate, no
|
||||||
runtime startup, or use an explicitly refreshable cache whose
|
side effects, no logging.
|
||||||
owner invalidates it after module-management operations. The
|
|
||||||
predicate itself remains side-effect-free and silent.
|
|
||||||
|
|
||||||
### 7.2 gating
|
### 7.2 gating
|
||||||
|
|
||||||
- `pytest.mark.tipc` registered in
|
- `pytest.mark.tipc` registered in `pyproject.toml`.
|
||||||
`_testing/pytest.py::pytest_configure()` via
|
- module-level
|
||||||
`config.addinivalue_line()`, where this repo declares its other
|
`pytestmark = pytest.mark.skipif(not is_tipc_available(),
|
||||||
custom marks. Do not invent a `pyproject.toml` marker table.
|
reason='`tipc` kernel module not loaded (`modprobe tipc`)')`
|
||||||
- keep pure address, serialization, and topology-codec tests
|
in `tests/ipc/test_tipc.py`.
|
||||||
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
|
- `--tpt-proto tipc` with no module must fail **loudly and
|
||||||
early** with the actionable message, not with 400 confusing
|
early** with the actionable message, not with 400 confusing
|
||||||
timeouts. Add the check to the `tpt_protos` fixture's existing
|
timeouts. Add the check to the `tpt_protos` fixture's existing
|
||||||
|
|
@ -677,29 +622,23 @@ predicate itself remains side-effect-free and silent.
|
||||||
`sudo modprobe tipc` in a `before` step. GH's
|
`sudo modprobe tipc` in a `before` step. GH's
|
||||||
`ubuntu-latest` runners do allow `modprobe tipc` (the module
|
`ubuntu-latest` runners do allow `modprobe tipc` (the module
|
||||||
ships with the standard Ubuntu kernel package); verify in a
|
ships with the standard Ubuntu kernel package); verify in a
|
||||||
throwaway workflow before wiring the matrix. #493's TIPC leg
|
throwaway workflow before wiring the matrix. If it turns out
|
||||||
is now blocking. If runners cease permitting the module load,
|
to be unavailable, fall back to a container job with
|
||||||
fix the environment or use a suitable container rather than
|
`--privileged`/`--cap-add NET_ADMIN`, and mark the job
|
||||||
silently restoring `continue-on-error`.
|
`continue-on-error` until it's proven stable.
|
||||||
- cross-node TIPC (bearer) cannot be CI'd; cover it with a
|
- cross-node TIPC (bearer) cannot be CI'd; cover it with a
|
||||||
documented manual smoke test in the docs page, in the style
|
documented manual smoke test in the docs page, in the style
|
||||||
of gh #482's LAN examples.
|
of gh #482's LAN examples.
|
||||||
|
|
||||||
### 7.4 backend-specific tests worth writing
|
### 7.4 backend-specific tests worth writing
|
||||||
|
|
||||||
- **known-name publication/resolution**: bind a listener on
|
- **name-publication is discovery**: bind a listener on
|
||||||
`(stype, inst)`, then from a second task `connect()` by name
|
`(stype, inst)`, then from a second task `connect()` by name
|
||||||
and assert it lands — *without* any `tractor` registrar.
|
and assert it lands — *without* any `tractor` registrar.
|
||||||
- **`get_random()` distribution**: 10k `get_random()` calls with
|
- **`get_random()` collision resistance**: 10k `get_random()`
|
||||||
no live runtime. Do **not** assert 10k distinct values: the
|
calls with no live runtime → 10k distinct `_instance`s.
|
||||||
no-runtime seeds and outputs are both only 32 bits. Including
|
(This is the silent-crosstalk risk from §2.3; if the 4-byte
|
||||||
duplicate seeds plus distinct-seed hash collisions puts the
|
digest ever collides in this test, escalate to §9.)
|
||||||
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*
|
- **round-robin surprise**: two listeners bound to the *same*
|
||||||
`(stype, inst)` both succeed (TIPC allows it) and connects
|
`(stype, inst)` both succeed (TIPC allows it) and connects
|
||||||
distribute. Assert the observed behaviour and reference it
|
distribute. Assert the observed behaviour and reference it
|
||||||
|
|
@ -739,61 +678,14 @@ single best demo this backend has; lead with it.
|
||||||
|
|
||||||
## 9. Known risks + escalations
|
## 9. Known risks + escalations
|
||||||
|
|
||||||
- **Instance collision / silent crosstalk remains unresolved.**
|
| risk | mitigation |
|
||||||
`Aid.uid` seeding and §7.4 tests reduce and measure risk, but
|
| --- | --- |
|
||||||
the instance field is still a hard 32 bits. [#501] owns
|
| `_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)` |
|
||||||
post-bind verification and recovery. Do not fold bits into
|
| kernel/module unavailability everywhere (dev boxes, macOS, CI) | hard gating (§7.2); TIPC is explicitly an *opt-in cluster* transport, never a default |
|
||||||
`_stype`: topology can watch only one service type.
|
| `getsockname()` returns port-id not name | the `rebind_from_sockname` opt-out (§3.2), landed first |
|
||||||
- **Concurrent registrar startup can split brain.** Topology can
|
| unregistered `/tipc` multiaddr proto | `str` maddr fallback (§4) + upstream track gh #483 |
|
||||||
observe duplicate publisher port IDs but cannot elect or fence
|
| 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 |
|
||||||
a winner; a separate election protocol is required (§5.1).
|
| `SOCK_SEQPACKET` topology framing byte-order | probe helper + `?TODO` (§5.2) |
|
||||||
- **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
|
## 10. Follow-up issue seeds
|
||||||
|
|
||||||
|
|
@ -803,11 +695,9 @@ explicit rather than reporting the backend as fully hardened:
|
||||||
`py-multiaddr`, then drop our `str`-maddr fallback (§4). Worth
|
`py-multiaddr`, then drop our `str`-maddr fallback (§4). Worth
|
||||||
filing *alongside* the `wg` spec-submission issue so both
|
filing *alongside* the `wg` spec-submission issue so both
|
||||||
proposals go up together rather than as one-offs.
|
proposals go up together rather than as one-offs.
|
||||||
- registrar-less discovery fast path via name derivation ([#499],
|
- registrar-less discovery fast path via name derivation (§5.1)
|
||||||
§5.1)
|
|
||||||
- `TIPC_TOP_SRV`-driven push registry in
|
- `TIPC_TOP_SRV`-driven push registry in
|
||||||
`discovery/_registry.py` ([#496], §5.2)
|
`discovery/_registry.py` (§5.2)
|
||||||
- post-bind collision verification and recovery ([#501], §9)
|
|
||||||
- `TIPC_IMPORTANCE` for the parent<->child lifetime channel
|
- `TIPC_IMPORTANCE` for the parent<->child lifetime channel
|
||||||
(§3.3) — genuinely novel supervision QoS, no other backend
|
(§3.3) — genuinely novel supervision QoS, no other backend
|
||||||
can do it
|
can do it
|
||||||
|
|
@ -815,7 +705,3 @@ explicit rather than reporting the backend as fully hardened:
|
||||||
for `tractor.trionics` fan-out (explicitly not `MsgTransport`)
|
for `tractor.trionics` fan-out (explicitly not `MsgTransport`)
|
||||||
- dual-link resiliency / multi-homing (#378's "hybrid dual link")
|
- dual-link resiliency / multi-homing (#378's "hybrid dual link")
|
||||||
once bearers are scripted in the docs
|
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,12 +3,6 @@
|
||||||
Tracks gh [#353]. Prereq reading:
|
Tracks gh [#353]. Prereq reading:
|
||||||
[`00_shared_backend_contract.md`](./00_shared_backend_contract.md).
|
[`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
|
**Thesis**: the value of `iroh` over "just QUIC" is
|
||||||
`NodeId`-addressed, NAT-traversing, relay-fallback endpoints —
|
`NodeId`-addressed, NAT-traversing, relay-fallback endpoints —
|
||||||
i.e. a `tractor` actor tree that spans hosts *without* a
|
i.e. a `tractor` actor tree that spans hosts *without* a
|
||||||
|
|
@ -55,22 +49,20 @@ relitigate:
|
||||||
**But**: build it first as the throwaway spike (§6 step 0) to
|
**But**: build it first as the throwaway spike (§6 step 0) to
|
||||||
de-risk the iroh API surface before writing the bridge.
|
de-risk the iroh API surface before writing the bridge.
|
||||||
|
|
||||||
Version pinning: treat API stability across `iroh` minors as an
|
Version pinning: `iroh` moves fast and has had breaking
|
||||||
**unverified external constraint** until step 0. Pin the version
|
API renames across minors. Pin `iroh>=X.Y,<X.Y+1` in a `quic`
|
||||||
exercised by the spike to `iroh>=X.Y,<X.Y+1` in a `quic` extra,
|
extra, and **write down the exact resolved version + the
|
||||||
and **write down the exact resolved version + generated
|
generated `iroh/_uniffi*` module layout** in the module
|
||||||
`iroh/_uniffi*` module layout** in the module docstring, because
|
docstring, because §2 depends on generated-code internals.
|
||||||
§2 depends on generated-code internals.
|
|
||||||
|
|
||||||
**Step 0 of implementation is an API-truth pass**: install the
|
**Step 0 of implementation is an API-truth pass**: install the
|
||||||
pinned `iroh`, inspect both its generated Python and loaded FFI
|
pinned `iroh`, `python -c "import iroh; help(iroh)"`, and record
|
||||||
symbols, and run the throwaway two-process spike. Record in
|
in this doc's §1.1 the real names of: endpoint builder, secret
|
||||||
§1.1 the real names and observed contracts. Every statement
|
key type, `connect`/`accept`, bi-stream open/accept, the
|
||||||
below about `iroh`, UniFFI, Rust callbacks, or generated symbols
|
send/recv methods and their exact signatures/return types, and
|
||||||
is a **step-0 hypothesis**, not a locally proven fact, unless it
|
whether they're `async def`. Everything below uses *provisional*
|
||||||
is copied into the completed API-truth table with a source or
|
names and must be reconciled. Do not skip this; do not guess
|
||||||
probe. Tractor and Trio behavior cited from this checkout is not
|
from memory.
|
||||||
subject to that qualifier.
|
|
||||||
|
|
||||||
### 1.1 API-truth table (fill in during step 0)
|
### 1.1 API-truth table (fill in during step 0)
|
||||||
|
|
||||||
|
|
@ -87,120 +79,122 @@ subject to that qualifier.
|
||||||
| send | `await send_stream.write_all(b)` | |
|
| send | `await send_stream.write_all(b)` | |
|
||||||
| recv | `await recv_stream.read(n) -> bytes\|None` | |
|
| recv | `await recv_stream.read(n) -> bytes\|None` | |
|
||||||
| half-close | `await send_stream.finish()` | |
|
| 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. The `trio`-native uniffi future bridge (`tractor/ipc/_uniffi_trio.py`)
|
||||||
|
|
||||||
### 2.1 Step-0 generated-ABI gate
|
### 2.1 what uniffi actually generates
|
||||||
|
|
||||||
The expected generated shape is: start an opaque Rust future,
|
`uniffi`'s async support does not use asyncio *semantically* —
|
||||||
poll it with a C callback, cancel through a generated cancel
|
it uses asyncio only as the *executor* for a poll loop. The
|
||||||
symbol, consume its terminal value/status through `complete`,
|
generated python for an `async fn` is, in shape:
|
||||||
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. the start, poll, cancel, complete, and free signatures for
|
1. call `_uniffi_..._<method>(...)` → returns an opaque
|
||||||
every return-type family used by `iroh`;
|
`RustFuture` handle (a `void*`/`u64`).
|
||||||
2. poll result values and whether callbacks can be synchronous,
|
2. loop: call
|
||||||
concurrent, repeated, or late;
|
`ffi_..._rust_future_poll_<T>(handle, callback, callback_data)`.
|
||||||
3. which terminal state permits `complete`, when `free` is
|
The callback is a C-ABI fn pointer invoked **from an
|
||||||
legal, and when no callback can still reference Python;
|
arbitrary rust thread** with a poll-result code
|
||||||
4. whether generated callback-data and call-status objects must
|
(`READY`/`MAYBE_READY`).
|
||||||
remain alive, and how generated lifting/errors are applied;
|
3. the generated glue's callback resolves an
|
||||||
5. whether one narrow generated async-driver entrypoint can be
|
`asyncio.Future` via `loop.call_soon_threadsafe(...)`; the
|
||||||
replaced without importing or requiring an asyncio loop.
|
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)`.
|
||||||
|
|
||||||
Do not implement from a remembered UniFFI version. If cancel
|
**The asyncio dependency is confined to step 3.** That is the
|
||||||
does not have a documented path to a terminal, safely freeable
|
whole insight: the bridge is ~40 lines.
|
||||||
state, the native Trio bridge fails the spike gate and the first
|
|
||||||
backend uses the infected-asyncio fallback.
|
|
||||||
|
|
||||||
### 2.2 Cancellation-safe ownership
|
### 2.2 the trio version
|
||||||
|
|
||||||
Do not let the caller task own a raw handle across an `await`.
|
```python
|
||||||
Introduce an actor-scoped `UniffiFutureSupervisor` running in the
|
async def await_rust_future(
|
||||||
dedicated transport nursery specified in §3.2.1. That nursery
|
poll: Callable, # ffi_..._rust_future_poll_<T>
|
||||||
must span parent bootstrap, the service nurseries, and final
|
complete: Callable, # ffi_..._rust_future_complete_<T>
|
||||||
deregistration. For each call, its operation task owns the
|
free: Callable, # ffi_..._rust_future_free_<T>
|
||||||
**entire** generated lifecycle:
|
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()`.
|
||||||
|
|
||||||
```text
|
'''
|
||||||
create handle -> poll/callback loop -> complete -> lift/status
|
token = trio.lowlevel.current_trio_token()
|
||||||
-> free -> publish result
|
while True:
|
||||||
^
|
wake = trio.Event()
|
||||||
cancel request uses generated cancel, then follows
|
# NOTE, invoked from a *rust* thread!
|
||||||
the verified terminal poll/complete/free protocol
|
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)
|
||||||
```
|
```
|
||||||
|
|
||||||
The operation task, not the awaiting caller, creates the handle.
|
Critical details, each a real bug if missed:
|
||||||
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.
|
|
||||||
|
|
||||||
Caller cancellation is a request, not handle ownership transfer:
|
- **`token.run_sync_soon()` is the only trio API callable from a
|
||||||
|
foreign thread**, and it is documented as such. Use it; do
|
||||||
1. the caller sends an idempotent cancel request and waits under
|
*not* use `trio.from_thread.run_sync` (requires a trio thread
|
||||||
a short shield for the operation to acknowledge it;
|
context) and do not touch the `Event` directly from the
|
||||||
2. the owner invokes the generated cancel function exactly once
|
callback.
|
||||||
and continues the **verified** poll/complete/free sequence;
|
- **the poll code must reach the trio side.** Capture it in a
|
||||||
3. once caller cancellation is observed, cleanup completion never
|
`nonlocal`/1-slot list written by the callback *before*
|
||||||
wins the race by returning a value. After acknowledgement the
|
`run_sync_soon`, since the callback owns the value. Handle
|
||||||
caller continues propagating its original Trio cancellation;
|
`MAYBE_READY` by re-polling (the loop above does).
|
||||||
if cleanup outlives the grace period it first abandons its
|
- **keep the `ctypes` callback object alive** across the await —
|
||||||
result channel while the actor supervisor keeps ownership;
|
a GC'd `CFUNCTYPE` trampoline is a segfault. Bind it to a
|
||||||
4. actor endpoint teardown stops accepting new calls, requests
|
local *and* make sure the local outlives the `poll()` call
|
||||||
cancellation of all live operations, and joins the supervisor
|
window.
|
||||||
before destroying endpoint/key state.
|
- **cancellation.** `await wake.wait()` is a trio checkpoint, so
|
||||||
|
a `Cancelled` can fire while rust still owns the future. On
|
||||||
There is deliberately no `move_on_after(...): free(handle)`
|
cancel we must still `free(handle)` — and per uniffi, the
|
||||||
path. A timeout proves only that cleanup is slow; it does not
|
correct sequence is to call the generated
|
||||||
prove that callbacks are quiescent or that `free` is legal. A
|
`ffi_..._rust_future_cancel_<T>(handle)` then continue
|
||||||
wedged operation therefore remains visible in the supervisor and
|
polling to completion before `free`. Wrap the whole thing so
|
||||||
can delay graceful actor shutdown; process-level termination is
|
the cancel path does:
|
||||||
the final escalation, not an unsafe FFI free.
|
`with trio.CancelScope(shield=True): cancel(handle); <drain
|
||||||
|
poll loop>; free(handle)`. **Bounded** shield (add a
|
||||||
Structured-concurrency race to test: caller cancellation may land
|
`trio.move_on_after()` with a module-level constant) so a
|
||||||
after handle creation, after each poll, during callback delivery,
|
wedged rust future can't make an actor un-cancellable —
|
||||||
after terminal readiness, during `complete`, and before result
|
`tractor` is SC-first and an unbounded shield here would
|
||||||
publication. At every checkpoint exactly one operation task owns
|
violate that.
|
||||||
the handle, exactly one `free` is possible, and the supervisor
|
- **`trio.lowlevel.current_trio_token()`** must be captured on
|
||||||
cannot exit while that task or a callable trampoline remains.
|
the trio side (not in the callback).
|
||||||
|
|
||||||
### 2.3 how to apply it to the generated bindings
|
### 2.3 how to apply it to the generated bindings
|
||||||
|
|
||||||
Do **not** fork/vendor the generated `iroh` Python. Subject to the
|
Do **not** fork/vendor the generated `iroh` python. Instead ship
|
||||||
step-0 gate, ship a *narrow* re-dispatch shim:
|
a *narrow* re-dispatch shim:
|
||||||
|
|
||||||
- write `tractor/ipc/_uniffi_trio.py` with the supervisor and a
|
- write `tractor/ipc/_uniffi_trio.py` with `await_rust_future()`
|
||||||
`@cm patch_uniffi_for_trio()` that patches only the generated
|
plus a `@cm patch_uniffi_for_trio()` that monkey-patches the
|
||||||
async-driver entrypoint recorded in §1.1;
|
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.
|
||||||
- verify at import time that the expected symbol exists and
|
- verify at import time that the expected symbol exists and
|
||||||
raise a clear, actionable error naming the pinned `iroh`
|
raise a clear, actionable error naming the pinned `iroh`
|
||||||
version if not. A silent fallback to asyncio would be a
|
version if not. A silent fallback to asyncio would be a
|
||||||
nightmare to debug.
|
nightmare to debug.
|
||||||
- treat every `iroh`/UniFFI upgrade as requiring the step-0 ABI
|
- **plan for this to break on `iroh`/`uniffi` upgrades.** Mitigate
|
||||||
gate again. Keep a test that drives one trivial call under bare
|
with (a) a unit test that drives one trivial `iroh` async call
|
||||||
`trio.run()`, asserts no asyncio loop, and injects cancellation
|
under bare `trio.run()` and asserts no event loop was ever
|
||||||
at every lifecycle checkpoint. Point the module docstring at
|
created (`asyncio.get_event_loop_policy()` untouched /
|
||||||
the exact generated template/revision mirrored by the shim.
|
`asyncio._get_running_loop() is None`), and (b) a docstring
|
||||||
|
pointing at the uniffi codegen template this mirrors.
|
||||||
|
|
||||||
If step 0 reveals the generated code is *structurally* hostile
|
If step 0 reveals the generated code is *structurally* hostile
|
||||||
to this (e.g. `asyncio` imported and used at module scope for
|
to this (e.g. `asyncio` imported and used at module scope for
|
||||||
|
|
@ -234,52 +228,18 @@ iroh bi-stream == one `Channel`/`MsgTransport` -> 1:1
|
||||||
- `layer_key: int = 4` still (QUIC is L4-ish); note in a comment
|
- `layer_key: int = 4` still (QUIC is L4-ish); note in a comment
|
||||||
that this backend is really 4+security+multiplex.
|
that this backend is really 4+security+multiplex.
|
||||||
|
|
||||||
**Connection pooling** is actor-endpoint state, never module
|
**Connection pooling** is the one place we add state the other
|
||||||
state. Its key is exactly
|
backends don't have: dialing the same peer twice should reuse
|
||||||
`(local_endpoint_identity, remote_node_id, alpn)`, where local
|
the `Connection` and open a second bi-stream. Implement as a
|
||||||
endpoint identity is the local NodeId derived from the actor key.
|
module-level `dict[NodeId, Connection]` guarded by a
|
||||||
Remote NodeId alone would incorrectly share connections across
|
`trio.Lock`... **no** — that's a per-process cache with
|
||||||
local keys or protocol epochs. Build it over the codebase's
|
lifetime/teardown hazards. Instead reuse the codebase's existing
|
||||||
`maybe_open_context()` idiom only after a concurrency review of
|
idiom: `tractor.trionics.maybe_open_context()` keyed on the
|
||||||
its actual last-user teardown behavior in the implementation
|
node-id, which already solves exactly this (one-cached-resource-
|
||||||
revision. Do not assume an issue reference proves the required
|
per-key, refcounted, teardown-on-last-exit) and whose teardown
|
||||||
ordering.
|
semantics were just hardened (gh #488). Use it; do not hand-roll
|
||||||
|
a cache. Anything concurrency-subtle here should get the
|
||||||
`acquire_connection()` returns a `ConnectionLease`, not a bare
|
`conc-anal` skill run over it.
|
||||||
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`
|
### 3.2 `IrohAddress`
|
||||||
|
|
||||||
|
|
@ -288,29 +248,29 @@ class IrohAddress(
|
||||||
msgspec.Struct,
|
msgspec.Struct,
|
||||||
frozen=True,
|
frozen=True,
|
||||||
):
|
):
|
||||||
_node_id: str
|
_node_id: str # 32B ed25519 pubkey, hex or z32
|
||||||
_alpn: str
|
_alpn: str = 'tractor/0' # the bindspace!
|
||||||
_relay_url: str|None
|
# optional dial hints; NOT part of identity
|
||||||
_direct_addrs: tuple[str, ...]
|
maybe_relay_url: str|None = None
|
||||||
|
maybe_direct_addrs: tuple[str, ...] = ()
|
||||||
|
|
||||||
proto_key: ClassVar[str] = 'quic'
|
proto_key: ClassVar[str] = 'iroh' # ?or 'quic'; see §3.2.1
|
||||||
unwrapped_type: ClassVar[type] = tuple
|
unwrapped_type: ClassVar[type] = tuple[str, str]
|
||||||
def_bindspace: ClassVar[str] = 'tractor/0'
|
def_bindspace: ClassVar[str] = 'tractor/0'
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`.unwrap()` is the complete, tagged wire descriptor**:
|
- **`.unwrap() -> (node_id_str, alpn_str)`** — a `(str, str)`
|
||||||
`('quic', node_id, alpn, relay_url, direct_addrs)`. All values
|
tuple, which is *unambiguously distinct* from
|
||||||
are msgpack-native and `direct_addrs` is canonicalized to a
|
`TCPAddress`'s `(str, int)`. But careful:
|
||||||
tuple. `from_addr()` requires that exact tag and shape; never
|
`wrap_address()`'s UDS case is
|
||||||
infer QUIC from a `(str, str)` pair. This depends on the shared
|
`case (_, filename) if type(filename) is str` — which
|
||||||
contract's tagged-address migration and removes the UDS
|
**already catches `(str, str)`**. So the iroh `case` MUST be
|
||||||
collision rather than ordering around it.
|
ordered *before* the UDS case and guarded, e.g.
|
||||||
- The descriptor always carries NodeId, ALPN, and both route-hint
|
`case (str() as nid, str() as alpn) if _is_node_id(nid):`
|
||||||
fields. For this discovery-free first backend, `.is_valid`
|
with `_is_node_id()` a cheap length+alphabet check. Add a
|
||||||
requires a parseable NodeId, non-empty ALPN, and at least one
|
regression test asserting a UDS `(dir, filename)` pair still
|
||||||
relay URL or direct address. Whether NodeId-only dialing works
|
wraps to `UDSAddress` — this is the exact "wrong transport
|
||||||
through optional iroh discovery is a step-0 API check and is
|
loaded" hazard `_addr.py:214` warns about.
|
||||||
not part of the first implementation.
|
|
||||||
- `.bindspace` → `self._alpn`. This is the honest analogue:
|
- `.bindspace` → `self._alpn`. This is the honest analogue:
|
||||||
the ALPN is the set of endpoints willing to talk to you, and
|
the ALPN is the set of endpoints willing to talk to you, and
|
||||||
two `tractor` deployments sharing an iroh network are
|
two `tractor` deployments sharing an iroh network are
|
||||||
|
|
@ -318,80 +278,39 @@ class IrohAddress(
|
||||||
separated by directory. Include a `tractor` version/proto
|
separated by directory. Include a `tractor` version/proto
|
||||||
epoch in the default ALPN so incompatible runtimes can't
|
epoch in the default ALPN so incompatible runtimes can't
|
||||||
handshake.
|
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 One actor endpoint and key
|
#### 3.2.1 `proto_key`: `'iroh'` vs `'quic'`
|
||||||
|
|
||||||
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
|
Use **`'quic'`** for the `proto_key`/`--tpt-proto` name and
|
||||||
name the module `_quic.py`, with `iroh` as the *implementation*.
|
name the module `_quic.py`, with `iroh` as the *implementation*.
|
||||||
|
|
@ -438,7 +357,7 @@ class QuicMsgStream(trio.abc.HalfCloseableStream):
|
||||||
'''
|
'''
|
||||||
tpt_key: ClassVar[MsgTransportKey] = ('msgpack', 'quic')
|
tpt_key: ClassVar[MsgTransportKey] = ('msgpack', 'quic')
|
||||||
|
|
||||||
def __init__(self, conn, send, recv, lease) -> None: ...
|
def __init__(self, conn, send, recv) -> None: ...
|
||||||
async def send_all(self, data: bytes) -> None: ...
|
async def send_all(self, data: bytes) -> None: ...
|
||||||
async def wait_send_all_might_not_block(self) -> None: ...
|
async def wait_send_all_might_not_block(self) -> None: ...
|
||||||
async def receive_some(self, max_bytes: int|None = None) -> bytes: ...
|
async def receive_some(self, max_bytes: int|None = None) -> bytes: ...
|
||||||
|
|
@ -460,38 +379,17 @@ already exists in `_transport.py` and must keep working):
|
||||||
absent, so the `raise_on_report` branch at
|
absent, so the `raise_on_report` branch at
|
||||||
`_transport.py:290` stays quiet).
|
`_transport.py:290` stays quiet).
|
||||||
- `send_all()` on a closed peer → `trio.BrokenResourceError`.
|
- `send_all()` on a closed peer → `trio.BrokenResourceError`.
|
||||||
- honour Trio's one-task-per-direction rule with public,
|
- honour `trio`'s one-task-per-direction rule: guard with
|
||||||
implementation-local guards that raise
|
`trio._util.ConflictDetector` equivalents (or just document +
|
||||||
`trio.BusyResourceError`; do not depend on `trio._util`.
|
assert), because `MsgpackTransport` already serializes sends
|
||||||
`MsgpackTransport` already serializes sends, while receives are
|
with a `StrictFIFOLock` but recvs are single-task by
|
||||||
single-task by construction.
|
construction.
|
||||||
- **buffering**: if iroh's `read()` doesn't support
|
- **buffering**: if iroh's `read()` doesn't support
|
||||||
"read up to n", `receive_some()` must maintain an internal
|
"read up to n", `receive_some()` must maintain an internal
|
||||||
leftover buffer. Note `MsgpackTransport` wraps us in
|
leftover buffer. Note `MsgpackTransport` wraps us in
|
||||||
`tricycle.BufferedReceiveStream` anyway, so `receive_some()`
|
`tricycle.BufferedReceiveStream` anyway, so `receive_some()`
|
||||||
just needs *some* nonzero-progress contract.
|
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
|
```python
|
||||||
class QuicListener(trio.abc.Listener):
|
class QuicListener(trio.abc.Listener):
|
||||||
'''
|
'''
|
||||||
|
|
@ -504,63 +402,41 @@ class QuicListener(trio.abc.Listener):
|
||||||
async def aclose(self) -> None: ...
|
async def aclose(self) -> None: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
The accept-side subtlety is fan-out: one actor transport accepts
|
The accept-side subtlety: `trio.abc.Listener.accept()` yields
|
||||||
connections and each connection accepts streams, while
|
one stream per call, but iroh gives us *connections* which then
|
||||||
`Listener.accept()` returns one stream. Give **each** listener a
|
yield *streams*. So `QuicListener` needs an internal
|
||||||
supervisor task started with
|
`trio.MemoryReceiveChannel[QuicMsgStream]` fed by a background
|
||||||
`await server_ep.listen_tn.start(...)`.
|
task-pair (one task accepting connections, one per connection
|
||||||
That task creates and owns a cancel scope, a child nursery for the
|
accepting bi-streams). `trio.abc.Listener` has no nursery, so:
|
||||||
endpoint feeder plus per-connection feeders, a guarded stream
|
make the listener **constructed by an `@acm`** that owns the
|
||||||
queue, and a completion event.
|
nursery, and have `start_listener()` be that `@acm`'s driver.
|
||||||
`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()`.
|
|
||||||
|
|
||||||
The queue is a guarded `deque`, not an unowned memory-channel
|
⚠️ this collides with `Endpoint.start_listener()` being a plain
|
||||||
buffer. A feeder transfers a fully constructed, lease-owning
|
`async def` returning a listener. Two options:
|
||||||
stream into it only while the listener is open; if close wins the
|
- **(a)** hang the nursery off the `Endpoint`'s existing
|
||||||
race, the feeder closes the stream itself. `accept()` atomically
|
`listen_tn` — `_serve_ipc_eps()` already creates `listen_tn`
|
||||||
pops one item or waits on the queue condition. Once close is
|
and passes it into every `Endpoint` (`_server.py:1063-1074`),
|
||||||
marked and the queue is empty, it raises
|
and `Endpoint.listen_tn` is right there. So
|
||||||
`trio.ClosedResourceError`.
|
`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.
|
||||||
|
|
||||||
`QuicListener.aclose()` is idempotent and has this exact order:
|
Since `start_listener()` is called via
|
||||||
|
`inspect.getmodule(addr)` with only `addr=` (contract §1.3),
|
||||||
1. under the queue guard, mark closed and wake all `accept()`
|
option (a) needs the `Endpoint` itself. Either add `ep=` to the
|
||||||
waiters without a checkpoint between the state change and
|
module-level `start_listener()` call signature (all backends
|
||||||
notification;
|
ignore it except quic → small upstream change, do it as part of
|
||||||
2. cancel the listener-owned supervisor scope;
|
the prep PR and make it keyword-only with a default) or have
|
||||||
3. the supervisor's shielded `finally` joins the endpoint and all
|
`QuicListener.accept()` lazily spawn via
|
||||||
connection feeders, atomically detaches the queue, closes every
|
`trio.lowlevel.current_task().parent_nursery` (**rejected** —
|
||||||
queued stream, releases their leases, and closes the queue;
|
fragile, implicit). Do the explicit `ep=` kwarg.
|
||||||
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`
|
### 3.4 `maddr`
|
||||||
|
|
||||||
Expected multiaddr spellings for direct QUIC and relay routes are
|
Multiaddr already standardizes the pieces:
|
||||||
**step-0 verification items**, not assumptions:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
/ip4/<h>/udp/<p>/quic-v1 # direct
|
/ip4/<h>/udp/<p>/quic-v1 # direct
|
||||||
|
|
@ -568,16 +444,19 @@ Expected multiaddr spellings for direct QUIC and relay routes are
|
||||||
/dns/<relay-host>/tcp/443/tls/ws/p2p/<node> # relay-ish
|
/dns/<relay-host>/tcp/443/tls/ws/p2p/<node> # relay-ish
|
||||||
```
|
```
|
||||||
|
|
||||||
- Do not emit NodeId alone in the first backend: without enabled
|
- primary form: `/p2p/<node-id>` alone is a legal maddr and is
|
||||||
discovery it would discard the route required by the complete
|
the *only* required component for iroh dialling — relay +
|
||||||
`IrohAddress`. `mk_maddr()` must preserve NodeId, ALPN, relay
|
direct addrs are discovery hints. So `mk_maddr()` emits
|
||||||
URL, and all direct addresses, or return a canonical Tractor
|
`/p2p/<node_id>` and, when known, prefixes the direct
|
||||||
string form that does until a multiaddr grammar can round-trip
|
`/ip4/../udp/../quic-v1/`.
|
||||||
every field.
|
- `/p2p/` values are multihash-encoded peer ids; an iroh node-id
|
||||||
- Verify whether an iroh NodeId can losslessly map to `/p2p/`.
|
is a raw ed25519 key. Converting requires the identity
|
||||||
If not, use a tractor-local `/iroh/<node-id>` segment rather
|
multihash + libp2p key protobuf wrapper. **Decide**: emit the
|
||||||
than pretending to be a libp2p peer-id. This needs upstream
|
raw node-id under a *tractor-local* `/iroh/<node-id>` segment
|
||||||
registration, on the same track as `wg`/`tipc` (gh #483).
|
(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`).
|
||||||
- this backend is the strongest argument for gh #443's
|
- this backend is the strongest argument for gh #443's
|
||||||
**tunnelled/composed maddr** item: `/ip4/../udp/../quic-v1/..`
|
**tunnelled/composed maddr** item: `/ip4/../udp/../quic-v1/..`
|
||||||
*is* a composed stack. Cross-reference plan 03 §5 so the two
|
*is* a composed stack. Cross-reference plan 03 §5 so the two
|
||||||
|
|
@ -587,25 +466,24 @@ Expected multiaddr spellings for direct QUIC and relay routes are
|
||||||
|
|
||||||
## 4. Discovery integration
|
## 4. Discovery integration
|
||||||
|
|
||||||
- The registrar stores the complete `IrohAddress`, not only a
|
- iroh's node-id addressing means the `tractor` registrar can
|
||||||
NodeId. Registration is forbidden until endpoint address
|
hold `IrohAddress`es that are **reachable from anywhere** with
|
||||||
resolution has produced that descriptor. If route hints change
|
no port-forwarding — that is the headline feature. The
|
||||||
later, dynamic re-registration is a follow-up; the spike uses
|
registrar itself works unchanged.
|
||||||
the pre-registration snapshot.
|
- iroh has its own discovery (DNS/pkarr/mdns). **Out of scope**;
|
||||||
- Optional iroh discovery mechanisms and their names/capabilities
|
note in the follow-up that `tractor.discovery` could
|
||||||
are step-0 verification items and out of scope for the first
|
eventually delegate to it, which would be the direct analogue
|
||||||
backend. No NodeId-only reachability claim is made.
|
of plan 01's TIPC-topology idea.
|
||||||
- Relay configuration belongs to `QuicActorEndpoint` creation,
|
- relay servers: default to n0's public relays for the demo,
|
||||||
not `start_listener()`, because dialing and listening reuse the
|
document self-hosting (docs.iroh.computer's dedicated-infra
|
||||||
same endpoint. The demo's relay choice and self-hosted option
|
page is linked from #353), and make the relay set a
|
||||||
are selected only after step 0 verifies the pinned API.
|
`start_listener()` kwarg.
|
||||||
|
|
||||||
## 5. Security note
|
## 5. Security note
|
||||||
|
|
||||||
The transport-security and NodeId-authentication properties of the
|
QUIC is TLS-1.3-always and iroh authenticates by node-id, so
|
||||||
pinned iroh stack are **step-0 documentation-verification items**.
|
this backend is the first `tractor` transport with real
|
||||||
Claim only the properties supported by that version's source and
|
transport security and peer authentication. Two things follow:
|
||||||
docs. Two design consequences remain:
|
|
||||||
1. an **allowlist hook** — an actor should be able to reject
|
1. an **allowlist hook** — an actor should be able to reject
|
||||||
inbound connections from unknown node-ids *before* the
|
inbound connections from unknown node-ids *before* the
|
||||||
`Aid` handshake. Natural home: a predicate kwarg on
|
`Aid` handshake. Natural home: a predicate kwarg on
|
||||||
|
|
@ -619,72 +497,59 @@ docs. Two design consequences remain:
|
||||||
|
|
||||||
0. **spike (throwaway, not committed)**: drive iroh under
|
0. **spike (throwaway, not committed)**: drive iroh under
|
||||||
`trio-asyncio`/`tractor.to_asyncio`, echo bytes over a
|
`trio-asyncio`/`tractor.to_asyncio`, echo bytes over a
|
||||||
bi-stream between two processes. Fill §1.1 with generated ABI,
|
bi-stream between two procs. Fills in §1.1. Timebox it.
|
||||||
endpoint resolution, close/join, and error observations. Probe
|
1. prep PR: annotation widening + `rebind_from_sockname` gate +
|
||||||
cancel at every generated lifecycle phase. Timebox it and use
|
`transport_from_stream()` `tpt_key` dispatch + `ep=` kwarg on
|
||||||
the fallback if any mandatory ownership fact stays unknown.
|
`start_listener()` + lazy `default_lo_addrs()`. **No new
|
||||||
1. prep PR: tagged address migration, annotation widening,
|
backend.** Full suite green on tcp *and* uds.
|
||||||
non-socket listener reconciliation, `tpt_key` dispatch,
|
2. `_uniffi_trio.py` + its tests (drive one iroh async call
|
||||||
typed `server_ep=`/`actor_tpt=` listener inputs, and lazy
|
under bare `trio.run()`; assert no asyncio loop; assert
|
||||||
default addresses. **No new backend.** Keep tcp and uds
|
cancellation frees the future).
|
||||||
behavior unchanged.
|
3. `QuicMsgStream` + tests against a *loopback* iroh endpoint
|
||||||
2. bootstrap prep: pass `ChildTransportBootstrap` through every
|
pair in one process (no `tractor` runtime): send/recv, clean
|
||||||
process-launch path and add the transport nursery around child
|
EOF → `b''`, reset → `BrokenResourceError`, use-after-close
|
||||||
parent-dial, service, deregistration, and teardown. Resolve the
|
→ `ClosedResourceError`.
|
||||||
endpoint address before registration. Add no iroh-specific
|
4. `QuicListener` + `start_listener()` + `IrohAddress` +
|
||||||
global state.
|
key-file mgmt.
|
||||||
3. `_uniffi_trio.py` supervisor + lifecycle fault-injection tests:
|
5. `MsgpackQuicStream(MsgpackTransport)` + `connect_to()` +
|
||||||
no asyncio loop, one owner/complete/free, callback retention,
|
`maybe_open_context()` connection pooling.
|
||||||
bounded caller handoff, and joined durable cleanup.
|
6. registration tables + `--tpt-proto quic` + full suite.
|
||||||
4. `QuicActorEndpoint` + provisioned registrar descriptor +
|
7. maddr + docs + a two-host example (pairs with #482's format).
|
||||||
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
|
## 7. Testing
|
||||||
|
|
||||||
- capability predicate `is_quic_available()` → `iroh` importable
|
- capability predicate `is_quic_available()` → `iroh` importable
|
||||||
*and* every step-0-recorded driver symbol present at the pinned
|
*and* the uniffi driver symbol present at the pinned version.
|
||||||
version. Same `pytest.fail`-early hook as plan 01 §7.2.
|
Same `pytest.fail`-early hook as plan 01 §7.2.
|
||||||
- **the acceptance bar is the same**: whole suite green under
|
- **the acceptance bar is the same**: whole suite green under
|
||||||
`--tpt-proto quic`. Expect this to shake out real bugs in the
|
`--tpt-proto quic`. Expect this to shake out real bugs in the
|
||||||
adapters (esp. teardown ordering and `TransportClosed`
|
adapters (esp. teardown ordering and `TransportClosed`
|
||||||
classification) — that's the point.
|
classification) — that's the point.
|
||||||
- Measure endpoint bind and first-connect latency in step 0; do
|
- expect to need **timeout headroom**: iroh endpoint bind +
|
||||||
not assume a multiplier. Before changing a deadline, rule out
|
first connect (relay discovery) is orders of magnitude slower
|
||||||
the project's CPU-throttle false-positive, then prefer one
|
than a UDS bind. Before touching any test deadline, rule out
|
||||||
per-proto harness multiplier over individual-test edits.
|
the CPU-throttle false-positive (see the project's
|
||||||
- Use the step-0-verified relay-disable configuration with direct
|
`env_cpu_throttle_masquerades_as_regression` note); then, if
|
||||||
loopback addresses for default CI. Mark separately verified
|
real, add a per-proto timeout multiplier to the test harness
|
||||||
relay tests `pytest.mark.net` and keep them out of default CI.
|
rather than editing individual tests.
|
||||||
- leak checks: assert the actor has one key/endpoint, every FFI
|
- a no-network test mode: iroh with relays disabled +
|
||||||
operation completed/freed once, all listener feeders joined,
|
loopback direct addrs only, so CI doesn't depend on n0's
|
||||||
all queued streams closed, every connection lease released,
|
infra. **Make this the default in CI**; mark the relay tests
|
||||||
and endpoint close completion observed before actor teardown.
|
`pytest.mark.net` and keep them out of the default run.
|
||||||
- address-ordering check: block registration until a descriptor
|
- leak checks: assert every `SecretKey`/`Endpoint` is closed on
|
||||||
with NodeId, ALPN, and at least one route is published; reject
|
actor teardown (an `Endpoint` left open holds UDP sockets and
|
||||||
sentinel, NodeId-only, and post-registration mutation cases.
|
relay connections; a leak here shows up as hung tests, not
|
||||||
|
errors).
|
||||||
|
|
||||||
## 8. Risks
|
## 8. Risks
|
||||||
|
|
||||||
| risk | mitigation |
|
| risk | mitigation |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| uniffi codegen internals shift on upgrade | pinned minor, symbol assertion at import, the "no asyncio loop" test, documented fallback to `to_asyncio` |
|
| uniffi codegen internals shift on upgrade | pinned minor, symbol assertion at import, the "no asyncio loop" test, documented fallback to `to_asyncio` |
|
||||||
| callback wakeup/lifetime semantics differ from the hypothesis | step-0 source + probe gate; retain callback/data through verified quiescence; durable owner; never timeout-free |
|
| 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 |
|
||||||
| 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 |
|
| `iroh` wheel availability for 3.13/3.14 on linux+macos | verify in step 0; if missing, that alone may force the `aioquic` fallback |
|
||||||
| endpoint or route resolution is not ready before parent dial/registration | actor endpoint bootstrap barrier; publish only a complete resolved descriptor |
|
| QUIC latency/jitter destabilizes the existing suite's timing assumptions | per-proto timeout multiplier, relay-less CI mode |
|
||||||
| connection closes while a stream still uses it | full-key pool + stream-held leases + exact-once release tests |
|
| `(str, str)` unwrapped form collides with UDS in `wrap_address()` | guarded case ordered first + explicit regression test (§3.2) |
|
||||||
| 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 |
|
| 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
|
## 9. Follow-up issue seeds
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ onto `trio` as the library's sans-io layer allows.
|
||||||
by multiformats/py-multiaddr#107 and gh #483.
|
by multiformats/py-multiaddr#107 and gh #483.
|
||||||
- so **today's deployable story is declarative**: run `wg-quick`
|
- so **today's deployable story is declarative**: run `wg-quick`
|
||||||
out-of-band, parse the maddr, strip to the overlay
|
out-of-band, parse the maddr, strip to the overlay
|
||||||
`(host, port)`, verify the pubkey in its host-specific role,
|
`(host, port)`, verify the pubkey against the live tunnel,
|
||||||
hand the overlay addr to `registry_addrs=`/`tpt_bind_addrs=`.
|
hand the overlay addr to `registry_addrs=`/`tpt_bind_addrs=`.
|
||||||
#482 already contains working example code for exactly this.
|
#482 already contains working example code for exactly this.
|
||||||
- `Address.namespace` exists in the Protocol
|
- `Address.namespace` exists in the Protocol
|
||||||
|
|
@ -82,10 +82,9 @@ does not create a new address type.** Two candidate encodings;
|
||||||
the tunnel spec. The wrapper is stripped (`→ .overlay`) at the
|
the tunnel spec. The wrapper is stripped (`→ .overlay`) at the
|
||||||
moment of bind/connect.
|
moment of bind/connect.
|
||||||
- ⚠️ `is_wrapped_addr()` (`_addr.py:194`) tests
|
- ⚠️ `is_wrapped_addr()` (`_addr.py:194`) tests
|
||||||
`type(addr) in _address_types.values()` — the build-registered
|
`type(addr) in _address_types.values()` — a `bidict` of
|
||||||
protocol-key-to-address-type registry. `TunnelledAddress`
|
proto_key→type. `TunnelledAddress` isn't in it and must not
|
||||||
isn't in it and must not be (it has no transport of its own).
|
be (it's not 1:1 with a proto). So either add an explicit
|
||||||
So either add an explicit
|
|
||||||
`isinstance(addr, TunnelledAddress)` clause there, or give
|
`isinstance(addr, TunnelledAddress)` clause there, or give
|
||||||
the wrapper a marker and test structurally. Do the former;
|
the wrapper a marker and test structurally. Do the former;
|
||||||
it's two lines and honest.
|
it's two lines and honest.
|
||||||
|
|
@ -104,7 +103,7 @@ class WGTunnelSpec(
|
||||||
msgspec.Struct,
|
msgspec.Struct,
|
||||||
frozen=True,
|
frozen=True,
|
||||||
):
|
):
|
||||||
pubkey: str # std-base64 `wg(8)` form
|
peer_pubkey: str # std-base64 `wg(8)` form
|
||||||
iface: str = 'wg0'
|
iface: str = 'wg0'
|
||||||
netns: str|None = None
|
netns: str|None = None
|
||||||
# layer-C-only fields, unset in layer A
|
# layer-C-only fields, unset in layer A
|
||||||
|
|
@ -192,10 +191,9 @@ Observed protocol-name lists, for writing the `match`:
|
||||||
...))` w/ the bearer recorded in the spec.
|
...))` w/ the bearer recorded in the spec.
|
||||||
- keep the existing 2-proto cases byte-identical; add the new
|
- keep the existing 2-proto cases byte-identical; add the new
|
||||||
case *after* them.
|
case *after* them.
|
||||||
- layer A rejects more than one `/wg/` segment. Its wrapper stores
|
- nesting (wg-in-wg) falls out of `.decapsulate_code()` cutting
|
||||||
one bearer, key, and overlay, so accepting wg-in-wg would
|
at the *last* occurrence — peel repeatedly rather than
|
||||||
silently misrepresent the maddr. Nested tunnel support needs a
|
recursing through a bespoke splitter.
|
||||||
different data shape and belongs in a later layer.
|
|
||||||
- `mk_maddr()` inverse for `TunnelledAddress` is just
|
- `mk_maddr()` inverse for `TunnelledAddress` is just
|
||||||
`.encapsulate()` composition; don't rebuild `str`s by hand.
|
`.encapsulate()` composition; don't rebuild `str`s by hand.
|
||||||
- **pending an upstream release**: py-multiaddr#108 is merged, so
|
- **pending an upstream release**: py-multiaddr#108 is merged, so
|
||||||
|
|
@ -217,21 +215,12 @@ Port #482 §2's helpers into `tractor/discovery/_tunnel.py` as
|
||||||
```python
|
```python
|
||||||
def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure
|
def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure
|
||||||
def wg8_pubkey(multibase_key: str) -> str: ... # pure
|
def wg8_pubkey(multibase_key: str) -> str: ... # pure
|
||||||
async def verify_wg_key(
|
def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # impure probe
|
||||||
spec: WGTunnelSpec,
|
|
||||||
role: Literal['local', 'peer'],
|
|
||||||
inspection: str|None = None,
|
|
||||||
) -> bool: ... # impure probe
|
|
||||||
```
|
```
|
||||||
|
|
||||||
In layer A `verify_wg_key()` may shell out to role-specific
|
In layer A `verify_wg_peer()` may shell out (`wg show <if>
|
||||||
`wg show <if> public-key|peers` queries, but it must be a *single*
|
peers`), but it must be a *single* function so layer B swaps
|
||||||
async, time-bounded function so it never blocks trio's run thread
|
only its body. Never call it implicitly from
|
||||||
and layer B swaps only its body. It verifies key presence only,
|
|
||||||
not `Endpoint`, `AllowedIPs`, handshake state, or routing. Never
|
|
||||||
run `tractor` as root: privileged inspection stays a separate
|
|
||||||
step whose public-key output can be passed as `inspection`. Never
|
|
||||||
call it implicitly from
|
|
||||||
`wrap_address()`/`parse_maddr()` — parsing must stay pure and
|
`wrap_address()`/`parse_maddr()` — parsing must stay pure and
|
||||||
side-effect-free; verification is the *caller's* explicit step
|
side-effect-free; verification is the *caller's* explicit step
|
||||||
(and later, the bindspace `@acm`'s).
|
(and later, the bindspace `@acm`'s).
|
||||||
|
|
@ -292,7 +281,7 @@ Three integration options, in increasing trio-nativeness:
|
||||||
- (3) reimplement the codecs. Never.
|
- (3) reimplement the codecs. Never.
|
||||||
|
|
||||||
**Recommended split**: ship (1) first so layer B is a small,
|
**Recommended split**: ship (1) first so layer B is a small,
|
||||||
reviewable, behaviour-preserving swap of `verify_wg_key()`'s
|
reviewable, behaviour-preserving swap of `verify_wg_peer()`'s
|
||||||
body; then land (2) as a follow-up commit for the read path
|
body; then land (2) as a follow-up commit for the read path
|
||||||
(`wg get`, `link get`) where the sans-io surface is smallest,
|
(`wg get`, `link get`) where the sans-io surface is smallest,
|
||||||
and keep (1) for the privileged mutating ops. Measure before
|
and keep (1) for the privileged mutating ops. Measure before
|
||||||
|
|
@ -317,7 +306,7 @@ async def read_wg_peers(
|
||||||
async def read_wg_pubkey(iface: str = 'wg0', ...) -> str: ...
|
async def read_wg_pubkey(iface: str = 'wg0', ...) -> str: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
and `verify_wg_key()` becomes a thin composition over the two.
|
and `verify_wg_peer()` becomes a thin composition over the two.
|
||||||
Note the pure-getter rule: no `read_wg_peers(..., create=True)`.
|
Note the pure-getter rule: no `read_wg_peers(..., create=True)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ different model/provider) without design or lib-selection drift.
|
||||||
**Read [`00_shared_backend_contract.md`](./00_shared_backend_contract.md)
|
**Read [`00_shared_backend_contract.md`](./00_shared_backend_contract.md)
|
||||||
first** — it is the normative description of what a `tractor`
|
first** — it is the normative description of what a `tractor`
|
||||||
transport backend *is* as of `main@83b34884` (the backend
|
transport backend *is* as of `main@83b34884` (the backend
|
||||||
duck-type, registration and address-selection wiring, the
|
duck-type, the 10-item registration checklist, the test-harness
|
||||||
test-harness plumbing, the code-style rules). The three plans
|
plumbing, the code-style rules). The three plans assume it and
|
||||||
assume it and document only their own deltas.
|
document only their own deltas.
|
||||||
|
|
||||||
| plan | issue | dep | size | lands |
|
| plan | issue | dep | size | lands |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
|
@ -23,12 +23,10 @@ Headline conclusions:
|
||||||
`trio.SocketListener` are address-family agnostic (only
|
`trio.SocketListener` are address-family agnostic (only
|
||||||
`SOCK_STREAM` + a trio socket), and CPython ships `AF_TIPC` +
|
`SOCK_STREAM` + a trio socket), and CPython ships `AF_TIPC` +
|
||||||
23 `TIPC_*` constants. So the backend is ~one module of
|
23 `TIPC_*` constants. So the backend is ~one module of
|
||||||
contract boilerplate, zero new deps, and it buys kernel-native
|
contract boilerplate, zero new deps, and it buys
|
||||||
service primitives: `bind()` publishes, known-address
|
*kernel-native* service discovery: `bind()` publishes,
|
||||||
`connect()` resolves, and topology events report publication
|
`connect()`-by-name resolves — no registrar in the loop.
|
||||||
changes. Actor-name lookup, registrar state and split-brain-safe
|
(`modprobe tipc` is required; hard-gate everything.)
|
||||||
election remain separate work. (`modprobe tipc` is required;
|
|
||||||
hard-gate everything.)
|
|
||||||
- **QUIC's cost is entirely in two adapters**, not in QUIC. The
|
- **QUIC's cost is entirely in two adapters**, not in QUIC. The
|
||||||
`iroh` python bindings are `uniffi`-generated asyncio, but the
|
`iroh` python bindings are `uniffi`-generated asyncio, but the
|
||||||
asyncio dependency is confined to *one* future-poll callback —
|
asyncio dependency is confined to *one* future-poll callback —
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,11 @@ uv sync
|
||||||
```
|
```
|
||||||
|
|
||||||
gets you a `wg`-aware `multiaddr`. That pin goes away once a
|
gets you a `wg`-aware `multiaddr`. That pin goes away once a
|
||||||
release carries the codec. Its `py-multibase` dependency provides
|
release carries the codec. You also need `multibase`:
|
||||||
the imported `multibase` module; no separate install command is
|
|
||||||
needed.
|
```bash
|
||||||
|
uv pip install multibase
|
||||||
|
```
|
||||||
|
|
||||||
Without the codec `parse_wg_maddr()` raises immediately with an
|
Without the codec `parse_wg_maddr()` raises immediately with an
|
||||||
actionable message — there is deliberately **no** degraded
|
actionable message — there is deliberately **no** degraded
|
||||||
|
|
@ -62,9 +64,8 @@ tunnel API (`.decapsulate_code()`, `.split()`, `.join()`,
|
||||||
`.encapsulate()`, `.value_for_protocol()`) rather than any
|
`.encapsulate()`, `.value_for_protocol()`) rather than any
|
||||||
bespoke segment slicing — see its README "En/decapsulate" and
|
bespoke segment slicing — see its README "En/decapsulate" and
|
||||||
"Tunneling" sections. gh #429 was about *dropping* our NIH
|
"Tunneling" sections. gh #429 was about *dropping* our NIH
|
||||||
parser, and that applies to peeling this composed maddr just as
|
parser, and that applies to peeling a tunnel stack just as much
|
||||||
much as to decoding one proto. This example rejects multiple
|
as to decoding one proto.
|
||||||
`/wg/` segments because `WGTunnelledAddr` stores one tunnel.
|
|
||||||
|
|
||||||
## 0. tunnel setup (out-of-band, both hosts)
|
## 0. tunnel setup (out-of-band, both hosts)
|
||||||
|
|
||||||
|
|
@ -105,11 +106,8 @@ AllowedIPs = 10.0.11.1/32
|
||||||
PersistentKeepalive = 25
|
PersistentKeepalive = 25
|
||||||
```
|
```
|
||||||
|
|
||||||
This example configures host A's `ListenPort` and host B's
|
Note how `ListenPort` and `Endpoint` are exactly the maddr's
|
||||||
`Endpoint` from the maddr bearer, and configures host A's
|
bearer segment, and `[Interface] Address` is its overlay host.
|
||||||
`[Interface] Address` from its overlay host. The verification
|
|
||||||
step below checks keys only; it does not inspect those fields or
|
|
||||||
either peer's `AllowedIPs`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo wg-quick up wg0 # both hosts
|
sudo wg-quick up wg0 # both hosts
|
||||||
|
|
@ -120,40 +118,16 @@ ping -c1 10.0.11.1 # from B
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -c "
|
python -c "
|
||||||
from wg_maddr import mb_pubkey
|
import base64, multibase
|
||||||
key = open('wg_pub.key').read().strip()
|
key = open('wg_pub.key').read().strip()
|
||||||
print(mb_pubkey(key))
|
print(multibase.encode('base64url', base64.b64decode(key)).decode())
|
||||||
"
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
Paste the `u...` output into `WG_MADDR` in both scripts (they use
|
Paste the `u...` output into `WG_MADDR` in both scripts (they use
|
||||||
the same string — A's bearer, A's key, A's overlay ep).
|
the same string — A's bearer, A's key, A's overlay ep).
|
||||||
|
|
||||||
## 2. verify the keys
|
## 2. run
|
||||||
|
|
||||||
Interface inspection commonly needs `CAP_NET_ADMIN`. Keep that
|
|
||||||
privileged operation separate from the `tractor` processes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# host A: output must equal the maddr's A_pub key
|
|
||||||
export WG_KEY_INSPECTION="$(sudo wg show wg0 public-key)"
|
|
||||||
|
|
||||||
# host B: output must contain the maddr's A_pub key
|
|
||||||
export WG_KEY_INSPECTION="$(sudo wg show wg0 peers)"
|
|
||||||
```
|
|
||||||
|
|
||||||
These checks establish only that host A uses the declared local
|
|
||||||
key and host B has that key as a configured peer. They do not
|
|
||||||
verify `Endpoint`, `AllowedIPs`, a recent handshake, or routing.
|
|
||||||
The exported text contains public keys only. Each script passes it
|
|
||||||
to `verify_wg_key()` with its host-specific role before starting
|
|
||||||
`tractor`. Callers that already have permission to inspect the
|
|
||||||
interface may omit that argument; the helper's direct query is
|
|
||||||
async and requests cancellation after five seconds. Trio's
|
|
||||||
subprocess termination escalation can make final process cleanup
|
|
||||||
take longer than that cancellation deadline.
|
|
||||||
|
|
||||||
## 3. run
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# host A
|
# host A
|
||||||
|
|
@ -163,17 +137,6 @@ python host_a_srv.py
|
||||||
python host_b_client.py
|
python host_b_client.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Run both `tractor` programs as the normal application account,
|
|
||||||
not as root. Privilege is needed only for tunnel setup and the
|
|
||||||
separate inspection above. If using that preflight, keep the
|
|
||||||
host-specific `WG_KEY_INSPECTION` value exported in each
|
|
||||||
program's shell.
|
|
||||||
|
|
||||||
The client binds its own actor listener to `10.0.11.2:0`, while
|
|
||||||
the service actor binds to host A's `10.0.11.1` overlay host with
|
|
||||||
a random port. Keep `LOCAL_OVERLAY_BIND` aligned with host B's
|
|
||||||
WireGuard interface address if adapting this example.
|
|
||||||
|
|
||||||
`host_a_srv.py` must be importable on host B too, since
|
`host_a_srv.py` must be importable on host B too, since
|
||||||
`portal.run()` refs the fn by module path — standard `tractor`
|
`portal.run()` refs the fn by module path — standard `tractor`
|
||||||
RPC semantics.
|
RPC semantics.
|
||||||
|
|
@ -190,19 +153,19 @@ Four corrections, all from
|
||||||
all. `parse_wg_maddr()` now rejects it with an actionable
|
all. `parse_wg_maddr()` now rejects it with an actionable
|
||||||
error.
|
error.
|
||||||
2. **parsing is pure.** #482's helper had the key-check adjacent
|
2. **parsing is pure.** #482's helper had the key-check adjacent
|
||||||
to the parse; `verify_wg_key()` is now a separate, explicitly
|
to the parse; `verify_wg_peer()` is now a separate, explicitly
|
||||||
composed step for inspection-capable callers. A parser that
|
composed step that the caller invokes. A parser that shells
|
||||||
shells out is a nasty surprise.
|
out is a nasty surprise.
|
||||||
3. **no `sudo`.** #482 ran `sudo wg show`; a library/example must
|
3. **no `sudo`.** #482 ran `sudo wg show`; a library/example must
|
||||||
never escalate or run `tractor` as root. Privileged tunnel
|
never escalate. `wg show` works unprivileged for read on most
|
||||||
setup and key inspection are separate shell steps.
|
setups; if yours needs root, run the script as root rather
|
||||||
|
than embedding `sudo`.
|
||||||
4. **no new `Address` proto-type.** The tunnel rides *beside* the
|
4. **no new `Address` proto-type.** The tunnel rides *beside* the
|
||||||
overlay addr in a frozen `WGTunnelledAddr`, and only `.overlay`
|
overlay addr in a frozen `WGTunnelledAddr`, and only `.overlay`
|
||||||
crosses into `open_nursery()`. #482 §6 floated a `WGAddress`
|
crosses into `open_nursery()`. #482 §6 floated a `WGAddress`
|
||||||
registered in `_address_types` — that registry maps available
|
registered in `_address_types` — that table is a `bidict`
|
||||||
transport keys to concrete address types, and
|
(1:1 proto-key↔type) and `_addr_to_transport` wants a
|
||||||
`_addr_to_transport` wants a `MsgTransport` per addr-type,
|
`MsgTransport` per addr-type, which `wg` doesn't have.
|
||||||
which `wg` doesn't have.
|
|
||||||
|
|
||||||
## next
|
## next
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,14 @@ Binds `tractor`'s registrar + an `echo_srv` sub-actor on the
|
||||||
tunnel's *overlay* addr, declared as a single `wg` maddr.
|
tunnel's *overlay* addr, declared as a single `wg` maddr.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from wg_maddr import (
|
from wg_maddr import (
|
||||||
parse_wg_maddr,
|
parse_wg_maddr,
|
||||||
verify_wg_key,
|
verify_wg_peer,
|
||||||
WGTunnelledAddr,
|
WGTunnelledAddr,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -37,17 +34,11 @@ async def echo(msg: str) -> str:
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
|
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
|
||||||
inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
|
assert verify_wg_peer(addr), (
|
||||||
if not await verify_wg_key(
|
f'wg pubkey from maddr not active on wg0 !\n'
|
||||||
addr,
|
f'maddr: {WG_MADDR}\n'
|
||||||
role='local',
|
f'key: {addr.peer_pubkey}\n'
|
||||||
inspection=inspection,
|
)
|
||||||
):
|
|
||||||
raise RuntimeError(
|
|
||||||
f'Maddr key is not wg0 local public key!\n'
|
|
||||||
f'maddr: {WG_MADDR}\n'
|
|
||||||
f'key: {addr.wg_pubkey}\n'
|
|
||||||
)
|
|
||||||
print(
|
print(
|
||||||
f'wg bearer (kernel-owned): {addr.bearer}\n'
|
f'wg bearer (kernel-owned): {addr.bearer}\n'
|
||||||
f'tractor overlay ep: {addr.overlay}\n'
|
f'tractor overlay ep: {addr.overlay}\n'
|
||||||
|
|
@ -60,9 +51,7 @@ async def main():
|
||||||
) as an:
|
) as an:
|
||||||
await an.start_actor(
|
await an.start_actor(
|
||||||
'echo_srv',
|
'echo_srv',
|
||||||
bind_addrs=[(addr.overlay[0], 0)],
|
enable_modules=[__name__],
|
||||||
enable_transports=[addr.overlay_proto],
|
|
||||||
enable_modules=['host_a_srv'],
|
|
||||||
)
|
)
|
||||||
print(f'echo_srv up on\n {addr.maddr}\n')
|
print(f'echo_srv up on\n {addr.maddr}\n')
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,15 @@ Host B: workstation dialing host A's actor tree through the
|
||||||
`wg` tunnel.
|
`wg` tunnel.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from host_a_srv import echo # noqa: F401 (RPC refs it by mod path)
|
from host_a_srv import echo # noqa: F401 (RPC refs it by mod path)
|
||||||
from wg_maddr import (
|
from wg_maddr import (
|
||||||
parse_wg_maddr,
|
parse_wg_maddr,
|
||||||
verify_wg_key,
|
verify_wg_peer,
|
||||||
WGTunnelledAddr,
|
WGTunnelledAddr,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -25,33 +22,23 @@ WG_MADDR: str = (
|
||||||
'/wg/u<A_pub_b64url>'
|
'/wg/u<A_pub_b64url>'
|
||||||
'/ip4/10.0.11.1/tcp/1616'
|
'/ip4/10.0.11.1/tcp/1616'
|
||||||
)
|
)
|
||||||
LOCAL_OVERLAY_BIND: tuple[str, int] = ('10.0.11.2', 0)
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
|
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
|
||||||
inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
|
assert verify_wg_peer(addr), (
|
||||||
if not await verify_wg_key(
|
f'wg pubkey from maddr not a peer on wg0 !\n'
|
||||||
addr,
|
f'maddr: {WG_MADDR}\n'
|
||||||
role='peer',
|
)
|
||||||
inspection=inspection,
|
|
||||||
):
|
|
||||||
raise RuntimeError(
|
|
||||||
f'Maddr key is not a configured wg0 peer!\n'
|
|
||||||
f'maddr: {WG_MADDR}\n'
|
|
||||||
f'key: {addr.wg_pubkey}\n'
|
|
||||||
)
|
|
||||||
async with (
|
async with (
|
||||||
tractor.open_root_actor(
|
tractor.open_root_actor(
|
||||||
name='wg_client',
|
name='wg_client',
|
||||||
tpt_bind_addrs=[LOCAL_OVERLAY_BIND],
|
|
||||||
registry_addrs=[addr.overlay],
|
registry_addrs=[addr.overlay],
|
||||||
enable_transports=[addr.overlay_proto],
|
enable_transports=[addr.overlay_proto],
|
||||||
),
|
),
|
||||||
tractor.find_actor(
|
tractor.find_actor(
|
||||||
'echo_srv',
|
'echo_srv',
|
||||||
registry_addrs=[addr.overlay],
|
registry_addrs=[addr.overlay],
|
||||||
raise_on_none=True,
|
|
||||||
) as portal,
|
) as portal,
|
||||||
):
|
):
|
||||||
res: str = await portal.run(
|
res: str = await portal.run(
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ stack "inner" reads as higher-up and later-called, whereas here
|
||||||
the encapsulated addr is bound *first* and sits deeper in the
|
the encapsulated addr is bound *first* and sits deeper in the
|
||||||
maddr — two opposite intuitions on one word.
|
maddr — two opposite intuitions on one word.
|
||||||
|
|
||||||
`/wg/u<key>` itself carries a declared Curve25519 pubkey
|
`/wg/u<key>` itself carries the tunnel peer's Curve25519 pubkey
|
||||||
as multibase base64url (std base64 from `wg(8)` contains `/` and
|
as multibase base64url (std base64 from `wg(8)` contains `/` and
|
||||||
so can't go in a `/`-delimited maddr). It binds nothing at all;
|
so can't go in a `/`-delimited maddr). It binds nothing at all;
|
||||||
it's an identity, verified out-of-band.
|
it's an identity, verified out-of-band.
|
||||||
|
|
@ -43,33 +43,28 @@ Design rules this module follows (see
|
||||||
through `.decapsulate_code()`, `.split()`, `.join()`,
|
through `.decapsulate_code()`, `.split()`, `.join()`,
|
||||||
`.encapsulate()` and `.value_for_protocol()`. We hand-roll no
|
`.encapsulate()` and `.value_for_protocol()`. We hand-roll no
|
||||||
segment splitting whatsoever — the whole point of gh #429 was
|
segment splitting whatsoever — the whole point of gh #429 was
|
||||||
dropping the NIH parser, and that applies to peeling this
|
dropping the NIH parser, and that applies to *peeling a tunnel
|
||||||
composed maddr every bit as much as to decoding one proto.
|
stack* every bit as much as to decoding a single proto.
|
||||||
- **parsing is pure**. `parse_wg_maddr()` does no I/O, no
|
- **parsing is pure**. `parse_wg_maddr()` does no I/O, no
|
||||||
`subprocess`, no netlink. A parser that shells out is a nasty
|
`subprocess`, no netlink. A parser that shells out is a nasty
|
||||||
surprise.
|
surprise.
|
||||||
- **verification is an explicit, separate step**. The caller
|
- **verification is an explicit, separate step**. The caller
|
||||||
composes `verify_wg_key()` when it has permission to inspect
|
composes `verify_wg_peer()` when it wants it; nothing implicit.
|
||||||
the iface; nothing implicit.
|
|
||||||
- **exactly one `wg` segment is supported**. `WGTunnelledAddr`
|
|
||||||
stores one bearer and one key, so accepting another segment
|
|
||||||
would silently misrepresent the maddr.
|
|
||||||
- **no new `Address` proto-type**. `wg` gets no entry in
|
- **no new `Address` proto-type**. `wg` gets no entry in
|
||||||
`tractor.discovery._addr._address_types`, which maps available
|
`tractor.discovery._addr._address_types` (a `bidict`, so 1:1
|
||||||
transport keys to concrete address types, bc it has no
|
proto-key<->type) bc it has no `MsgTransport` of its own. The
|
||||||
`MsgTransport` of its own. The
|
|
||||||
tunnel is a *bindspace*, so we carry it beside the overlay
|
tunnel is a *bindspace*, so we carry it beside the overlay
|
||||||
addr and strip to `.overlay` at bind/dial time.
|
addr and strip to `.overlay` at bind/dial time.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import base64
|
import base64
|
||||||
|
import subprocess
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from multiaddr import Multiaddr
|
from multiaddr import Multiaddr
|
||||||
import trio
|
from multiaddr.protocols import P_WG
|
||||||
|
|
||||||
|
|
||||||
IPProto = Literal['ip4', 'ip6']
|
IPProto = Literal['ip4', 'ip6']
|
||||||
|
|
@ -81,16 +76,15 @@ class WGTunnelledAddr(
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
A `wg`-tunnelled endpoint: the underlay bearer, the tunnel
|
A `wg`-tunnelled endpoint: the underlay bearer, the tunnel
|
||||||
key, and the overlay addr `tractor` binds/dials.
|
peer key, and the overlay addr `tractor` binds/dials.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
# underlay, owned by `wg(8)`/the kernel — NEVER bound by us
|
# underlay, owned by `wg(8)`/the kernel — NEVER bound by us
|
||||||
bearer: tuple[str, int]
|
bearer: tuple[str, int]
|
||||||
|
|
||||||
# declared wg pubkey in std-base64 `wg(8)` form; it is the
|
# tunnel peer pubkey in the std-base64 `wg(8)` form, i.e.
|
||||||
# local key on the bearer host and a configured peer on a dialer
|
# directly comparable to `wg show <if> peers` output
|
||||||
wg_pubkey: str
|
peer_pubkey: str
|
||||||
|
|
||||||
# overlay ep: an `UnwrappedAddress` as accepted by
|
# overlay ep: an `UnwrappedAddress` as accepted by
|
||||||
# `tractor.discovery.wrap_address()`
|
# `tractor.discovery.wrap_address()`
|
||||||
|
|
@ -114,7 +108,7 @@ class WGTunnelledAddr(
|
||||||
return (
|
return (
|
||||||
Multiaddr(f'/{self.bearer_ip}/{b_host}/udp/{b_port}')
|
Multiaddr(f'/{self.bearer_ip}/{b_host}/udp/{b_port}')
|
||||||
.encapsulate(
|
.encapsulate(
|
||||||
Multiaddr(f'/wg/{mb_pubkey(self.wg_pubkey)}')
|
Multiaddr(f'/wg/{mb_pubkey(self.peer_pubkey)}')
|
||||||
)
|
)
|
||||||
.encapsulate(
|
.encapsulate(
|
||||||
Multiaddr(
|
Multiaddr(
|
||||||
|
|
@ -139,13 +133,7 @@ def mb_pubkey(wg8_key: str) -> str:
|
||||||
|
|
||||||
'''
|
'''
|
||||||
import multibase
|
import multibase
|
||||||
|
raw: bytes = base64.b64decode(wg8_key)
|
||||||
raw: bytes = base64.b64decode(wg8_key, validate=True)
|
|
||||||
if len(raw) != 32:
|
|
||||||
raise ValueError(
|
|
||||||
f'WireGuard public keys must decode to 32 bytes, '
|
|
||||||
f'not {len(raw)}'
|
|
||||||
)
|
|
||||||
return multibase.encode('base64url', raw).decode('ascii')
|
return multibase.encode('base64url', raw).decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -155,17 +143,11 @@ def wg8_pubkey(mb_key: str) -> str:
|
||||||
|
|
||||||
'''
|
'''
|
||||||
import multibase
|
import multibase
|
||||||
|
|
||||||
raw: bytes = multibase.decode(mb_key)
|
raw: bytes = multibase.decode(mb_key)
|
||||||
if len(raw) != 32:
|
|
||||||
raise ValueError(
|
|
||||||
f'WireGuard public keys must decode to 32 bytes, '
|
|
||||||
f'not {len(raw)}'
|
|
||||||
)
|
|
||||||
return base64.b64encode(raw).decode('ascii')
|
return base64.b64encode(raw).decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
_wg_proto_known: bool | None = None
|
_wg_proto_known: bool|None = None
|
||||||
|
|
||||||
|
|
||||||
def _have_wg_maddr_proto() -> bool:
|
def _have_wg_maddr_proto() -> bool:
|
||||||
|
|
@ -185,7 +167,6 @@ def _have_wg_maddr_proto() -> bool:
|
||||||
if _wg_proto_known is None:
|
if _wg_proto_known is None:
|
||||||
from multiaddr.protocols import protocol_with_name
|
from multiaddr.protocols import protocol_with_name
|
||||||
from multiaddr.exceptions import ProtocolNotFoundError
|
from multiaddr.exceptions import ProtocolNotFoundError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
protocol_with_name('wg')
|
protocol_with_name('wg')
|
||||||
_wg_proto_known = True
|
_wg_proto_known = True
|
||||||
|
|
@ -196,7 +177,7 @@ def _have_wg_maddr_proto() -> bool:
|
||||||
|
|
||||||
|
|
||||||
def parse_wg_maddr(
|
def parse_wg_maddr(
|
||||||
maddr: str | Multiaddr,
|
maddr: str|Multiaddr,
|
||||||
) -> WGTunnelledAddr:
|
) -> WGTunnelledAddr:
|
||||||
'''
|
'''
|
||||||
Peel a `wg`-tunnelled maddr into its bearer/key/overlay
|
Peel a `wg`-tunnelled maddr into its bearer/key/overlay
|
||||||
|
|
@ -227,31 +208,18 @@ def parse_wg_maddr(
|
||||||
for seg in segs
|
for seg in segs
|
||||||
for proto in seg.protocols()
|
for proto in seg.protocols()
|
||||||
]
|
]
|
||||||
wg_count: int = names.count('wg')
|
if 'wg' not in names:
|
||||||
if not wg_count:
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'Not a `wg`-tunnelled maddr, no `/wg/` segment ??\n'
|
f'Not a `wg`-tunnelled maddr, no `/wg/` segment ??\n'
|
||||||
f'maddr: {ma}\n'
|
f'maddr: {ma}\n'
|
||||||
)
|
)
|
||||||
if wg_count > 1:
|
|
||||||
raise ValueError(
|
|
||||||
f'Nested `wg` segments are not supported; '
|
|
||||||
f'`WGTunnelledAddr` stores one tunnel only.\n'
|
|
||||||
f'maddr: {ma}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resolve the unreleased protocol only after the capability
|
|
||||||
# check, so importing this module works with released multiaddr.
|
|
||||||
from multiaddr.protocols import protocol_with_name
|
|
||||||
|
|
||||||
wg_code: int = protocol_with_name('wg').code
|
|
||||||
|
|
||||||
# NOTE, `.decapsulate_code()` cuts at the LAST occurrence of
|
# NOTE, `.decapsulate_code()` cuts at the LAST occurrence of
|
||||||
# the proto and keeps the *prefix*, which is exactly the
|
# the proto and keeps the *prefix*, which is exactly the
|
||||||
# bearer. It handles `/wg/` cleanly precisely bc it cuts on
|
# bearer. It handles `/wg/` cleanly precisely bc it cuts on
|
||||||
# proto-code and never tries to match an addr value — the
|
# proto-code and never tries to match an addr value — the
|
||||||
# key seg has no addr of its own.
|
# key seg has no addr of its own.
|
||||||
bearer_ma: Multiaddr = ma.decapsulate_code(wg_code)
|
bearer_ma: Multiaddr = ma.decapsulate_code(P_WG)
|
||||||
overlay_ma: Multiaddr = Multiaddr.join(
|
overlay_ma: Multiaddr = Multiaddr.join(
|
||||||
*segs[names.index('wg') + 1:]
|
*segs[names.index('wg') + 1:]
|
||||||
)
|
)
|
||||||
|
|
@ -291,7 +259,7 @@ def parse_wg_maddr(
|
||||||
|
|
||||||
return WGTunnelledAddr(
|
return WGTunnelledAddr(
|
||||||
bearer=bearer,
|
bearer=bearer,
|
||||||
wg_pubkey=wg8_pubkey(ma.value_for_protocol('wg')),
|
peer_pubkey=wg8_pubkey(ma.value_for_protocol('wg')),
|
||||||
overlay=overlay,
|
overlay=overlay,
|
||||||
overlay_proto=l4,
|
overlay_proto=l4,
|
||||||
bearer_ip=b_ip,
|
bearer_ip=b_ip,
|
||||||
|
|
@ -299,23 +267,13 @@ def parse_wg_maddr(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def verify_wg_key(
|
def verify_wg_peer(
|
||||||
addr: WGTunnelledAddr,
|
addr: WGTunnelledAddr,
|
||||||
role: Literal['local', 'peer'],
|
|
||||||
iface: str = 'wg0',
|
iface: str = 'wg0',
|
||||||
timeout: float = 5,
|
|
||||||
inspection: str | None = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
'''
|
'''
|
||||||
Verify the declared key in the role required on this host.
|
True iff `addr.peer_pubkey` is a configured peer (or our own
|
||||||
|
pubkey) on `iface`.
|
||||||
A bearer host uses `role='local'`; a dialer uses `role='peer'`.
|
|
||||||
This verifies only key presence. It does not inspect the peer's
|
|
||||||
endpoint, AllowedIPs, handshake state, or iface addresses.
|
|
||||||
|
|
||||||
`inspection` accepts output captured by a separate privileged
|
|
||||||
`wg show` step. Without it, query asynchronously for callers
|
|
||||||
which already have interface-inspection permission.
|
|
||||||
|
|
||||||
IMPURE + explicit by design: never called from
|
IMPURE + explicit by design: never called from
|
||||||
`parse_wg_maddr()`.
|
`parse_wg_maddr()`.
|
||||||
|
|
@ -326,23 +284,16 @@ async def verify_wg_key(
|
||||||
netns unless `netns=` is passed down.
|
netns unless `netns=` is passed down.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
match role:
|
def _wg(*args: str) -> str:
|
||||||
case 'local':
|
return subprocess.run(
|
||||||
field = 'public-key'
|
['wg', 'show', iface, *args],
|
||||||
case 'peer':
|
capture_output=True,
|
||||||
field = 'peers'
|
text=True,
|
||||||
case _:
|
check=True,
|
||||||
raise ValueError(f'Unknown WireGuard key role: {role!r}')
|
).stdout
|
||||||
|
|
||||||
if inspection is None:
|
return (
|
||||||
with trio.fail_after(timeout):
|
addr.peer_pubkey in _wg('peers').split()
|
||||||
proc = await trio.run_process(
|
or
|
||||||
['wg', 'show', iface, field],
|
addr.peer_pubkey == _wg('public-key').strip()
|
||||||
capture_stdout=True,
|
)
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
inspection = proc.stdout.decode()
|
|
||||||
|
|
||||||
if role == 'local':
|
|
||||||
return addr.wg_pubkey == inspection.strip()
|
|
||||||
return addr.wg_pubkey in inspection.split()
|
|
||||||
|
|
|
||||||
14
uv.lock
14
uv.lock
|
|
@ -273,9 +273,9 @@ name = "greenback"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "greenlet" },
|
{ name = "greenlet", marker = "python_full_version < '3.14'" },
|
||||||
{ name = "outcome" },
|
{ name = "outcome", marker = "python_full_version < '3.14'" },
|
||||||
{ name = "sniffio" },
|
{ name = "sniffio", marker = "python_full_version < '3.14'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/c1/ab3a42c0f3ed56df9cd33de1539b3198d98c6ccbaf88a73d6be0b72d85e0/greenback-1.2.1.tar.gz", hash = "sha256:de3ca656885c03b96dab36079f3de74bb5ba061da9bfe3bb69dccc866ef95ea3", size = 42597, upload-time = "2024-02-20T21:23:13.239Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/dc/c1/ab3a42c0f3ed56df9cd33de1539b3198d98c6ccbaf88a73d6be0b72d85e0/greenback-1.2.1.tar.gz", hash = "sha256:de3ca656885c03b96dab36079f3de74bb5ba061da9bfe3bb69dccc866ef95ea3", size = 42597, upload-time = "2024-02-20T21:23:13.239Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
|
@ -518,7 +518,7 @@ wheels = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multiaddr"
|
name = "multiaddr"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = { git = "https://github.com/multiformats/py-multiaddr.git?rev=f86519daaa21699023d0037c58cdff600313dd09#f86519daaa21699023d0037c58cdff600313dd09" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "base58" },
|
{ name = "base58" },
|
||||||
{ name = "dnspython" },
|
{ name = "dnspython" },
|
||||||
|
|
@ -533,6 +533,10 @@ dependencies = [
|
||||||
{ name = "trio-typing" },
|
{ name = "trio-typing" },
|
||||||
{ name = "varint" },
|
{ name = "varint" },
|
||||||
]
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/10/4e26a8577cfce1c0febc8d83087e1373e93c695c6e73ad010546fb67e229/multiaddr-0.2.0.tar.gz", hash = "sha256:acb6b25c332ec1b2f1f8fef8d03a8c63385d34a87d690df0f4bba43cdf6efe8d", size = 58356, upload-time = "2026-03-17T21:51:00.274Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/13/56e503d01218d1ca27ea9fda862045a4b400cae5e756f47315f5aaba0eee/multiaddr-0.2.0-py3-none-any.whl", hash = "sha256:bcff7bf3d7de3d6da0b865b25423bcb411de1d20d70cc6abfacf75170d17866c", size = 40424, upload-time = "2026-03-17T21:50:58.833Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mypy-extensions"
|
name = "mypy-extensions"
|
||||||
|
|
@ -1189,7 +1193,7 @@ requires-dist = [
|
||||||
{ name = "bidict", specifier = ">=0.23.1" },
|
{ name = "bidict", specifier = ">=0.23.1" },
|
||||||
{ name = "colorlog", specifier = ">=6.8.2,<7" },
|
{ name = "colorlog", specifier = ">=6.8.2,<7" },
|
||||||
{ name = "msgspec", specifier = ">=0.20.0" },
|
{ name = "msgspec", specifier = ">=0.20.0" },
|
||||||
{ name = "multiaddr", git = "https://github.com/multiformats/py-multiaddr.git?rev=f86519daaa21699023d0037c58cdff600313dd09" },
|
{ name = "multiaddr", specifier = ">=0.2.0" },
|
||||||
{ name = "pdbp", specifier = ">=1.8.2,<2" },
|
{ name = "pdbp", specifier = ">=1.8.2,<2" },
|
||||||
{ name = "platformdirs", specifier = ">=4.4.0" },
|
{ name = "platformdirs", specifier = ">=4.4.0" },
|
||||||
{ name = "setproctitle", specifier = ">=1.3,<2" },
|
{ name = "setproctitle", specifier = ">=1.3,<2" },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue