Compare commits

...

12 Commits

Author SHA1 Message Date
Gud Boi 0d95a9e63c Emit canonical tagged addresses
- Make `TCPAddress.unwrap()` emit `('tcp', host, port)` and
  `UDSAddress.unwrap()` emit `('unix', path)` while retaining the
  compatibility readers from the preceding change.

- Pass concrete TCP fields to Trio, compose multiaddrs from tagged
  values, and let `SpawnSpec` carry protocol-specific tuple shapes
  for validation by `wrap_address()`.

- Compare runtime, registry, bind, and tunnel addresses through
  canonical serialized forms and cover both TCP and UDS operation.

Prompt-IO: ai/prompt-io/opencode/20260820T033108Z_ba07e09d_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 15:47:28 -04:00
Gud Boi c857ad09f2 Decode tagged transport addresses
- Define canonical `tcp` and `unix` tuple shapes while retaining
  legacy pair aliases as the emitted `UnwrappedAddress`.

- Dispatch tagged tuple/list payloads explicitly, accept `uds` as a
  Unix input alias, and preserve legacy TCP, UDS, and native IPv6
  readers.

- Cover tag aliases, msgpack-style lists, legacy payloads, and IPv6
  socket addresses before switching writers.

Prompt-IO: ai/prompt-io/opencode/20260820T033107Z_ba07e09d_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 15:47:28 -04:00
Gud Boi 46a2daff23 Model bindspaces as scoped capabilities
Separate serializable bindspace declarations from live namespace
identity, FDs, ownership and teardown resources.

Require child namespace entry during spawn bootstrap, before actor
runtime initialization, then distinguish listen/dial provisioning and
owned/borrowed cleanup without encoding operation role into maddrs.

Prompt-IO: ai/prompt-io/opencode/20260820T021516Z_dfad66a0_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 15:47:26 -04:00
Gud Boi ffb87d351e Peel tunnels before `Endpoint` binding
Carry tunnel declarations through listener configuration, then strip
them immediately before constructing transport endpoints.

Also allocate random listener addresses from a contacted registry's
overlay, and prove a real TCP listener never stores the wrapper while
the source declaration retains its bindspace metadata.

Prompt-IO: ai/prompt-io/opencode/20260819T213145Z_f81fc5e5_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 15:21:18 -04:00
Gud Boi b517f7f523 Peel tunnels before `Channel` connects
Retain tunnel annotations through address declaration, then hand only
the bindable overlay to exact-type transport lookup and dialing.

Broaden `Channel.from_addr()` and `_connect_chan()` inputs accordingly,
and cover plain plus tunnelled TCP dispatch arguments.

Prompt-IO: ai/prompt-io/opencode/20260819T213144Z_f81fc5e5_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 15:21:18 -04:00
Gud Boi 22e154900b Use discovery's `wg` parser in examples
Drop the example-local address struct and hand-rolled single-tunnel
parser now that discovery owns the production implementation.

Keep only the explicit `wg(8)` peer probe in the multihost helper,
and update the examples and plan for nested parsing, packaged codec
dependencies and tractor-owned bindspace provisioning.

Prompt-IO: ai/prompt-io/opencode/20260818T075031Z_dd02c7c0_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 15:21:16 -04:00
Gud Boi a26a0a48b6 Support nested `wg` maddrs
Teach discovery to preserve WireGuard bearer and identity metadata
around a bindable TCP overlay.

Deats,
- encode `wg(8)` keys as strict 32-byte multibase values
- peel nested stacks with `Multiaddr.decapsulate_code()` and compose
  them with `.encapsulate()` instead of splitting strings
- integrate wrappers with `parse_maddr()`, `mk_maddr()`,
  `wrap_address()` and `parse_endpoints()`
- pin the unreleased py-multiaddr#108 codec in package metadata
- cover exact round trips, nesting, bad grammar and missing codecs

Prompt-IO: ai/prompt-io/opencode/20260818T075031Z_dd02c7c0_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 13:31:56 -04:00
Gud Boi cc1bb333e6 Add `TunnelledAddress` wrapper primitives
Introduce the first layer-A address type from the `wg` bindspace
plan without treating a transparent tunnel as a `MsgTransport`.

Deats,
- add frozen `WGTunnelSpec` and `TunnelledAddress` structs which
  delegate proto identity, bindspace, validity and wire
  serialization to their overlay
- add `strip_tunnels()` and `tunnels_of()` for nested wrappers
- recognize wrappers in `is_wrapped_addr()` while keeping them out
  of `_address_types`
- cover delegation, namespace fallback and nested peeling semantics

Also,
- widen `Address.namespace` ids for named netns
- export the new discovery API
- clarify that tractor's layer-C bindspace lifecycle may provision
  the kernel-owned bearer without making it a `MsgTransport`

Prompt-IO: ai/prompt-io/opencode/20260818T021729Z_d9a6e2e9_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 13:31:56 -04:00
Bd 0c52f2770a
Merge pull request #492 from goodboy/ng_tpts_planning
Add impl plans for `TIPC`/`QUIC`/`wg` tpt backends
2026-08-31 22:26:01 -04:00
Gud Boi e4e27ec71d Harden the wg LAN example for real hosts
Validate WireGuard keys and tunnel descriptors strictly. Inspect
iface keys asynchronously by local/peer role and reject unsupported
nested tunnels.

Correct both host binds and service publication, document an
unprivileged two-host setup and pin the merged `py-multiaddr` codec
revision in the lock.

Prompt-IO: ai/prompt-io/opencode/20260831T022317Z_768b5316_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-31 12:28:19 -04:00
Gud Boi 7c2de6c359 Reconcile tpt plans with runtime contracts
Bring the shared backend contract and TIPC plan in line with
current runtime behavior and downstream implementation evidence.

Rework the QUIC plan around actor-owned endpoint, bootstrap,
stream, listener and cleanup lifecycles, with explicit validation
gates for the still-unverified UniFFI details.

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-31 12:27:43 -04:00
Gud Boi 83e4af169c Pin tpt prompt provenance to immutable refs
Replace mutable branch pointers with the exact source commit and
historical base-to-commit range. Normalize the substantive scope
while leaving the recorded raw response unchanged.

Prompt-IO: ai/prompt-io/claude/20260813T001102Z_27c34aeb_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-31 12:27:23 -04:00
49 changed files with 3467 additions and 1044 deletions

View File

@ -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: 27c34aeb git_ref: 27c34aebb615c30d4039fa399f4ce2766ed7ba2c
scope: docs+code scope: code
substantive: true substantive: true
raw_file: 20260813T001102Z_27c34aeb_prompt_io.raw.md raw_file: 20260813T001102Z_27c34aeb_prompt_io.raw.md
--- ---

View File

@ -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: 27c34aeb git_ref: 27c34aebb615c30d4039fa399f4ce2766ed7ba2c
diff_cmd: git diff main..ng_tpts_planning diff_cmd: git diff 3ad7e7e5dc28aa336499725a03744fc10c6c0026..27c34aebb615c30d4039fa399f4ce2766ed7ba2c
--- ---
# Raw output — next-gen tpt-backend implementation plans # Raw output — next-gen tpt-backend implementation plans
## Generated planning docs ## Generated planning docs
> `git diff main..ng_tpts_planning -- ai/tpt-backends/` > `git diff 3ad7e7e5dc28aa336499725a03744fc10c6c0026..27c34aebb615c30d4039fa399f4ce2766ed7ba2c -- 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 main..ng_tpts_planning -- examples/multihost/wg_lan/` > `git diff 3ad7e7e5dc28aa336499725a03744fc10c6c0026..27c34aebb615c30d4039fa399f4ce2766ed7ba2c -- 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`,

View File

@ -0,0 +1,69 @@
---
model: gpt-5.6-sol
service: opencode
session: intercepted-claude-7b9c97c4-fff7-4ac4-97fb-35720453308e
timestamp: 2026-08-18T02:17:29Z
git_ref: d9a6e2e9
scope: code
substantive: true
raw_file: 20260818T021729Z_d9a6e2e9_prompt_io.raw.md
---
## Prompt
> Intercept Claude session
> `7b9c97c4-fff7-4ac4-97fb-35720453308e`, pick up where it
> stopped in its open worktree, finish the tunnelled-address change
> and commit plan, and prepare any outstanding context for another
> provider.
The recovered final prompt specifically called out the `uds` versus
`unix` boundary decision, absent concrete `Address.namespace`
implementations, the corrected namespace test, and the passing
focused and wider suites.
During review, the human further required that tunnelled-maddr work
delegate to `multiaddr`'s encapsulation APIs, challenged the premature
transport-shaped listener hooks and directed their removal, corrected
the long-term bearer provisioning model, and confirmed the intended
split between discovery metadata and bindspace lifecycle code.
## Response summary
Recovered the transcript and matched it to `wkts/addr_unpacking`,
audited the staged implementation, and completed the interrupted
verification and commit-plan work. The audit removed premature
transport-shaped listener hooks, widened the namespace identifier
type, updated stale import documentation, and removed an
invalid-escape warning from the maddr diagram. It also preserved the
layer-C design where tractor provisions the kernel-owned tunnel
bearer without treating it as a message transport.
## Files changed
- `tractor/discovery/_tunnel.py` - tunnel specs, address wrapper, and
peeling helpers.
- `tractor/discovery/_addr.py` - wrapped-address recognition and
namespace typing.
- `tractor/discovery/__init__.py` - public tunnel API exports.
- `tests/discovery/test_tunnelled_addr.py` - delegation and boundary
regression coverage.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - distinguish
tractor-owned bindspace provisioning from kernel socket ownership.
## Human edits
Substantial human-directed editing occurred over several review turns:
- required use of `multiaddr`'s `.encapsulate()`/`.decapsulate()`
family rather than a hand-rolled tunnel peeler
- rejected the premature `start_listener()`/`close_listener()` hooks
and directed their removal from this foundational change
- corrected the documentation so tractor retains ownership of future
bindspace provisioning while the kernel owns the bearer socket
- reviewed and accepted the placement of declarative tunnel metadata
under `tractor.discovery`, with lifecycle code kept separate
The final source lines were applied through the coding agents, but
these design corrections and deletion decisions came from the human
review and materially shaped the patch.

View File

@ -0,0 +1,66 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-18T02:17:29Z
git_ref: d9a6e2e9
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - tunnelled-address handoff completion
Recovered Claude Code session
`7b9c97c4-fff7-4ac4-97fb-35720453308e` and continued its
interrupted `wkts/addr_unpacking` changes.
## Generated code
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py`
Added frozen `WGTunnelSpec` and `TunnelledAddress` structs. The
wrapper delegates transport identity, validity, bindspace, and wire
serialization to its overlay while retaining tunnel metadata locally.
Added pure helpers to peel nested wrappers and enumerate their tunnel
specs. The module documents why wrappers must be peeled before
`Endpoint` selects the overlay transport backend.
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py`
Extended `is_wrapped_addr()` to recognize `TunnelledAddress` without
registering tunnels as message transports, and widened the namespace
identifier type to cover named network namespaces.
> `git diff HEAD~1..HEAD -- tractor/discovery/__init__.py`
Exported the tunnel address API from `tractor.discovery` and updated
the eager-import documentation.
> `git diff HEAD~1..HEAD -- tests/discovery/test_tunnelled_addr.py`
Added focused coverage for delegation, serialization, rewrapping,
namespace fallback, nested peeling order, and frozen structs.
> `git diff HEAD~1..HEAD -- ai/tpt-backends/03_wg_tunnel_bindspace.md`
Clarified that tractor owns the eventual bindspace lifecycle and may
provision the WireGuard iface, routes, and kernel UDP listener through
netlink/`pyroute2`. Kernel socket ownership does not make the bearer an
application `MsgTransport` endpoint.
## Verification
Focused tests:
```text
9 passed in 0.03s
```
Discovery and IPC suites:
```text
67 passed, 2 xpassed
```
The audit removed premature module-level listener hooks. Runtime
integration must peel the wrapper explicitly at bind and dial
boundaries rather than make `._tunnel` impersonate a transport
backend.

View File

@ -0,0 +1,50 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking-followup
timestamp: 2026-08-18T07:50:31Z
git_ref: dd02c7c0
scope: code
substantive: true
raw_file: 20260818T075031Z_dd02c7c0_prompt_io.raw.md
---
## Prompt
The human requested the complete tunnelled-maddr parsing/composition
slice as an unattended batch, with every numbered requirement finished
and atomic commit plans prepared at the end. Existing human decisions
required native `multiaddr` encapsulation APIs, no hand-written peeler,
and preservation of tractor's future bindspace lifecycle ownership.
## Response summary
Implemented strict WG key codecs and native single/nested tunnel maddr
parsing and composition, integrated them into discovery APIs, migrated
the multihost example off its duplicate parser, corrected package
dependency metadata, and added focused and end-to-end parser
regressions. Verified the complete tractor suite and built both package
artifacts.
## Files changed
- `tractor/discovery/` - WG codecs, parser/composer, wrapper typing,
public exports, and discovery dispatch.
- `tests/discovery/` - key, grammar, nesting, round-trip, and public
boundary regressions.
- `examples/multihost/wg_lan/` - production parser migration and
updated usage documentation.
- `pyproject.toml`, `uv.lock` - reproducible WG codec and multibase
dependencies for checkout and package installs.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - current layer-A state
and future bindspace ownership.
## Human edits
The human selected the five-step scope and batch execution model,
required delegation to `multiaddr`'s encapsulation APIs, rejected
transport-shaped listener placeholders in the prerequisite commit, and
clarified that tractor will eventually provision the kernel-owned
bearer through its bindspace layer. The agent implemented and tested
those decisions; no direct manual source edits were observed during
this batch.

View File

@ -0,0 +1,61 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-18T07:50:31Z
git_ref: dd02c7c0
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - native WireGuard maddr integration
The human requested completion of the five-step tunnelled-maddr slice:
port the proven WireGuard parser, delegate to `py-multiaddr`'s native
tunnel APIs, integrate public parse and composition entry points, add
regressions, and return atomic commit plans after completing the batch.
## Generated code
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/_multiaddr.py tractor/discovery/_addr.py tractor/discovery/__init__.py`
Added strict WireGuard standard-base64/multibase key codecs and native
WG maddr parsing/composition. Nested stacks peel the last `/wg/`
repeatedly with `.decapsulate_code()`, isolate segments through
`.split()`/`.join()`, and compose recursively with `.encapsulate()`.
Public discovery parsing, wrapping, endpoint-table parsing, and maddr
composition now preserve `TunnelledAddress` metadata locally.
> `git diff HEAD~1..HEAD -- pyproject.toml uv.lock`
Declared `py-multibase` directly and pinned the unreleased
py-multiaddr#108 WG codec revision in distribution metadata. Enabled
Hatch direct-reference metadata so editable, wheel, and sdist builds
use the same dependency contract.
> `git diff HEAD~1..HEAD -- tests/discovery/test_multiaddr.py tests/discovery/test_tunnelled_addr.py`
Added key-codec, single/nested WG round-trip, malformed key, invalid
bearer/overlay, missing codec, missing bearer, `wrap_address()`, and
`parse_endpoints()` coverage while retaining plain TCP/UDS regressions.
> `git diff HEAD~1..HEAD -- examples/multihost/wg_lan ai/tpt-backends/03_wg_tunnel_bindspace.md`
Removed the duplicate example parser, migrated callers to production
`TunnelledAddress`, retained only explicit `wg(8)` verification, and
updated dependency and future bindspace ownership documentation.
## Verification
- focused discovery files: `45 passed`
- discovery and IPC suites: `81 passed, 2 xpassed`
- full suite: `443 passed, 9 skipped, 7 xfailed, 4 xpassed`
- collection: `462 tests`
- Ruff: clean
- sdist and wheel builds: successful
## Review corrections
A review caught that `[tool.uv.sources]` alone would not constrain
built-package consumers to a WG-capable py-multiaddr revision. The
dependency was moved into PEP 621 metadata and the parser now converts
missing-codec failures into an actionable tractor error without
misclassifying Unix paths containing a `wg` directory.

View File

@ -0,0 +1,37 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking-followup
timestamp: 2026-08-19T21:31:44Z
git_ref: f81fc5e5
scope: code
substantive: true
raw_file: 20260819T213144Z_f81fc5e5_prompt_io.raw.md
---
## Prompt
The human requested runtime boundary integration with the established
per-step implementation and commit-plan workflow.
## Response summary
Kept `TunnelledAddress` available to callers while peeling it at the
last outbound boundary before transport lookup and dialing. Added a
regression which captures both transport arguments and confirms plain
TCP behavior is unchanged.
## Files changed
- `tractor/ipc/_chan.py` - peel tunnel annotations before outbound
transport dispatch and connection.
- `tests/ipc/test_channel_tunnel_boundary.py` - verify plain and
tunnelled channel inputs deliver only TCP overlays.
## Human edits
The human chose the runtime-boundary slice, required the existing
per-step commit-plan flow, and previously established that wrappers
must retain bindspace metadata without impersonating transports. The
agent implemented those constraints; no direct manual source edits were
observed during this step.

View File

@ -0,0 +1,25 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-19T21:31:44Z
git_ref: f81fc5e5
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - outbound tunnel boundary
The human requested the next tunnelled-address slice using the same
per-step commit-plan flow. Existing design decisions require retaining
tunnel metadata until the narrow IPC transport boundary and never
teaching exact-type transport tables about tunnel wrappers.
> `git diff HEAD~1..HEAD -- tractor/ipc/_chan.py tests/ipc/test_channel_tunnel_boundary.py`
Extended channel address inputs to accept tunnel declarations, then
called `strip_tunnels()` immediately before exact-type transport lookup
and `connect_to()`. Added plain/tunnel parameterized coverage proving
both operations receive the identical TCP overlay while the original
wrapper retains its tunnel spec.
Verification included focused IPC tests, Ruff, discovery/IPC suites,
and the full tractor suite.

View File

@ -0,0 +1,40 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking-followup
timestamp: 2026-08-19T21:31:45Z
git_ref: f81fc5e5
scope: code
substantive: true
raw_file: 20260819T213145Z_f81fc5e5_prompt_io.raw.md
---
## Prompt
The human requested completion of inbound runtime peeling using the
same per-step implementation and commit-plan workflow.
## Response summary
Preserved tunnel declarations through listener configuration, peeled
them immediately before `Endpoint` construction, and used the overlay
for backend-specific random listener allocation after registry
discovery. Added a real listener regression for the reflection and
exact-type boundary.
## Files changed
- `tractor/ipc/_server.py` - accept wrapper declarations and peel at
`Endpoint` construction.
- `tractor/_root.py` - allocate random transport addresses from the
contacted registry's overlay.
- `tests/ipc/test_server_tunnel_boundary.py` - verify a real listener
stores only TCP while preserving the source annotation.
## Human edits
The human selected the runtime-boundary work and previously corrected
the architecture so tractor retains future bindspace provisioning
ownership while `Endpoint` sees only application transports. The agent
implemented and tested that direction; no direct manual source edits
were observed during this step.

View File

@ -0,0 +1,25 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-19T21:31:45Z
git_ref: f81fc5e5
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - inbound tunnel boundary
The human requested runtime boundary integration while preserving the
future tractor-owned bindspace lifecycle.
> `git diff HEAD~1..HEAD -- tractor/ipc/_server.py tractor/_root.py tests/ipc/test_server_tunnel_boundary.py`
Broadened listener declarations to carry tunnel wrappers until
`_serve_ipc_eps()` and peeled immediately before `Endpoint`
construction. Also peeled a contacted tunnelled registry before
backend-specific random listener allocation. Added a real TCP listener
regression proving `Endpoint` stores only the resolved overlay while
the original declaration retains bindspace metadata.
Verification included `465` collected tests, `84` passing
discovery/IPC tests with two xpasses, Ruff, and the full suite with
`447` passes.

View File

@ -0,0 +1,41 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking-followup
timestamp: 2026-08-20T02:15:16Z
git_ref: dfad66a0
scope: docs
substantive: true
raw_file: 20260820T021516Z_dfad66a0_prompt_io.raw.md
---
## Prompt
The human requested that the bindspace plan preserve the agreed
capability, spawn-bootstrap, endpoint-role, namespace augmentation,
random-address, and teardown semantics, using `github/ns_aware` as
prototype input.
## Response summary
Updated plan-03 and the shared backend contract to separate serializable
bindspace declarations from scoped live capabilities, make namespace
entry a pre-runtime spawn operation, keep maddr paths role-neutral, and
define listen/dial provisioning plus ownership-sensitive teardown.
## Files changed
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - layer-C capability,
bootstrap, role, teardown, test, and risk model.
- `ai/tpt-backends/00_shared_backend_contract.md` - distinguish
transport bind selectors from process namespace lifecycle.
## Human edits
The human supplied the core architecture: structured scoped
capabilities, spawn-time namespace entry, orthogonal namespace
augmentation, source/destination-dependent provisioning, and
role-dependent teardown. They also rejected premature assumptions about
`open_bindspace()` returning an address and requested grounding in the
existing namespace prototype. The agent translated those decisions into
the plan text; no direct manual source edits were observed.

View File

@ -0,0 +1,34 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-20T02:15:16Z
git_ref: dfad66a0
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - bindspace capability design
The human corrected the layer-C design around local network-stack
realization. They established that bindspace state should be both
structured and a scoped capability; namespace entry belongs in
subactor bootstrap; maddrs can describe source or destination network
paths while namespace selection augments them orthogonally; random
address and teardown behavior depend on operation role and ownership.
They directed comparison with the prototype on `github/ns_aware` and
requested these decisions be preserved in the plan.
> `git diff HEAD~1..HEAD -- ai/tpt-backends/03_wg_tunnel_bindspace.md ai/tpt-backends/00_shared_backend_contract.md`
Reworked layer C around serializable `BindspaceSpec`, stable
`BindspaceIdentity`, and scoped non-serializable `BindspaceHandle`
concepts. Namespace FDs pin identity and lifetime; parent/supervisor
provisioning transfers entry capability through spawn; the child enters
before runtime, channels, listeners, sockets, or worker threads and then
drops authority. Listen/dial roles and owned/borrowed teardown are
explicit, while maddrs remain role-neutral network-path declarations.
The shared backend contract now separates transport-level `.bindspace`
selectors from process namespace lifecycle. Added tests/risks for FD
identity, bootstrap ordering, privilege drop, role ownership, and
shared-resource teardown.

View File

@ -0,0 +1,41 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking
timestamp: 2026-08-20T03:31:07Z
git_ref: ba07e09d
scope: code
substantive: true
raw_file: 20260820T033107Z_ba07e09d_prompt_io.raw.md
---
## Prompt
The human requested canonical tagged transport addresses with a
reader-first migration. TCP should decode `('tcp', host, port)`, Unix
should decode `('unix', path)`, `uds` should remain an accepted input
alias and internal transport key, and legacy tuple/list inputs must keep
working before writers switch formats.
## Response summary
Introduced canonical and compatibility address aliases, explicit tagged
dispatch, transport-specific tagged readers, and focused serialization
tests. Kept legacy pair inputs and native IPv6 socket values readable so
this boundary can ship before tagged emission.
## Files changed
- `tractor/discovery/_addr.py` - address aliases and tagged dispatch.
- `tractor/ipc/_tcp.py` - tagged, legacy, and IPv6 TCP decoding.
- `tractor/ipc/_uds.py` - canonical Unix and UDS-alias decoding.
- `tests/discovery/test_address_serialization.py` - reader compatibility
coverage.
## Human edits
The human supplied the canonical `tcp` and `unix` forms, chose `uds` as
an input-only serialization alias while preserving it as the runtime
transport key, and required a reader-first commit boundary. The agent
implemented those decisions; no direct manual source edits were
observed.

View File

@ -0,0 +1,25 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-20T03:31:07Z
git_ref: ba07e09d
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - tagged address readers
The human requested a migration away from ambiguous untagged transport
tuples. They established `('tcp', host, port)` and `('unix', path)` as
canonical forms, retained `('uds', path)` as an input alias, and required
a reader-first compatibility boundary before changing emitted values.
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py tractor/ipc/_tcp.py tractor/ipc/_uds.py tests/discovery/test_address_serialization.py`
Added explicit tagged address aliases and dispatch, taught TCP and UDS
readers to decode tagged tuple/list payloads, preserved legacy pair input,
and retained native IPv6 socket-address decoding. Added focused tests for
canonical tags, the UDS alias, msgpack-style lists, legacy pairs, and IPv6
socket values.
Focused reader tests and Ruff checks passed before the writer migration
was applied.

View File

@ -0,0 +1,45 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking
timestamp: 2026-08-20T03:31:08Z
git_ref: ba07e09d
scope: code
substantive: true
raw_file: 20260820T033108Z_ba07e09d_prompt_io.raw.md
---
## Prompt
The human asked the agent to continue after adding tagged readers,
complete canonical address emission without dropping legacy input
compatibility, verify the migration, and prepare a complete multi-commit
package when the turn was done.
## Response summary
Changed `.unwrap()` to emit tagged TCP and Unix addresses, updated direct
tuple consumers and spawn payload declarations, and aligned multiaddr,
runtime, IPC, and discovery tests with canonical serialized equality.
Kept untagged tuples and the `uds` spelling readable at input boundaries.
## Files changed
- `tractor/discovery/_addr.py` - canonical output alias.
- `tractor/discovery/_multiaddr.py` - tagged address composition.
- `tractor/ipc/_tcp.py` - tagged emission and direct socket dialing.
- `tractor/ipc/_uds.py` - tagged full-path emission.
- `tractor/msg/types.py` - protocol-neutral spawn tuple containers.
- `tests/discovery/test_address_serialization.py` - writer assertions.
- `tests/discovery/test_multiaddr.py` - canonical round-trip assertions.
- `tests/discovery/test_tpt_bind_addrs.py` - tagged bind assertions.
- `tests/ipc/test_each_tpt.py` - canonical runtime address assertions.
- `tests/ipc/test_server_tunnel_boundary.py` - tagged TCP destructuring.
- `tests/test_local.py` - canonical registry comparison.
## Human edits
The human established the reader-before-writer sequencing, canonical tag
spellings, retained compatibility expectations, and requested final
multi-commit packaging. The agent implemented and tested those choices;
no direct manual source edits were observed.

View File

@ -0,0 +1,26 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-20T03:31:08Z
git_ref: ba07e09d
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - canonical tagged address writers
After the reader compatibility boundary, the human asked the agent to
continue the migration and package the completed work as dependency-
ordered commits.
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py tractor/discovery/_multiaddr.py tractor/ipc/_tcp.py tractor/ipc/_uds.py tractor/msg/types.py tests/discovery/test_address_serialization.py tests/discovery/test_multiaddr.py tests/discovery/test_tpt_bind_addrs.py tests/ipc/test_each_tpt.py tests/ipc/test_server_tunnel_boundary.py tests/test_local.py`
Switched TCP and Unix `.unwrap()` output to canonical tagged tuples,
updated direct transport and multiaddr consumers, widened spawn message
tuple containers for protocol-specific shapes, and migrated runtime and
test comparisons to serialized address equality. Legacy inputs remain
accepted at `wrap_address()` and backend reader boundaries.
Ruff and focused tests passed. The complete non-debugger TCP suite passed
with 412 tests; the UDS suite reached 80% without failure before the
harness timeout, then all 97 remaining tests passed on resume. Debugger
PTY coverage was excluded after an unrelated timeout.

View File

@ -0,0 +1,45 @@
---
model: gpt-5.6-sol
service: opencode
session: addr-unpacking-rebase-conflict
timestamp: 2026-08-28T00:38:05Z
git_ref: e40c39da
scope: code
substantive: true
raw_file: 20260828T003805Z_e40c39da_prompt_io.raw.md
---
## Prompt
The human asked the agent to resolve the first conflict in the
`wkt/addr_unpacking` rebase because the replayed history was primarily
agent-generated.
## Response summary
Preserved the lazy-import and platform-aware behavior from the new
base while restoring the replayed WireGuard address parsing and
composition behavior. Reconciled the changed dependency lock with the
replayed direct dependency contract and validated the combined result.
## Files changed
- `tractor/discovery/_addr.py` - combined address-platform and tunnel
typing changes.
- `tractor/discovery/_multiaddr.py` - combined lazy imports with WG
parse and composition dispatch.
- `tractor/discovery/_tunnel.py` - retained cold-import behavior for
the newly added WG implementation.
- `tests/discovery/test_multiaddr.py` - adapted missing-codec
monkeypatching to the lazily imported upstream protocol module.
- `uv.lock` - restored the WG-capable multiaddr source and direct
multibase metadata.
## Human edits
The human chose to abort the first rebase attempt, restart with an
explicit old-base boundary, and delegated the conflict resolution to
the agent. The human also chose not to preserve the superseded
module-level protocol-lookup test seam; the agent redirected the test
to the upstream protocol module. No direct manual source edits were
observed during this resolution.

View File

@ -0,0 +1,32 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-28T00:38:05Z
git_ref: e40c39da
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - addr-unpacking rebase resolution
The human delegated resolution of the first conflict while rebasing
`wkt/addr_unpacking` from `d9a6e2e9` onto `85a44588`.
## Generated code
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py tractor/discovery/_multiaddr.py tractor/discovery/_tunnel.py`
Combined the rebased base's platform-aware address registries and lazy
optional-dependency imports with the replayed `TunnelledAddress` typing
and WireGuard multiaddr dispatch. Adapted the new tunnel module so
importing tractor does not eagerly load `multiaddr`.
> `git diff HEAD~1..HEAD -- uv.lock`
Regenerated the lockfile to retain the replayed WG-capable
`py-multiaddr` revision and direct `py-multibase` dependency after the
new base's independently changed lockfile silently omitted that hunk.
## Verification
Conflict-marker, syntax, lock consistency, lint, collection, focused
discovery, and cold-import checks were selected for the resolution.

View File

@ -0,0 +1,72 @@
---
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.

View File

@ -0,0 +1,60 @@
---
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.

View File

@ -33,19 +33,35 @@ 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/`.
exposing exactly four things. There is no ABC to subclass and no There is no ABC to subclass and no plugin entrypoint; wiring is by
plugin entrypoint; wiring is by explicit table registration explicit table registration (§2) plus one piece of reflection
(§2) plus one piece of reflection (§1.3). (§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)`
Structurally conforms to the `Address` `Protocol` in The runtime-consumed address-wrapper surface is:
`tractor/discovery/_addr.py:82`. Required surface:
| member | kind | notes | | member | kind | notes |
| --- | --- | --- | | --- | --- | --- |
| `proto_key` | `ClassVar[str]` | the wire/registry key, e.g. `'tcp'`, `'uds'` | | `proto_key` | `ClassVar[str]` | internal transport key, e.g. `'tcp'`, `'uds'` |
| `unwrapped_type` | `ClassVar[type]` | the primitive tuple shape | | `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" |
@ -81,22 +97,32 @@ 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 backends: make the unwrapped form carry an explicit internal
proto-key, using the `multiaddr` protocol name as the proto-key** — `('tcp', host, port)`,
canonical spelling** — `('tcp', host, port)`, `('uds', filedir, filename)`, `('tipc', stype, inst, scope)`.
`('unix', path)`, `('udp', ...)`, `('tipc', stype, inst, The tag must be a `TransportProtocolKey`/registry key. In
scope)`. Then `wrap_address()` collapses from an particular it is **`'uds'`, not the external multiaddr spelling
order-sensitive `match` to `_address_types[addr[0]]`, and the `'unix'`**. If a wire or display format uses a different name,
whole collision class stops existing. Note this *also* aligns name that translation explicitly; today `_multiaddr.py` maps
the on-wire form with `mk_maddr()`/`parse_maddr()`, so the two internal `uds` to external `/unix/`. Then `wrap_address()` can
representations stop being independent inventions. dispatch through `_address_types[addr[0]]` without an
order-sensitive shape match.
Two consequences to plan for: Two consequences to plan for:
- it's a **wire-format change** (`SpawnSpec`, - it's a **wire-format change**. Widen and keep synchronized
`_root_mailbox`, `_registry_addrs`) plus every test fixture `discovery._addr.UnwrappedAddress` and the duplicate wire
and downstream config (`piker`'s `[network]` table). It alias in `msg.types`; change `SpawnSpec.reg_addrs` and
wants its **own migration commit, landed before any new `.bind_addrs`, not only `_root_mailbox` and
backend**, not smuggled into one. `_registry_addrs`. Audit the related `RuntimeVars`
`_root_mailbox`/`_root_addrs` annotations, `Actor.reg_addrs`
and accept-address annotations, channel/spawn signatures,
fixtures, and downstream config (`piker`'s `[network]`
table). `msgspec` rejects a union containing multiple
array-like tuple shapes, so #493 used
`tuple[str|int, ...]` as the truthful transitional wire type;
the complete proto-key migration can restore per-proto
validation. This wants its **own migration commit, landed
before any new backend**, not smuggled into one.
- it's the moment to **stop handing raw unwrapped tuples to - 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
@ -104,15 +130,20 @@ 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 be collision-free without a live - **`.get_random()` must not deterministically alias without a
runtime.** See the `UDSAddress.get_random()` uuid-token live 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.
- **`.bindspace` semantics**: "the address' bindable space" — - **`.bindspace` semantics**: "the address' bindable space" —
ip/host for `tcp`, the socket-file *directory* for `uds`. For ip/host for `tcp`, the socket-file *directory* for `uds`. For
the new backends: the TIPC *scope* (§1 of plan 01), the iroh the new backends: the TIPC *scope* (§1 of plan 01), the iroh
*ALPN + relay/discovery realm* (plan 02), the netns (plan 03). *ALPN + relay/discovery realm* (plan 02). Do not overload this
transport-level bind selector with process namespace lifecycle.
Plan 03 augments an maddr/address declaration with a serializable
`BindspaceSpec` and a scoped, non-serializable `BindspaceHandle`;
the latter owns namespace identity/FD/lifetime and is consumed at
spawn bootstrap before a concrete address reaches transport bind.
`Address.namespace` is already spec'd in the Protocol as `Address.namespace` is already spec'd in the Protocol as
"the if-available OS-specific network namespace key" and is "the if-available OS-specific network namespace key" and is
currently unimplemented by both backends — plan 03 is the currently unimplemented by both backends — plan 03 is the
@ -162,10 +193,11 @@ 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. This is fine for return value is a valid `from_addr()` input. That is false for
TIPC (§3 of plan 01) and **is the main integration hazard for TIPC, whose listener sockname is an undialable port ID, and for
iroh** (§3 of plan 02) — plans that break it must say so non-socket iroh. Both plans must use the explicit backend rebind
explicitly and propose the upstream `_server.py` patch. policy added at this integration point rather than pretending a
sockname is always an address replacement.
### 1.4 `class Msgpack<Proto>Stream(MsgpackTransport)` ### 1.4 `class Msgpack<Proto>Stream(MsgpackTransport)`
@ -224,20 +256,26 @@ path.** This is why plan 01 is small and plan 02 is not.
--- ---
## 2. Registration tables (the full wiring checklist) ## 2. Registration and policy wiring
Adding a backend touches these and only these: Adding a backend requires this complete audit. Not every item
changes for every backend, but none may be assumed from the others:
1. `tractor/runtime/_state.py:46` 1. `tractor/runtime/_state.py:46`
`TransportProtocolKey = Literal['tcp', 'uds', ...]` — add the `TransportProtocolKey = Literal['tcp', 'uds', ...]` — add the
key. This `Literal` is the canonical set; `_testing/pytest.py` internal key. This `Literal` is the **declared protocol-key
drives `--tpt-proto` validation off `_addr._address_types`, set**, not proof that a backend is usable on this host.
and the spawn-backend fixture already models the 2. `tractor/discovery/_addr.py` `_address_protos` and
"drive-the-set-from-the-Literal" pattern `_address_types: dict[str, Type[Address]]` — register
(`pytest.py:870-880`) — do the same rather than hardcoding. `'<key>': <Proto>Address`. `_address_types` is a plain
2. `tractor/discovery/_addr.py:173` `_address_types: bidict` **`dict`, not a `bidict`**, and represents the backends this
`{'<key>': <Proto>Address}`. Note it is a **`bidict`**, so build registers for import and dispatch. UDS is conditional on
the mapping must stay 1:1. `HAS_UDS`, while TIPC can remain registered on a host where its
kernel support is unavailable. An importable backend with a
runtime capability requirement therefore needs a separate
availability check. Never conflate this dispatch registry with
either host usability or the declared `TransportProtocolKey`
universe.
3. `tractor/discovery/_addr.py:181` `_default_lo_addrs` 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
@ -250,7 +288,7 @@ Adding a backend touches these and only these:
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` entry instead of an order-sensitive `case`. `_address_types` lookup 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]`.
@ -262,9 +300,17 @@ Adding a backend touches these and only these:
`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/_testing/addr.py::get_rando_addr()` — per-proto 9. `tractor/discovery/_api.py::_is_local_addr()` and
`prefer_addr()` — define and test the backend's locality and
selection tier. The current order is UDS, local TCP, then
remote. A new backend must not silently fall into `remote` by
accident: for example TIPC node scope is local, cluster scope
is not known-local, and an observed address with unknown scope
must not be promoted. Preserve the last-registered tie-break
unless intentionally changing policy.
10. `tractor/_testing/addr.py::get_rando_addr()` — per-proto
branch so the whole suite can run under `--tpt-proto <key>`. branch so the whole suite can run under `--tpt-proto <key>`.
10. `pyproject.toml` — new deps go in an **optional extra**, never 11. `pyproject.toml` — new deps go in an **optional extra**, never
in `[project].dependencies`. See §5. in `[project].dependencies`. See §5.
## 3. Where the `trio.SocketListener` assumption is load-bearing ## 3. Where the `trio.SocketListener` assumption is load-bearing
@ -339,7 +385,10 @@ 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). asserts the lookup). Thus CLI acceptance follows the
build-registered `_address_types`, while type-level declarations
follow `TransportProtocolKey` and host usability follows each
backend's capability probe; test all three layers separately.
- The **acceptance bar** for every backend is: the *entire* - 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
@ -353,8 +402,11 @@ 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 `pyproject.toml`, per - New pytest marks must be registered in
the project's fix-warnings-at-source rule (gh #469). `_testing/pytest.py::pytest_configure()` with
`config.addinivalue_line()`, alongside the existing custom
marks. The repo has no `pyproject.toml` marker table. This is
still part of the fix-warnings-at-source rule (gh #469).
## 7. Code style (non-negotiable, matches the repo) ## 7. Code style (non-negotiable, matches the repo)

View File

@ -4,13 +4,18 @@ 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
simultaneously the only one that gives us cluster-wide service gives us kernel-native service-name publication, known-address
discovery **for free, in the kernel**, replacing (for dialling, and topology events. Those are primitives for reducing
TIPC-capable deployments) the whole `tractor.discovery` registrar traffic; they do **not** by themselves replace
registrar round-trip with a `bind()`/`connect()` on a `tractor.discovery`, derive an actor's address from its name, or
*service name*. It is stdlib-only: zero new dependencies. elect one registrar. It is stdlib-only: zero new dependencies.
This plan is reconciled against downstream PR [#493]'s code and
tests. Treat that implementation as prior art without mistaking
implemented transport primitives for completed discovery policy.
[#378]: https://github.com/goodboy/tractor/issues/378 [#378]: https://github.com/goodboy/tractor/issues/378
[#493]: https://github.com/goodboy/tractor/pull/493
--- ---
@ -74,10 +79,12 @@ The design decision that makes this backend coherent:
> ever an *observed* address (`getpeername()`), never a > ever an *observed* address (`getpeername()`), never a
> user-facing one.** > user-facing one.**
This is exactly the "leverage the built-in discovery machinery" This is the "leverage the built-in discovery machinery" part of
ask in #378: publishing a bind *is* registration, and #378: publishing a bind is kernel name-table registration and
`connect()` on a name *is* a lookup, with no registrar actor in `connect()` on an already-known name is a kernel lookup, with no
the loop. registrar actor on that **dial** path. Mapping an application name
to that address and maintaining Tractor's actor registry remain
separate work (§5).
### 2.2 the struct ### 2.2 the struct
@ -89,12 +96,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, never part of identity/equality-by-intent # observed-only, excluded from the unwrapped service identity
maybe_node: int|None = None # from TIPC_ADDR_ID getpeername() maybe_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] unwrapped_type: ClassVar[type] = tuple[str, int, int, int]
def_bindspace: ClassVar[int] = TIPC_CLUSTER_SCOPE def_bindspace: ClassVar[int] = TIPC_CLUSTER_SCOPE
``` ```
@ -106,8 +113,8 @@ shape as `TCPAddress`*, so `wrap_address()`'s
`case (str(), int())` steals it. This backend is therefore the `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 an explicit proto-key, spelled > **make the unwrapped form carry the explicit internal
> with the `multiaddr` protocol name.** > `TransportProtocolKey`.**
```python ```python
def unwrap(self) -> tuple[str, int, int, int]: def unwrap(self) -> tuple[str, int, int, int]:
@ -115,11 +122,31 @@ def unwrap(self) -> tuple[str, int, int, int]:
``` ```
`wrap_address()` then dispatches `_address_types[addr[0]]` and `wrap_address()` then dispatches `_address_types[addr[0]]` and
the collision class disappears. **This is a prerequisite the collision class disappears. The complete all-backend change
migration commit, not part of this backend** — see contract §1.1 is a prerequisite migration; #493 necessarily carried the
for its blast radius (wire format + every fixture + `piker` transitional `UnwrappedAddress`/`SpawnSpec.reg_addrs`/
config) and for the follow-on "stop handing raw tuples to users `.bind_addrs` widening needed for TIPC. See contract §1.1 for the
at all, à la `ipaddress`" direction. remaining runtime annotations, fixtures and `piker` config. Here
`tipc` is both the internal and external spelling; UDS remains
internally `uds` and translates explicitly to external `/unix/`.
`msgpack` decodes tuples as lists, so both forms are part of the
round-trip contract. Match only the exact three- or four-element
tagged shapes and test all four routes:
```python
case (
('tipc', int() as stype, int() as inst, int() as scope)
|
['tipc', int() as stype, int() as inst, int() as scope]
):
...
```
Also test the scope-defaulted three-element form through
`TIPCAddress.from_addr()`, and tuple/list forms through the global
`wrap_address()`. A normal two-element TCP/UDS address whose first
element happens to be `'tipc'` must retain its classic dispatch.
⚠️ an earlier revision of this plan proposed a self-tagging ⚠️ 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
@ -150,25 +177,28 @@ 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 collision-free: reproducible and well-distributed, **not collision-free**:
```python ```python
# 32-bit instance derived from the actor's uuid4 (+ pid when # 32-bit instance derived from the actor's Aid.uid, or from a
# there's no live runtime, per the UDS precedent). # per-call token + pid when there is no live runtime.
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 = f'{actor.aid.name}@{pid}'` if where `seed = '.'.join(actor.aid.uid)` 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
the test that proves the 4-byte digest is enough and §9 has a statistical test and §9 records the unresolved recovery work
the mitigation if it isn't. in [#501].
- `_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`**:
@ -189,7 +219,9 @@ 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
@ -236,10 +268,11 @@ 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__` will try - `SocketListener.__init__` calls
`getsockopt(SOL_SOCKET, SO_ACCEPTCONN)`. If TIPC rejects it, `getsockopt(SOL_SOCKET, SO_ACCEPTCONN)`. The live-kernel probe
trio's `except OSError: pass` covers us. Assert this in a used by #493 answers `1`; retain the unit test so a kernel-side
unit test rather than assuming. change is visible rather than relying on trio's suppressed-
`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
@ -258,33 +291,31 @@ returns a `TIPC_ADDR_ID`-flavoured 5-tuple (the port id), *not*
the name-seq we bound. So the `!=` is **always true** and 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.
Handle it inside `TIPCAddress.from_addr()` — do **not** patch `TIPCAddress.from_addr()` must accept only proto-keyed service
`_server.py`: names. It must reject a bare port ID because no conversion can
recover `(stype, instance)`:
```python ```python
@classmethod @classmethod
def from_addr(cls, addr) -> TIPCAddress: def from_addr(cls, addr) -> TIPCAddress:
match addr: match addr:
# our own unwrapped form # our proto-keyed tuple or decoded-list wire form
case (str() as tag, int() as inst) if tag.startswith('tipc:'): case (
_, stype, scope = tag.split(':') ('tipc', int() as stype, int() as inst, int() as scope)
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 kernel-observed TIPC_ADDR_ID 5-tuple: keep the # a bare kernel-observed TIPC_ADDR_ID 5-tuple has no
# *service* identity we already know and only annotate # service identity to 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)`.
— that info isn't in a port id. So `from_addr()` alone is The resolution is the explicit listener-rebind policy added ahead
insufficient for the reconciliation path. **Resolution**: make of the backend in #493:
`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 (
@ -300,12 +331,13 @@ 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. **Land this as its own commit, ahead of the backend**, hack. Keep the guard test that TCP's `port=0` behaviour is
with a test that `tcp`'s `port=0` behaviour is unchanged. unchanged.
Keep the observed port-id available anyway: annotate Do **not** annotate `Endpoint.addr` from `getsockname()`: the
`ep.addr = ep.addr.with_port_id(*getsockname()[1:3])` (a pure listener endpoint must remain the dialable service name. Port IDs
`msgspec.structs.replace()` helper) purely for logging/repr. are observed only on connected streams and may annotate a copy via
`with_port_id()` purely for logging/repr.
### 3.3 `MsgpackTIPCStream` ### 3.3 `MsgpackTIPCStream`
@ -340,8 +372,9 @@ 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,
) )
@ -363,11 +396,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**: TIPC returns - **`connect_to()` on a name with no publisher**: the live-kernel
`ECONNREFUSED`/`EHOSTUNREACH` promptly (no SYN-timeout wait), result is immediate `EHOSTUNREACH`. Python exposes that as a
which is *better* discovery-ping behaviour than TCP. Confirm bare `OSError`, not a `ConnectionError` subtype, so
the errno and make sure it surfaces as `ConnectionError` `_reraise_as_connerr()` is load-bearing for contract §4. Keep
(contract §4 — the registrar ping path depends on it). the exact errno and normalization under test.
### 3.4 `get_stream_addrs()` ### 3.4 `get_stream_addrs()`
@ -385,29 +418,27 @@ Problem: neither end's port-id tells us the *service name*. The
`laddr`/`raddr` are used for logging, `Channel.raddr`, `laddr`/`raddr` are used for logging, `Channel.raddr`,
`Server._peers` keying-adjacent repr, and `maddr`. Design: `Server._peers` keying-adjacent repr, and `maddr`. Design:
- the **connecting** side knows the destaddr it dialled → - `get_stream_addrs()` converts both socket results into
`connect_to()` overrides `_raddr` after construction with the **observed-only** addresses: `_stype`/`_instance` use the
known-good `TIPCAddress`, exactly as `TIPC_NAME_UNKNOWN = -1` sentinel and `maybe_node`/`maybe_ref`
`MsgpackUDSStream.connect_to()` does for the peer-pid case carry the port ID. Such addresses are invalid for dialling.
(`_uds.py:539-543`). - the **connecting** side knows the service name it dialled, so
- the **accepting** side does not know the peer's service name `connect_to()` replaces `_raddr` after construction with that
from the socket. Two honest options: known `TIPCAddress` while retaining the constructor's one
- **(a) accept it: `raddr` carries only `(node, ref)`** via tolerant port-ID observation. Do not call `getpeername()` a
`maybe_node`/`maybe_ref`, `_stype/_instance` set to a second time: the peer can withdraw between the two calls.
sentinel `-1`, and `__repr__` renders - the **accepting** side genuinely cannot recover the peer's
`TIPCAddress[<peer-node:0x...>:<ref>]`. The `Aid` from the service name from a port ID. Keep the observed-only `raddr`;
handshake already gives us the peer's logical identity, so the handshake's `Aid` supplies logical identity. Piggybacking a
nothing in the runtime actually *needs* the peer's service bound name in the handshake is outside this backend.
name. **Recommended.** - `laddr` is observed-only as well. It is used for repr/logging,
- (b) piggyback the peer's own bound name in the handshake. not to replace the endpoint's known service name.
Rejected for this PR: touches `Aid`/msg-spec. - unlike TCP/UDS, TIPC can answer `ENOTCONN` from
- `laddr` on the accepting side: the `Endpoint` knows its own `getpeername()` after a connect-then-drop. This lookup happens
`addr`; but `get_stream_addrs()` is a `@classmethod` with only during `MsgpackTransport` construction, before handshake error
the stream. Use `TIPC_ADDR_ID` for `laddr` too and let tolerance. Wrap `getsockname()` and `getpeername()` in a
`Endpoint.peer_tpts` keying (which is by *peer* addr) still tolerant helper and degrade to a port-ID-less observed address;
work. Verify nothing asserts `laddr == ep.addr` — grep for a dropped peer must cost an observation, not kill the actor.
`.laddr` uses before committing (`_server.py`'s
`con_status` logging, `Channel.pformat()`).
--- ---
@ -444,43 +475,47 @@ the maddr stays 2-segment like `/unix/...`.
--- ---
## 5. Discovery: the actually-interesting part ## 5. Discovery primitives and explicit limits
Two independently-shippable layers. **Layer A is in scope for The backend provides independently-shippable kernel primitives.
the first PR; layer B is a fast-follow.** Neither primitive alone implements Tractor's actor-name discovery,
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 `tractor` tree whose `connect(TIPC_ADDR_NAME)` resolves, a caller that **already knows**
`registry_addrs` are TIPC service names needs **no registrar a TIPC service address can dial it without a registrar lookup.
liveness at all** for the connect path: `find_actor()`'s This is narrower than registrar-less `find_actor(name)`:
"connect to the registrar and ask" becomes "connect to the
service name directly". Concretely:
- `tractor.discovery._api.find_actor()` etc. keep working - `tractor.discovery._api.find_actor()` and peers still query a
unchanged (they go through the registrar), *and* registrar; #493 does not change them.
- a new, TIPC-only fast path becomes possible: derive an actor's - deriving a stable service address from `(name, uuid)` and
service name from its `(name, uuid)` and dial it without any dialling it directly is follow-up [#499]. The mapping must be
registrar hop. documented and cross-language stable.
- `registry_addrs` still identify registrars. Connecting to a
known registrar by TIPC name removes no registrar bookkeeping
or ownership semantics.
Do **not** build the fast path in PR 1. Instead, prove the There is also an unresolved **split-brain election** problem.
property with a test (§7.4) and file the follow-up: it changes Duplicate TIPC name publication succeeds and round-robins, so two
`discovery` semantics (name→instance derivation must be a roots can both probe an unoccupied registrar name, both bind it,
documented, stable, cross-language-able hash) and deserves its and both believe they won. The backend provides no atomic
own design. compare-and-publish, lease, quorum, or deterministic winner. A
topology subscription can reveal multiple publisher port IDs but
does not elect or fence one. Do not describe registrar election as
solved until a separate protocol closes this race.
### 5.2 Layer B — the topology service (`TIPC_TOP_SRV`) ### 5.2 Layer B — the topology service (`TIPC_TOP_SRV`)
This is what makes #378's "end game cluster proto" claim real: This is the push primitive behind #378's "end game cluster proto"
a *subscription* to name-table events, i.e. push-based direction: a subscription to kernel name-table publish/withdraw
`register`/`deregister` for free, replacing the registrar's events. #493 implements `open_topology_events()`; consuming that
polled `find_actor()`. feed in `discovery._registry` is follow-up [#496]. Until then it
does not replace registrar state or `find_actor()`.
Mechanics (verify each field against Mechanics, verified against `linux/include/uapi/linux/tipc.h`,
`linux/include/uapi/linux/tipc.h` + `net/tipc/topsrv.c` at `net/tipc/topsrv.c` and #493's live-kernel probe:
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
@ -498,22 +533,25 @@ 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 = '=IIIII8s' # ⚠ 5*I is 20 -> use '=5I8s' _SUBSCR_FMT: str = '=5I8s'
``` ```
- **byte order**: the topology server historically accepts both - **byte order**: #493's live-kernel probe verified native
host and swapped order and auto-detects; modern kernels are standard-size (`'='`) packing for publish and withdraw events.
strict-ish. Pack native (`'='`) first, and if the server Use `'=5I8s'` for the 28-byte subscription. Do not retain the
closes the connection immediately, retry with `'>'`. Encode speculative `'>'` retry/probe as if it were required. Preserve
that as a one-time probe helper the earlier `# ?TODO` to verify the deterministic rule directly
`_detect_topsrv_endianness()` cached at module level — and against `net/tipc/topsrv.c`; it is source-audit work, not a
put a `# ?TODO` pointing at `net/tipc/topsrv.c` for someone runtime retry requirement.
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 → 40 bytes. `event ∈ {TIPC_PUBLISHED, TIPC_WITHDRAWN, echo: **48 bytes** (`4 + 4 + 4 + 8 + 28`), not 40. Use
TIPC_SUBSCR_TIMEOUT}`. `'=10I8s'` and assert `struct.calcsize(...) == 48`.
`event ∈ {TIPC_PUBLISHED, TIPC_WITHDRAWN,
TIPC_SUBSCR_TIMEOUT}`. Python exposes `TIPC_WAIT_FOREVER` as
`-1`, so mask it with `& 0xFFFF_FFFF` before packing an
unsigned `I`.
- **trio shape** — this is where the "nearly-functional, - **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:
@ -538,14 +576,22 @@ 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(40)` in a loop and task doing `await sock.recv(48)` in a loop. The feed is
`send_nowait()`ing decoded events, with the `@acm` closing the authoritative and may neither block the socket reader nor drop
socket on exit → reader gets `ClosedResourceError` → cancel transitions silently. Use `send_nowait()` and, on
scope collapses. Standard `tractor` `@acm` discipline. `trio.WouldBlock`, raise a dedicated
- **consumer**: `tractor/discovery/_registry.py` gains an `TIPCNameEventOverflow` that aborts the subscription and tells
optional "watch" mode so a registrar (or any actor) can keep the consumer to resubscribe and rebuild its view. A timeout
a live view of the actor set without polling. Sketch the event is delivered once and then closes the channel. The
integration in the follow-up issue; do not wire it in PR 1. `@acm` cancels its reader before closing the fd so teardown
cannot race a retried `recv()` into `EBADF`.
- **scope**: topology events carry no publication scope. Use an
explicit unknown-scope sentinel and keep the resulting address
non-dialable; never copy caller/subscription context into
supposedly observed data.
- **consumer**: [#496] owns the optional watch mode and the
decision whether the feed subsumes or merely accelerates
existing registrar bookkeeping.
- **`SOCK_SEQPACKET` is fine here** because this socket never - **`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"
@ -561,14 +607,16 @@ 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()` uniqueness, bind/listen + `SO_ACCEPTCONN` `get_random()` distribution, 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-6, 9) + 4. registration tables (contract §2 items 1-8 and 10) +
`pyproject.toml` mark/extra. Test: full suite under `pyproject.toml` mark/extra. Test: full suite under
`--tpt-proto tipc` (§7.3). `--tpt-proto tipc` (§7.3). Keep TIPC in the conservative remote
preference tier until a follow-up implements and tests contract
item 9's node-scope locality policy.
5. maddr support (`str` form + prefix special-case) + docs. 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.
@ -589,6 +637,8 @@ 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
@ -596,17 +646,22 @@ def is_tipc_available() -> bool:
return False return False
``` ```
Cache it in a module global (it can't change without a Do not permanently memoize the result: `modprobe tipc` and module
`modprobe`, and a cold call costs a syscall). Pure predicate, no removal can change it during a long-lived process. Probe once per
side effects, no logging. runtime startup, or use an explicitly refreshable cache whose
owner invalidates it after module-management operations. The
predicate itself remains side-effect-free and silent.
### 7.2 gating ### 7.2 gating
- `pytest.mark.tipc` registered in `pyproject.toml`. - `pytest.mark.tipc` registered in
- module-level `_testing/pytest.py::pytest_configure()` via
`pytestmark = pytest.mark.skipif(not is_tipc_available(), `config.addinivalue_line()`, where this repo declares its other
reason='`tipc` kernel module not loaded (`modprobe tipc`)')` custom marks. Do not invent a `pyproject.toml` marker table.
in `tests/ipc/test_tipc.py`. - keep pure address, serialization, and topology-codec tests
runnable on every host. Apply a shared `requires_tipc` marker
only to tests that create sockets or otherwise touch the kernel;
do not module-skip `tests/ipc/test_tipc.py`.
- `--tpt-proto tipc` with no module must fail **loudly and - `--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
@ -622,23 +677,29 @@ side effects, no logging.
`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. If it turns out throwaway workflow before wiring the matrix. #493's TIPC leg
to be unavailable, fall back to a container job with is now blocking. If runners cease permitting the module load,
`--privileged`/`--cap-add NET_ADMIN`, and mark the job fix the environment or use a suitable container rather than
`continue-on-error` until it's proven stable. silently restoring `continue-on-error`.
- 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
- **name-publication is discovery**: bind a listener on - **known-name publication/resolution**: bind a listener on
`(stype, inst)`, then from a second task `connect()` by name `(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()` collision resistance**: 10k `get_random()` - **`get_random()` distribution**: 10k `get_random()` calls with
calls with no live runtime → 10k distinct `_instance`s. no live runtime. Do **not** assert 10k distinct values: the
(This is the silent-crosstalk risk from §2.3; if the 4-byte no-runtime seeds and outputs are both only 32 bits. Including
digest ever collides in this test, escalate to §9.) duplicate seeds plus distinct-seed hash collisions puts the
modeled chance of at least one duplicate near 2.3% for 10k
calls. #493 uses `>= n - 2` (modeled probability of more than
two collisions around `2e-6`) and separately proves
`instance_from_seed()` is a pure function. Also hold actor
name/PID fixed while varying only `Aid.uuid` to prove live
actors seed from `Aid.uid`.
- **round-robin surprise**: two listeners bound to the *same* - **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
@ -678,14 +739,61 @@ single best demo this backend has; lead with it.
## 9. Known risks + escalations ## 9. Known risks + escalations
| risk | mitigation | - **Instance collision / silent crosstalk remains unresolved.**
| --- | --- | `Aid.uid` seeding and §7.4 tests reduce and measure risk, but
| `_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)` | the instance field is still a hard 32 bits. [#501] owns
| kernel/module unavailability everywhere (dev boxes, macOS, CI) | hard gating (§7.2); TIPC is explicitly an *opt-in cluster* transport, never a default | post-bind verification and recovery. Do not fold bits into
| `getsockname()` returns port-id not name | the `rebind_from_sockname` opt-out (§3.2), landed first | `_stype`: topology can watch only one service type.
| unregistered `/tipc` multiaddr proto | `str` maddr fallback (§4) + upstream track gh #483 | - **Concurrent registrar startup can split brain.** Topology can
| 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 | observe duplicate publisher port IDs but cannot elect or fence
| `SOCK_SEQPACKET` topology framing byte-order | probe helper + `?TODO` (§5.2) | a winner; a separate election protocol is required (§5.1).
- **Kernel/module availability is opt-in.** Keep the hard gate in
§7.2; TIPC is never the default transport.
- **A listener sockname is a port ID, not its service name.** Keep
the `rebind_from_sockname` opt-out (§3.2).
- **`/tipc` is not yet a registered multiaddr protocol.** Keep
the interim `str` maddr fallback (§4) and upstream gh #483.
- **The public TIPC docs can be stale.** Treat
`include/uapi/linux/tipc.h` and `net/tipc/` as normative and
cite file/symbol names in code comments.
- **A slow topology consumer loses continuity.** Fail fast with
`TIPCNameEventOverflow`; resubscribe and rebuild rather than
block the reader or retain stale state (§5.2).
- **TIPC locality preference is not implemented.** Current
`_is_local_addr()` handles only UDS and TCP, so node- and
cluster-scope TIPC both remain in the conservative remote tier.
Add explicit scope-aware policy and multihomed selection tests
before claiming node-scope preference (contract §2.9).
### 9.1 remaining constructor/error cleanup
#493 closes the peer-withdrawal race in transport construction,
but it is not a blanket error-path cleanup. Keep these gaps
explicit rather than reporting the backend as fully hardened:
- direct `TIPCAddress(...)` construction bypasses
`from_addr()` scope normalization; `is_valid` is queried later
rather than enforcing validity at construction. Decide whether
constructors should reject bad service types/instances/scopes
or document direct construction as trusted-internal.
- `maybe_node`/`maybe_ref` are excluded from `.unwrap()` but, as
`msgspec.Struct` fields, still participate in structural
equality/hash. If service-name identity must ignore observation
metadata, represent or compare it explicitly instead of relying
on the current "observed-only" description.
- `start_listener()` must keep ownership of the raw socket through
`bind()`, `listen()` and `SocketListener(...)`. The downstream
implementation normalizes bind errors but does not yet wrap the
complete listener-construction sequence in close-on-error, so a
later setup failure can leak the fd.
- `_maybe_sockaddr()` currently degrades every `OSError` to an
unknown observed address. Narrow that tolerance to expected
peer-withdrawal errors (notably `ENOTCONN`) so unrelated bad-fd
or programming failures remain visible.
- error normalization is intentionally required for an
unpublished-name `EHOSTUNREACH`, but setup `setsockopt`,
listener-constructor, and topology setup failures still need a
consistent policy and focused regression tests.
## 10. Follow-up issue seeds ## 10. Follow-up issue seeds
@ -695,9 +803,11 @@ single best demo this backend has; lead with it.
`py-multiaddr`, then drop our `str`-maddr fallback (§4). Worth `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 (§5.1) - registrar-less discovery fast path via name derivation ([#499],
§5.1)
- `TIPC_TOP_SRV`-driven push registry in - `TIPC_TOP_SRV`-driven push registry in
`discovery/_registry.py` (§5.2) `discovery/_registry.py` ([#496], §5.2)
- post-bind collision verification and recovery ([#501], §9)
- `TIPC_IMPORTANCE` for the parent<->child lifetime channel - `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
@ -705,3 +815,7 @@ single best demo this backend has; lead with it.
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

View File

@ -3,6 +3,12 @@
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
@ -49,20 +55,22 @@ 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: `iroh` moves fast and has had breaking Version pinning: treat API stability across `iroh` minors as an
API renames across minors. Pin `iroh>=X.Y,<X.Y+1` in a `quic` **unverified external constraint** until step 0. Pin the version
extra, and **write down the exact resolved version + the exercised by the spike to `iroh>=X.Y,<X.Y+1` in a `quic` extra,
generated `iroh/_uniffi*` module layout** in the module and **write down the exact resolved version + generated
docstring, because §2 depends on generated-code internals. `iroh/_uniffi*` module layout** in the module docstring, because
§2 depends on generated-code internals.
**Step 0 of implementation is an API-truth pass**: install the **Step 0 of implementation is an API-truth pass**: install the
pinned `iroh`, `python -c "import iroh; help(iroh)"`, and record pinned `iroh`, inspect both its generated Python and loaded FFI
in this doc's §1.1 the real names of: endpoint builder, secret symbols, and run the throwaway two-process spike. Record in
key type, `connect`/`accept`, bi-stream open/accept, the §1.1 the real names and observed contracts. Every statement
send/recv methods and their exact signatures/return types, and below about `iroh`, UniFFI, Rust callbacks, or generated symbols
whether they're `async def`. Everything below uses *provisional* is a **step-0 hypothesis**, not a locally proven fact, unless it
names and must be reconciled. Do not skip this; do not guess is copied into the completed API-truth table with a source or
from memory. probe. Tractor and Trio behavior cited from this checkout is not
subject to that qualifier.
### 1.1 API-truth table (fill in during step 0) ### 1.1 API-truth table (fill in during step 0)
@ -79,122 +87,120 @@ from memory.
| 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 what uniffi actually generates ### 2.1 Step-0 generated-ABI gate
`uniffi`'s async support does not use asyncio *semantically* The expected generated shape is: start an opaque Rust future,
it uses asyncio only as the *executor* for a poll loop. The poll it with a C callback, cancel through a generated cancel
generated python for an `async fn` is, in shape: symbol, consume its terminal value/status through `complete`,
then call `free`. The expected callback may arrive on a foreign
Rust thread. **All of that is external and provisional.** Step 0
must identify the exact generated driver and prove, from its
template/source plus probes:
1. call `_uniffi_..._<method>(...)` → returns an opaque 1. the start, poll, cancel, complete, and free signatures for
`RustFuture` handle (a `void*`/`u64`). every return-type family used by `iroh`;
2. loop: call 2. poll result values and whether callbacks can be synchronous,
`ffi_..._rust_future_poll_<T>(handle, callback, callback_data)`. concurrent, repeated, or late;
The callback is a C-ABI fn pointer invoked **from an 3. which terminal state permits `complete`, when `free` is
arbitrary rust thread** with a poll-result code legal, and when no callback can still reference Python;
(`READY`/`MAYBE_READY`). 4. whether generated callback-data and call-status objects must
3. the generated glue's callback resolves an remain alive, and how generated lifting/errors are applied;
`asyncio.Future` via `loop.call_soon_threadsafe(...)`; the 5. whether one narrow generated async-driver entrypoint can be
coroutine awaits it, then re-polls. replaced without importing or requiring an asyncio loop.
4. on ready: `ffi_..._rust_future_complete_<T>(handle,
&call_status)` → the value; then
`ffi_..._rust_future_free_<T>(handle)`.
**The asyncio dependency is confined to step 3.** That is the Do not implement from a remembered UniFFI version. If cancel
whole insight: the bridge is ~40 lines. does not have a documented path to a terminal, safely freeable
state, the native Trio bridge fails the spike gate and the first
backend uses the infected-asyncio fallback.
### 2.2 the trio version ### 2.2 Cancellation-safe ownership
```python Do not let the caller task own a raw handle across an `await`.
async def await_rust_future( Introduce an actor-scoped `UniffiFutureSupervisor` running in the
poll: Callable, # ffi_..._rust_future_poll_<T> dedicated transport nursery specified in §3.2.1. That nursery
complete: Callable, # ffi_..._rust_future_complete_<T> must span parent bootstrap, the service nurseries, and final
free: Callable, # ffi_..._rust_future_free_<T> deregistration. For each call, its operation task owns the
handle: int, **entire** generated lifecycle:
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
token = trio.lowlevel.current_trio_token() create handle -> poll/callback loop -> complete -> lift/status
while True: -> free -> publish result
wake = trio.Event() ^
# NOTE, invoked from a *rust* thread! cancel request uses generated cancel, then follows
def _cb(_data, poll_code): the verified terminal poll/complete/free protocol
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)
``` ```
Critical details, each a real bug if missed: The operation task, not the awaiting caller, creates the handle.
Creation and insertion in the supervisor's live-operation set
must have no cancellation checkpoint between them. The operation
retains strong references to the C callback trampoline, callback
data, wake state, call status, and handle until step 0 proves all
callbacks are quiescent and `free` has returned. Use one stable
callback per operation unless the verified ABI requires a fresh
one per poll; in either case, retain every potentially callable
trampoline. Capture `current_trio_token()` in the Trio owner and
schedule the wake into Trio with `token.run_sync_soon(...)`; the
foreign callback only stores its poll result and schedules that
wake.
- **`token.run_sync_soon()` is the only trio API callable from a Caller cancellation is a request, not handle ownership transfer:
foreign thread**, and it is documented as such. Use it; do
*not* use `trio.from_thread.run_sync` (requires a trio thread 1. the caller sends an idempotent cancel request and waits under
context) and do not touch the `Event` directly from the a short shield for the operation to acknowledge it;
callback. 2. the owner invokes the generated cancel function exactly once
- **the poll code must reach the trio side.** Capture it in a and continues the **verified** poll/complete/free sequence;
`nonlocal`/1-slot list written by the callback *before* 3. once caller cancellation is observed, cleanup completion never
`run_sync_soon`, since the callback owns the value. Handle wins the race by returning a value. After acknowledgement the
`MAYBE_READY` by re-polling (the loop above does). caller continues propagating its original Trio cancellation;
- **keep the `ctypes` callback object alive** across the await — if cleanup outlives the grace period it first abandons its
a GC'd `CFUNCTYPE` trampoline is a segfault. Bind it to a result channel while the actor supervisor keeps ownership;
local *and* make sure the local outlives the `poll()` call 4. actor endpoint teardown stops accepting new calls, requests
window. cancellation of all live operations, and joins the supervisor
- **cancellation.** `await wake.wait()` is a trio checkpoint, so before destroying endpoint/key state.
a `Cancelled` can fire while rust still owns the future. On
cancel we must still `free(handle)` — and per uniffi, the There is deliberately no `move_on_after(...): free(handle)`
correct sequence is to call the generated path. A timeout proves only that cleanup is slow; it does not
`ffi_..._rust_future_cancel_<T>(handle)` then continue prove that callbacks are quiescent or that `free` is legal. A
polling to completion before `free`. Wrap the whole thing so wedged operation therefore remains visible in the supervisor and
the cancel path does: can delay graceful actor shutdown; process-level termination is
`with trio.CancelScope(shield=True): cancel(handle); <drain the final escalation, not an unsafe FFI free.
poll loop>; free(handle)`. **Bounded** shield (add a
`trio.move_on_after()` with a module-level constant) so a Structured-concurrency race to test: caller cancellation may land
wedged rust future can't make an actor un-cancellable — after handle creation, after each poll, during callback delivery,
`tractor` is SC-first and an unbounded shield here would after terminal readiness, during `complete`, and before result
violate that. publication. At every checkpoint exactly one operation task owns
- **`trio.lowlevel.current_trio_token()`** must be captured on the handle, exactly one `free` is possible, and the supervisor
the trio side (not in the callback). cannot exit while that task or a callable trampoline remains.
### 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. Instead ship Do **not** fork/vendor the generated `iroh` Python. Subject to the
a *narrow* re-dispatch shim: step-0 gate, ship a *narrow* re-dispatch shim:
- write `tractor/ipc/_uniffi_trio.py` with `await_rust_future()` - write `tractor/ipc/_uniffi_trio.py` with the supervisor and a
plus a `@cm patch_uniffi_for_trio()` that monkey-patches the `@cm patch_uniffi_for_trio()` that patches only the generated
generated module's single async-driver entrypoint (in current async-driver entrypoint recorded in §1.1;
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.
- **plan for this to break on `iroh`/`uniffi` upgrades.** Mitigate - treat every `iroh`/UniFFI upgrade as requiring the step-0 ABI
with (a) a unit test that drives one trivial `iroh` async call gate again. Keep a test that drives one trivial call under bare
under bare `trio.run()` and asserts no event loop was ever `trio.run()`, asserts no asyncio loop, and injects cancellation
created (`asyncio.get_event_loop_policy()` untouched / at every lifecycle checkpoint. Point the module docstring at
`asyncio._get_running_loop() is None`), and (b) a docstring the exact generated template/revision mirrored by the shim.
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
@ -228,18 +234,52 @@ iroh bi-stream == one `Channel`/`MsgTransport` -> 1:1
- `layer_key: int = 4` still (QUIC is L4-ish); note in a comment - `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 the one place we add state the other **Connection pooling** is actor-endpoint state, never module
backends don't have: dialing the same peer twice should reuse state. Its key is exactly
the `Connection` and open a second bi-stream. Implement as a `(local_endpoint_identity, remote_node_id, alpn)`, where local
module-level `dict[NodeId, Connection]` guarded by a endpoint identity is the local NodeId derived from the actor key.
`trio.Lock`... **no** — that's a per-process cache with Remote NodeId alone would incorrectly share connections across
lifetime/teardown hazards. Instead reuse the codebase's existing local keys or protocol epochs. Build it over the codebase's
idiom: `tractor.trionics.maybe_open_context()` keyed on the `maybe_open_context()` idiom only after a concurrency review of
node-id, which already solves exactly this (one-cached-resource- its actual last-user teardown behavior in the implementation
per-key, refcounted, teardown-on-last-exit) and whose teardown revision. Do not assume an issue reference proves the required
semantics were just hardened (gh #488). Use it; do not hand-roll ordering.
a cache. Anything concurrency-subtle here should get the
`conc-anal` skill run over it. `acquire_connection()` returns a `ConnectionLease`, not a bare
connection. An outgoing `QuicMsgStream` owns that entered lease
for its whole lifetime; `connect_to()` must not exit the cached
context immediately after `open_bi()`. Exact transfer paths:
- dial/acquire or `open_bi()` failure releases the lease in a
shielded `finally` before raising;
- successful stream construction atomically transfers the lease
to `QuicMsgStream` before the first cancellation checkpoint;
- `send_eof()` closes only the send half and does not release;
- clean receive EOF closes only the receive half and does not
release while the send half remains usable;
- one guarded terminal-state transition releases exactly once
when both halves have become terminal, in either order;
- `aclose()`, reset, or terminal connection failure closes both
halves as applicable and idempotently releases exactly once;
- a stream queued by `QuicListener` already owns its lease; if
never accepted, listener draining closes it and releases it.
After `accept()` returns, the server dispatch path owns the stream
until a handler task starts and must close it if task start fails.
The handler then takes ownership, with an outer `finally` that
calls `stream.aclose()` on normal return, handshake failure, and
cancellation. Lease release itself is an idempotent pool state
transition; if last-user connection teardown awaits FFI, the actor
endpoint's pool supervisor owns that await so cancellation of the
handler cannot strand the lease.
For inbound connections, the connection-feeder owns a base lease
while accepting streams and each queued/returned stream gets a
child lease. The base lease is released only after the accept
loop ends; the pool closes the connection after the base and all
stream leases are gone. Reject or deterministically reconcile a
simultaneous inbound/outbound duplicate for the same full key;
record the chosen iroh-compatible rule during step 0.
### 3.2 `IrohAddress` ### 3.2 `IrohAddress`
@ -248,29 +288,29 @@ class IrohAddress(
msgspec.Struct, msgspec.Struct,
frozen=True, frozen=True,
): ):
_node_id: str # 32B ed25519 pubkey, hex or z32 _node_id: str
_alpn: str = 'tractor/0' # the bindspace! _alpn: str
# optional dial hints; NOT part of identity _relay_url: str|None
maybe_relay_url: str|None = None _direct_addrs: tuple[str, ...]
maybe_direct_addrs: tuple[str, ...] = ()
proto_key: ClassVar[str] = 'iroh' # ?or 'quic'; see §3.2.1 proto_key: ClassVar[str] = 'quic'
unwrapped_type: ClassVar[type] = tuple[str, str] unwrapped_type: ClassVar[type] = tuple
def_bindspace: ClassVar[str] = 'tractor/0' def_bindspace: ClassVar[str] = 'tractor/0'
``` ```
- **`.unwrap() -> (node_id_str, alpn_str)`** — a `(str, str)` - **`.unwrap()` is the complete, tagged wire descriptor**:
tuple, which is *unambiguously distinct* from `('quic', node_id, alpn, relay_url, direct_addrs)`. All values
`TCPAddress`'s `(str, int)`. But careful: are msgpack-native and `direct_addrs` is canonicalized to a
`wrap_address()`'s UDS case is tuple. `from_addr()` requires that exact tag and shape; never
`case (_, filename) if type(filename) is str` — which infer QUIC from a `(str, str)` pair. This depends on the shared
**already catches `(str, str)`**. So the iroh `case` MUST be contract's tagged-address migration and removes the UDS
ordered *before* the UDS case and guarded, e.g. collision rather than ordering around it.
`case (str() as nid, str() as alpn) if _is_node_id(nid):` - The descriptor always carries NodeId, ALPN, and both route-hint
with `_is_node_id()` a cheap length+alphabet check. Add a fields. For this discovery-free first backend, `.is_valid`
regression test asserting a UDS `(dir, filename)` pair still requires a parseable NodeId, non-empty ALPN, and at least one
wraps to `UDSAddress` — this is the exact "wrong transport relay URL or direct address. Whether NodeId-only dialing works
loaded" hazard `_addr.py:214` warns about. through optional iroh discovery is a step-0 API check and is
not part of the first implementation.
- `.bindspace``self._alpn`. This is the honest analogue: - `.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
@ -278,39 +318,80 @@ 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 `proto_key`: `'iroh'` vs `'quic'` ### 3.2.1 One actor endpoint and key
Add an actor-scoped `QuicActorEndpoint` resource containing the
secret key, one bound iroh endpoint, the UniFFI supervisor, the
connection pool, and its latest resolved `IrohAddress`. It cannot
live in `_service_tn`: a child dials its parent before that nursery
opens, while final deregistration may dial after it closes.
Add a dedicated `transport_tn` around the complete actor runtime:
the task that opens this nursery must start the complete
`async_main` sequence as a **child** of it and wait for that child.
That makes `transport_tn` an ancestor of every parent-dial,
service, and deregistration caller, satisfying
`maybe_open_context(tn=transport_tn)` rather than asking the
nursery-opening task to use its own child nursery. The child keeps
the nursery around `_root_tn` and `_service_tn`, performs final
deregistration while it remains open, then returns so the owner can
close the transport resource and nursery. Root startup needs the
equivalent outer owner around actor construction, service, and
teardown. If this shape cannot be preserved, the connection pool
must stop depending on `maybe_open_context()`'s ancestor-nursery
contract. No path creates a second endpoint for the actor.
The child currently receives transport configuration only in the
`SpawnSpec` sent over its already-open parent channel. QUIC cannot
derive its local key, ALPN, or requested bind policy from that late
message. Add a small msgpack/pickle-native
`ChildTransportBootstrap` to every process-launch path. It carries
the selected protocol and the QUIC-local key reference/generation
policy, ALPN, relay policy, and requested bind constraints. It is
available before `_from_parent()`; the later `SpawnSpec` repeats
the public configuration and startup rejects any mismatch. Root
actors derive the same bootstrap record directly from
`open_root_actor()` inputs before address selection.
With that prep in place, the order is:
1. consume the launch-time bootstrap record, select one key
(persisted and explicitly provisioned for a registrar, fresh
for an ordinary actor), and construct/bind the endpoint in the
transport owner task;
2. await the step-0-verified address-ready API and build a valid
descriptor from the endpoint's NodeId, ALPN, relay URL, and
direct addresses;
3. only then dial `_from_parent()` through this endpoint;
4. start `QuicListener` over this endpoint's accept API;
5. publish the resolved descriptor as `Endpoint.addr` and
`Actor.accept_addrs` before parent/registrar registration;
6. after service nurseries close, keep the endpoint available for
deregistration; then close listeners and streams, drain
connection leases and FFI operations, close/join the endpoint,
and release key state.
`IrohAddress.get_random()` is therefore a descriptor lookup on
the active actor transport resource, not key generation. Broaden
the shared `get_random()` contract for resource-backed transports
and make root/subactor address selection consume the bootstrap
resource instead of calling it before that resource exists. Do not
hide a secret in a module-level side table. Calls without an active
resource fail clearly rather than allocating an unowned key.
`get_root()` never returns an empty/sentinel NodeId. Make default
addresses lazy, and have QUIC load a provisioned public registrar
descriptor. Registrar provisioning writes its secret separately
with mode 0600 and writes the matching complete public descriptor
atomically; endpoint startup verifies the derived NodeId. If no
descriptor exists, default QUIC registrar discovery fails with an
actionable configuration error. Automatic first-process election
is deferred until a safe key-file locking and endpoint-binding
protocol is proven; key generation never occurs in the listen
path.
#### 3.2.2 `proto_key`: `'iroh'` vs `'quic'`
Use **`'quic'`** for the `proto_key`/`--tpt-proto` name and 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*.
@ -357,7 +438,7 @@ class QuicMsgStream(trio.abc.HalfCloseableStream):
''' '''
tpt_key: ClassVar[MsgTransportKey] = ('msgpack', 'quic') tpt_key: ClassVar[MsgTransportKey] = ('msgpack', 'quic')
def __init__(self, conn, send, recv) -> None: ... def __init__(self, conn, send, recv, lease) -> None: ...
async def send_all(self, data: bytes) -> None: ... async def 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: ...
@ -379,17 +460,38 @@ 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: guard with - honour Trio's one-task-per-direction rule with public,
`trio._util.ConflictDetector` equivalents (or just document + implementation-local guards that raise
assert), because `MsgpackTransport` already serializes sends `trio.BusyResourceError`; do not depend on `trio._util`.
with a `StrictFIFOLock` but recvs are single-task by `MsgpackTransport` already serializes sends, while receives are
construction. single-task by 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):
''' '''
@ -402,41 +504,63 @@ class QuicListener(trio.abc.Listener):
async def aclose(self) -> None: ... async def aclose(self) -> None: ...
``` ```
The accept-side subtlety: `trio.abc.Listener.accept()` yields The accept-side subtlety is fan-out: one actor transport accepts
one stream per call, but iroh gives us *connections* which then connections and each connection accepts streams, while
yield *streams*. So `QuicListener` needs an internal `Listener.accept()` returns one stream. Give **each** listener a
`trio.MemoryReceiveChannel[QuicMsgStream]` fed by a background supervisor task started with
task-pair (one task accepting connections, one per connection `await server_ep.listen_tn.start(...)`.
accepting bi-streams). `trio.abc.Listener` has no nursery, so: That task creates and owns a cancel scope, a child nursery for the
make the listener **constructed by an `@acm`** that owns the endpoint feeder plus per-connection feeders, a guarded stream
nursery, and have `start_listener()` be that `@acm`'s driver. queue, and a completion event.
`start_listener(addr=, server_ep=, actor_tpt=)` does not return
until the supervisor has reported all of those ready.
Do not borrow an implicit parent nursery or spawn feeders lazily
from `accept()`.
⚠️ this collides with `Endpoint.start_listener()` being a plain The queue is a guarded `deque`, not an unowned memory-channel
`async def` returning a listener. Two options: buffer. A feeder transfers a fully constructed, lease-owning
- **(a)** hang the nursery off the `Endpoint`'s existing stream into it only while the listener is open; if close wins the
`listen_tn``_serve_ipc_eps()` already creates `listen_tn` race, the feeder closes the stream itself. `accept()` atomically
and passes it into every `Endpoint` (`_server.py:1063-1074`), pops one item or waits on the queue condition. Once close is
and `Endpoint.listen_tn` is right there. So marked and the queue is empty, it raises
`start_listener()` can `self.listen_tn.start_soon(...)` the `trio.ClosedResourceError`.
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.
Since `start_listener()` is called via `QuicListener.aclose()` is idempotent and has this exact order:
`inspect.getmodule(addr)` with only `addr=` (contract §1.3),
option (a) needs the `Endpoint` itself. Either add `ep=` to the 1. under the queue guard, mark closed and wake all `accept()`
module-level `start_listener()` call signature (all backends waiters without a checkpoint between the state change and
ignore it except quic → small upstream change, do it as part of notification;
the prep PR and make it keyword-only with a default) or have 2. cancel the listener-owned supervisor scope;
`QuicListener.accept()` lazily spawn via 3. the supervisor's shielded `finally` joins the endpoint and all
`trio.lowlevel.current_task().parent_nursery` (**rejected** — connection feeders, atomically detaches the queue, closes every
fragile, implicit). Do the explicit `ep=` kwarg. queued stream, releases their leases, and closes the queue;
4. only after that finalizer finishes, the supervisor sets its
completion event;
5. `aclose()` waits under a shield for that event and returns;
concurrent closers wait for the same event.
The same supervisor finalizer runs if its parent nursery is
cancelled before someone calls `aclose()`. This makes the
supervisor, not an arbitrarily cancelled caller, the sole final
cleanup owner. Test cancellation at feeder accept, stream
construction, queue transfer, `accept()` wakeup, and each close
checkpoint; no feeder may outlive the listener and no queued
lease may survive completion.
This needs two explicit, typed references in the module-level
listener call: `server_ep=` is the IPC server `Endpoint` that owns
`listen_tn`, while `actor_tpt=` is the already-open
`QuicActorEndpoint` whose iroh accept API supplies connections.
Store `actor_tpt` on the server endpoint during actor transport
bootstrap and pass both keyword-only arguments; socket backends
ignore `actor_tpt`. `Endpoint.start_listener()` then stores the
listener's already-resolved address instead of calling
`getsockname()`.
### 3.4 `maddr` ### 3.4 `maddr`
Multiaddr already standardizes the pieces: Expected multiaddr spellings for direct QUIC and relay routes are
**step-0 verification items**, not assumptions:
``` ```
/ip4/<h>/udp/<p>/quic-v1 # direct /ip4/<h>/udp/<p>/quic-v1 # direct
@ -444,19 +568,16 @@ Multiaddr already standardizes the pieces:
/dns/<relay-host>/tcp/443/tls/ws/p2p/<node> # relay-ish /dns/<relay-host>/tcp/443/tls/ws/p2p/<node> # relay-ish
``` ```
- primary form: `/p2p/<node-id>` alone is a legal maddr and is - Do not emit NodeId alone in the first backend: without enabled
the *only* required component for iroh dialling — relay + discovery it would discard the route required by the complete
direct addrs are discovery hints. So `mk_maddr()` emits `IrohAddress`. `mk_maddr()` must preserve NodeId, ALPN, relay
`/p2p/<node_id>` and, when known, prefixes the direct URL, and all direct addresses, or return a canonical Tractor
`/ip4/../udp/../quic-v1/`. string form that does until a multiaddr grammar can round-trip
- `/p2p/` values are multihash-encoded peer ids; an iroh node-id every field.
is a raw ed25519 key. Converting requires the identity - Verify whether an iroh NodeId can losslessly map to `/p2p/`.
multihash + libp2p key protobuf wrapper. **Decide**: emit the If not, use a tractor-local `/iroh/<node-id>` segment rather
raw node-id under a *tractor-local* `/iroh/<node-id>` segment than pretending to be a libp2p peer-id. This needs upstream
(needs upstream registration, same track as `wg`/`tipc`, registration, on the same track as `wg`/`tipc` (gh #483).
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
@ -466,24 +587,25 @@ Multiaddr already standardizes the pieces:
## 4. Discovery integration ## 4. Discovery integration
- iroh's node-id addressing means the `tractor` registrar can - The registrar stores the complete `IrohAddress`, not only a
hold `IrohAddress`es that are **reachable from anywhere** with NodeId. Registration is forbidden until endpoint address
no port-forwarding — that is the headline feature. The resolution has produced that descriptor. If route hints change
registrar itself works unchanged. later, dynamic re-registration is a follow-up; the spike uses
- iroh has its own discovery (DNS/pkarr/mdns). **Out of scope**; the pre-registration snapshot.
note in the follow-up that `tractor.discovery` could - Optional iroh discovery mechanisms and their names/capabilities
eventually delegate to it, which would be the direct analogue are step-0 verification items and out of scope for the first
of plan 01's TIPC-topology idea. backend. No NodeId-only reachability claim is made.
- relay servers: default to n0's public relays for the demo, - Relay configuration belongs to `QuicActorEndpoint` creation,
document self-hosting (docs.iroh.computer's dedicated-infra not `start_listener()`, because dialing and listening reuse the
page is linked from #353), and make the relay set a same endpoint. The demo's relay choice and self-hosted option
`start_listener()` kwarg. are selected only after step 0 verifies the pinned API.
## 5. Security note ## 5. Security note
QUIC is TLS-1.3-always and iroh authenticates by node-id, so The transport-security and NodeId-authentication properties of the
this backend is the first `tractor` transport with real pinned iroh stack are **step-0 documentation-verification items**.
transport security and peer authentication. Two things follow: Claim only the properties supported by that version's source and
docs. Two design consequences remain:
1. an **allowlist hook** — an actor should be able to reject 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
@ -497,59 +619,72 @@ transport security and peer authentication. Two things follow:
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 procs. Fills in §1.1. Timebox it. bi-stream between two processes. Fill §1.1 with generated ABI,
1. prep PR: annotation widening + `rebind_from_sockname` gate + endpoint resolution, close/join, and error observations. Probe
`transport_from_stream()` `tpt_key` dispatch + `ep=` kwarg on cancel at every generated lifecycle phase. Timebox it and use
`start_listener()` + lazy `default_lo_addrs()`. **No new the fallback if any mandatory ownership fact stays unknown.
backend.** Full suite green on tcp *and* uds. 1. prep PR: tagged address migration, annotation widening,
2. `_uniffi_trio.py` + its tests (drive one iroh async call non-socket listener reconciliation, `tpt_key` dispatch,
under bare `trio.run()`; assert no asyncio loop; assert typed `server_ep=`/`actor_tpt=` listener inputs, and lazy
cancellation frees the future). default addresses. **No new backend.** Keep tcp and uds
3. `QuicMsgStream` + tests against a *loopback* iroh endpoint behavior unchanged.
pair in one process (no `tractor` runtime): send/recv, clean 2. bootstrap prep: pass `ChildTransportBootstrap` through every
EOF → `b''`, reset → `BrokenResourceError`, use-after-close process-launch path and add the transport nursery around child
`ClosedResourceError`. parent-dial, service, deregistration, and teardown. Resolve the
4. `QuicListener` + `start_listener()` + `IrohAddress` + endpoint address before registration. Add no iroh-specific
key-file mgmt. global state.
5. `MsgpackQuicStream(MsgpackTransport)` + `connect_to()` + 3. `_uniffi_trio.py` supervisor + lifecycle fault-injection tests:
`maybe_open_context()` connection pooling. no asyncio loop, one owner/complete/free, callback retention,
6. registration tables + `--tpt-proto quic` + full suite. bounded caller handoff, and joined durable cleanup.
7. maddr + docs + a two-host example (pairs with #482's format). 4. `QuicActorEndpoint` + provisioned registrar descriptor +
loopback direct-address tests; prove one endpoint handles dial,
listen, address lookup, and ordered teardown.
5. `QuicMsgStream` + exhaustive error-normalization and lease
release tests against the loopback endpoint pair.
6. `QuicListener` supervisor + cancellation-at-every-checkpoint
tests, including queued-stream draining and feeder joins.
7. `MsgpackQuicStream`, full-key connection pooling, registration
tables, and `--tpt-proto quic`; then run the full suite.
8. routable maddr/string form + docs + a two-host example (pairs
with #482's format).
## 7. Testing ## 7. Testing
- capability predicate `is_quic_available()``iroh` importable - capability predicate `is_quic_available()``iroh` importable
*and* the uniffi driver symbol present at the pinned version. *and* every step-0-recorded driver symbol present at the pinned
Same `pytest.fail`-early hook as plan 01 §7.2. version. 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.
- expect to need **timeout headroom**: iroh endpoint bind + - Measure endpoint bind and first-connect latency in step 0; do
first connect (relay discovery) is orders of magnitude slower not assume a multiplier. Before changing a deadline, rule out
than a UDS bind. Before touching any test deadline, rule out the project's CPU-throttle false-positive, then prefer one
the CPU-throttle false-positive (see the project's per-proto harness multiplier over individual-test edits.
`env_cpu_throttle_masquerades_as_regression` note); then, if - Use the step-0-verified relay-disable configuration with direct
real, add a per-proto timeout multiplier to the test harness loopback addresses for default CI. Mark separately verified
rather than editing individual tests. relay tests `pytest.mark.net` and keep them out of default CI.
- a no-network test mode: iroh with relays disabled + - leak checks: assert the actor has one key/endpoint, every FFI
loopback direct addrs only, so CI doesn't depend on n0's operation completed/freed once, all listener feeders joined,
infra. **Make this the default in CI**; mark the relay tests all queued streams closed, every connection lease released,
`pytest.mark.net` and keep them out of the default run. and endpoint close completion observed before actor teardown.
- leak checks: assert every `SecretKey`/`Endpoint` is closed on - address-ordering check: block registration until a descriptor
actor teardown (an `Endpoint` left open holds UDP sockets and with NodeId, ALPN, and at least one route is published; reject
relay connections; a leak here shows up as hung tests, not sentinel, NodeId-only, and post-registration mutation cases.
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` |
| rust-thread callback → trio wakeup mishandled (segfault / lost wakeup / un-cancellable task) | strong ref on the ctypes trampoline; `run_sync_soon` only; **bounded** shielded cancel-drain; run the `conc-anal` skill over the bridge | | callback wakeup/lifetime semantics differ from the hypothesis | step-0 source + probe gate; retain callback/data through verified quiescence; durable owner; never timeout-free |
| cancelled foreign future never reaches a freeable state | bounded caller handoff to visible actor supervisor; joined graceful shutdown or process-level escalation; never speculative free |
| `iroh` wheel availability for 3.13/3.14 on linux+macos | verify in step 0; if missing, that alone may force the `aioquic` fallback | | `iroh` wheel availability for 3.13/3.14 on linux+macos | verify in step 0; if missing, that alone may force the `aioquic` fallback |
| QUIC latency/jitter destabilizes the existing suite's timing assumptions | per-proto timeout multiplier, relay-less CI mode | | endpoint or route resolution is not ready before parent dial/registration | actor endpoint bootstrap barrier; publish only a complete resolved descriptor |
| `(str, str)` unwrapped form collides with UDS in `wrap_address()` | guarded case ordered first + explicit regression test (§3.2) | | connection closes while a stream still uses it | full-key pool + stream-held leases + exact-once release tests |
| listener close strands feeder tasks or queued streams | listener-owned scope/completion event; cancel, join, drain, then return |
| QUIC latency/jitter destabilizes suite timing assumptions | measure first; per-proto multiplier only if demonstrated; relay-less CI mode |
| address tuple collides with another backend | required `'quic'` tag and exact-shape dispatch |
| scope creep into iroh's docs/blobs/gossip crates | this backend is `Endpoint`+`Connection`+bi-streams only; anything else is a separate issue | | 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

View File

@ -19,35 +19,35 @@ onto `trio` as the library's sans-io layer allows.
--- ---
## 1. What exists today (verified, per #482) ## 1. What exists today (derived from #482)
- `wrap_address()` accepts maddr `str`s (leading-`/` dispatch, - `wrap_address()` accepts maddr `str`s (leading-`/` dispatch,
`_addr.py:262`) but `parse_maddr()` only knows `_addr.py:262`). `parse_maddr()` and `mk_maddr()` support plain
`/ip4|ip6/<h>/tcp/<p>` and `/unix/<p>`; a `.../wg/u<key>` TCP/UDS addresses plus nested, canonical bearer-first `/wg/`
maddr raises `ValueError('Unsupported multiaddr protocol stacks represented locally as `TunnelledAddress` wrappers.
combo')`.
- there is no `wg` proto in the multiaddr *spec* yet, but - there is no `wg` proto in the multiaddr *spec* yet, but
multiformats/py-multiaddr#108 (key form `u<base64url>`) is multiformats/py-multiaddr#108 (key form `u<base64url>`) is
**merged** as of 2026-07-28 (`f86519da`) — and unreleased, the **merged** as of 2026-07-28 (`f86519da`) — and unreleased, the
latest `0.2.0` predating it. Spec registration is still tracked latest `0.2.0` predating it. Spec registration is still tracked
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` - **today's deployable story remains declarative**: run `wg-quick`
out-of-band, parse the maddr, strip to the overlay out-of-band, parse the maddr, strip its wrapper to the overlay
`(host, port)`, verify the pubkey against the live tunnel, `(host, port)`, verify the pubkey in its host-specific role,
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. The repaired `examples/multihost/wg_lan/` implementation derives
from and supersedes #482's original example.
- `Address.namespace` exists in the Protocol - `Address.namespace` exists in the Protocol
(`_addr.py:94-101`, "the if-available OS-specific network (`_addr.py:94-101`, "the if-available OS-specific network
namespace key") and **no backend implements it**. This plan is namespace key"). `TunnelledAddress` implements it from its spec;
its first consumer. no concrete transport backend implements it yet.
## 2. Three layers, three PRs ## 2. Three layers, three PRs
| layer | what | dep | ships | | layer | what | dep | ships |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| **A. declarative** | commit #482's examples; `parse_maddr()` learns `/wg/u<key>` → overlay `Address` + verified pubkey | `multiaddr` (already), `wg(8)` CLI | first | | **A. declarative** | land repaired examples derived from #482; `parse_maddr()` learns `/wg/u<key>``TunnelledAddress` wrappers carrying overlay `Address` values and declared WG pubkeys | `multiaddr`, `py-multibase`, `wg(8)` CLI | first |
| **B. `pyroute2` read/verify** | replace the `subprocess.run(['sudo','wg','show'])` shelling with netlink queries | `pyroute2` extra | second | | **B. `pyroute2` read/verify** | replace the example-local, role-aware async `wg(8)` verification probe with netlink queries | `pyroute2` extra | second |
| **C. `@acm` lifecycle** | create/configure/tear down wg ifaces + netns *from the runtime*, as nested bindspaces; implement `Address.namespace` | `pyroute2` + `CAP_NET_ADMIN` | third | | **C. `@acm` lifecycle** | create/configure/tear down wg ifaces + netns *from the runtime*, consume `Address.namespace` for nested bindspaces, and implement explicit `None` on concrete transports | `pyroute2` + `CAP_NET_ADMIN` + `CAP_SYS_ADMIN` or a userns/helper equivalent | third |
Each is independently valuable and independently reviewable. Each is independently valuable and independently reviewable.
**Do not attempt C first** — the interesting design (nested **Do not attempt C first** — the interesting design (nested
@ -73,28 +73,24 @@ does not create a new address type.** Two candidate encodings;
overlay: Address # e.g. TCPAddress overlay: Address # e.g. TCPAddress
tunnel: WGTunnelSpec # proto-specific, frozen tunnel: WGTunnelSpec # proto-specific, frozen
``` ```
with `.proto_key` **delegating to `overlay.proto_key`** so every with `.proto_key`, `.bindspace`, and `.unwrap()` delegating to
existing table lookup (`_addr_to_transport`, the overlay so transport guards retain their existing meaning
`enable_transports` guard at `_root.py:391`, and **nothing new crosses the wire**. `.namespace` derives from
`transport_from_addr()`) keeps working untouched, and the tunnel spec. Exact-type dispatch through
`.unwrap()` delegating to `overlay.unwrap()` so **nothing new `_addr_to_transport`/`transport_from_addr()` still requires the
crosses the wire**. `.namespace` and `.bindspace` come from wrapper to be stripped (`→ .overlay`) at bind/connect time.
the tunnel spec. The wrapper is stripped (`→ .overlay`) at the - ⚠️ `is_wrapped_addr()` explicitly recognizes
moment of bind/connect. `TunnelledAddress` even though the wrapper is deliberately not
- ⚠️ `is_wrapped_addr()` (`_addr.py:194`) tests in `_address_types`: it has no `MsgTransport` of its own and
`type(addr) in _address_types.values()` — a `bidict` of therefore gets no build-registered proto-key entry.
proto_key→type. `TunnelledAddress` isn't in it and must not
be (it's not 1:1 with a proto). So either add an explicit
`isinstance(addr, TunnelledAddress)` clause there, or give
the wrapper a marker and test structurally. Do the former;
it's two lines and honest.
- the reflection in `Endpoint.start_listener()` - the reflection in `Endpoint.start_listener()`
(`inspect.getmodule(self.addr)`) would resolve to the (`inspect.getmodule(self.addr)`) would resolve to the
*wrapper's* module, not the transport's. **So the wrapper *wrapper's* module, not the transport's. **So the wrapper
must be unwrapped before it reaches `Endpoint`** — i.e. by must be unwrapped before it reaches `Endpoint`** — i.e. by
the bindspace `@acm` (layer C) or by `parse_maddr()` the bindspace `@acm` (layer C) or explicitly via `.overlay`
(layer A). State this loudly in the docstring; it's the #1 or `strip_tunnels()` at each bind/dial boundary (layer A).
way to get this wrong. State this loudly in the docstring; it's the #1 way to get
this wrong.
- (b) add fields to each existing `Address` type. Rejected: - (b) add fields to each existing `Address` type. Rejected:
duplicates tunnel logic per-backend and pollutes `.unwrap()`. duplicates tunnel logic per-backend and pollutes `.unwrap()`.
@ -104,10 +100,10 @@ class WGTunnelSpec(
frozen=True, frozen=True,
): ):
peer_pubkey: str # std-base64 `wg(8)` form peer_pubkey: str # std-base64 `wg(8)` form
bearer: tuple[str, int]|None = None
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
maybe_endpoint: tuple[str, int]|None = None
maybe_allowed_ips: tuple[str, ...] = () maybe_allowed_ips: tuple[str, ...] = ()
``` ```
@ -123,7 +119,7 @@ that the key decodes to exactly 32 bytes, so a truncated key is a
``` ```
/ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616 /ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616
\_______ bearer __________/\__ key __/\______ overlay ______/ \_______ bearer __________/\__ key __/\______ overlay ______/
underlay, wg `ListenPort` the ONLY part we bind underlay, wg `ListenPort` the `MsgTransport` bind
``` ```
The `/wg/` segment is **infix, not suffix** — the segments The `/wg/` segment is **infix, not suffix** — the segments
@ -137,9 +133,8 @@ examples in gh #482) used a *suffix* form
`/ip4/10.0.11.1/tcp/1616/wg/u<key>`. That parses, but it is `/ip4/10.0.11.1/tcp/1616/wg/u<key>`. That parses, but it is
semantically inverted: it puts the overlay addr where the bearer semantically inverted: it puts the overlay addr where the bearer
belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares
no overlay endpoint at all. `parse_wg_maddr()` in no overlay endpoint at all. `tractor.discovery.parse_wg_maddr()`
`examples/multihost/wg_lan/` now rejects it with an actionable now rejects it with an actionable error.
error.
Observed protocol-name lists, for writing the `match`: Observed protocol-name lists, for writing the `match`:
| maddr | `[p.name for p in m.protocols()]` | | maddr | `[p.name for p in m.protocols()]` |
@ -150,11 +145,11 @@ Observed protocol-name lists, for writing the `match`:
- so the three parts have **three different owners**, and only the - so the three parts have **three different owners**, and only the
third is an `Endpoint`: third is an `Endpoint`:
| part | bound by | in the runtime? | | part | socket owner / provisioner | runtime role |
| --- | --- | --- | | --- | --- | --- |
| bearer | kernel, via `wg-quick`/`pyroute2` | no | | bearer | kernel-owned; externally provisioned in layer A, tractor bindspace-provisioned in layer C | control-plane metadata, never an `Endpoint` |
| `/wg/u<key>` | nothing — it's an identity | no, verified out-of-band | | `/wg/u<key>` | nothing — it's an identity | parsed and explicitly verified |
| overlay | `tractor`'s `IPCServer` | **yes**, as `.overlay` | | overlay | `tractor`'s `IPCServer` | application `MsgTransport`, as `.overlay` |
This owner-split is the real axis of the design, *not* whether This owner-split is the real axis of the design, *not* whether
the maddr stack is "composed" (it is). the maddr stack is "composed" (it is).
@ -168,7 +163,7 @@ Observed protocol-name lists, for writing the `match`:
| need | API | | need | API |
| --- | --- | | --- | --- |
| isolate the bearer | `ma.decapsulate_code(P_WG)` | | isolate the bearer | `ma.decapsulate_code(_wg_proto_code())` |
| drop the overlay, keep bearer+key | `ma.decapsulate(overlay_ma)` | | drop the overlay, keep bearer+key | `ma.decapsulate(overlay_ma)` |
| per-seg maddrs | `ma.split()` | | per-seg maddrs | `ma.split()` |
| rejoin a seg tail | `Multiaddr.join(*segs)` | | rejoin a seg tail | `Multiaddr.join(*segs)` |
@ -184,22 +179,18 @@ Observed protocol-name lists, for writing the `match`:
silently returns the **first** match, i.e. the bearer's host. silently returns the **first** match, i.e. the bearer's host.
Always call it on a peeled sub-maddr, never the whole stack. Always call it on a peeled sub-maddr, never the whole stack.
- `parse_maddr()` gains a case on - keep the existing 2-proto cases byte-identical; add
`[('ip4'|'ip6'), 'udp', 'wg', ('ip4'|'ip6'), <overlay-l4>]` `case _ if 'wg' in proto_names:` after them.
peel w/ the API above, decode the multibase key to std-base64, - that case delegates to `parse_wg_maddr()`, which repeatedly
and return `TunnelledAddress(overlay=..., tunnel=WGTunnelSpec( peels the last `/wg/`, decodes its key to std-base64, records
...))` w/ the bearer recorded in the spec. its bearer in `WGTunnelSpec`, and wraps the overlay in one
- keep the existing 2-proto cases byte-identical; add the new `TunnelledAddress` per segment.
case *after* them.
- nesting (wg-in-wg) falls out of `.decapsulate_code()` cutting
at the *last* occurrence — peel repeatedly rather than
recursing through a bespoke splitter.
- `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
`Multiaddr('/…/wg/u…')` parses — but off a `[tool.uv.sources]` `Multiaddr('/…/wg/u…')` parses off a PEP 621 direct-revision pin,
`rev` pin, since no release carries the codec. Gate the tests since no release carries the codec. Gate parser entry on
on `_have_wg_maddr_proto()`, implemented as `_wg_proto_code()`, implemented as
`protocols.protocol_with_name('wg')` under `protocols.protocol_with_name('wg')` under
`except ProtocolNotFoundError`. Do **not** probe by parsing a `except ProtocolNotFoundError`. Do **not** probe by parsing a
dummy like `Multiaddr('/wg/uAAAA')` — the codec enforces a dummy like `Multiaddr('/wg/uAAAA')` — the codec enforces a
@ -207,20 +198,33 @@ Observed protocol-name lists, for writing the `match`:
**not** hand-roll a `wg` parser in `tractor` — the whole point **not** hand-roll a `wg` parser in `tractor` — the whole point
of #429 was dropping the NIH parser. of #429 was dropping the NIH parser.
### 3.3 verification helper (pure, composable) ### 3.3 pure parser helpers + explicit verification
Port #482 §2's helpers into `tractor/discovery/_tunnel.py` as The parser/key-codec helpers live in
*pure functions* + one impure probe, cleanly separated: `tractor/discovery/_tunnel.py`; the impure verifier remains
example-local until layer B:
```python ```python
def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure def parse_wg_maddr(maddr: str|Multiaddr) -> TunnelledAddress: ...
def wg8_pubkey(multibase_key: str) -> str: ... # pure def mb_pubkey(wg8_key: str) -> str: ...
def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # impure probe def wg8_pubkey(multibase_key: str) -> str: ...
async def verify_wg_key(
addr: TunnelledAddress,
role: Literal['local', 'peer'],
iface: str|None = None,
timeout: float = 5,
inspection: str|None = None,
) -> bool: ... # example-local impure probe
``` ```
In layer A `verify_wg_peer()` may shell out (`wg show <if> In layer A `verify_wg_key()` may shell out to role-specific
peers`), but it must be a *single* function so layer B swaps `wg show <if> public-key|peers` queries, but it must be a *single*
only its body. Never call it implicitly from async, time-bounded function so it never blocks trio's run thread
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).
@ -281,7 +285,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_peer()`'s reviewable, behaviour-preserving swap of `verify_wg_key()`'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
@ -306,7 +310,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_peer()` becomes a thin composition over the two. and `verify_wg_key()` 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)`.
--- ---
@ -319,40 +323,117 @@ data-structure which can easily be passed to nested `@acm`s
which consecutively setup nested net bindspaces for binding the which consecutively setup nested net bindspaces for binding the
endpoint addrs"*. endpoint addrs"*.
Layer C is where tractor takes ownership of bindspace orchestration.
For a fully bootstrapped deployment it may create the netns and wg
iface, configure peers/routes, and ask the kernel to establish the
bearer's UDP `ListenPort` through netlink/`pyroute2`. "Kernel-owned"
describes the data-plane socket, not who provisions it: tractor owns
the lifecycle while `Endpoint`/`MsgTransport` remain responsible only
for the overlay application socket.
### 5.1 the composition ### 5.1 the composition
The maddr describes the composed network path and can be used as
either a source/listen or destination/dial handle. It does **not**
select the local instance of that network stack. A netns, VRF,
interface, user namespace, or equivalent platform resource is
orthogonal augmentation carried alongside/below the maddr.
Keep two bindspace representations with deliberately different
lifetimes:
```python ```python
class BindspaceSpec(msgspec.Struct, frozen=True):
'''Serializable spawn/config declaration.'''
kind: str # `netns`, later `vrf`, ...
key: str|None # requested name/key, if any
class BindspaceIdentity(msgspec.Struct, frozen=True):
'''Stable identity of the realized platform resource.'''
kind: str
key: str|None
inode: int|None # Linux namespace identity
class BindspaceHandle:
'''Scoped, non-serializable capability for one live bindspace.'''
spec: BindspaceSpec
identity: BindspaceIdentity
namespace_fd: int|None
ownership: Literal['owned', 'borrowed']
@acm @acm
async def open_bindspace( async def open_bindspace(
addr: TunnelledAddress, spec: BindspaceSpec,
) -> AsyncGenerator[Address, None]: *,
role: Literal['listen', 'dial'],
) -> AsyncGenerator[BindspaceHandle, None]:
''' '''
Enter the net-bindspace implied by `addr`'s tunnel stack, Provision/borrow one bindspace and yield its live capability.
yielding the *overlay* `Address` ready to bind/connect.
Nests: one `@acm` per tunnel segment, outermost-first, so
a 2-deep stack is just two nested `async with`s and the
teardown order is guaranteed by `trio`.
''' '''
``` ```
with per-tunnel-kind implementations: The exact field set remains design work; the required split does not:
`BindspaceSpec` crosses config/spawn serialization, while
`BindspaceHandle` contains live OS resources (especially an open
namespace FD), pins identity/lifetime, and must never cross msgpack.
An FD is a stronger capability than a namespace name: it avoids
name-resolution TOCTOU, survives rename/unlink, and identifies the
exact namespace the parent provisioned.
`open_bindspace()` is **not** an address factory and does not return a
`TunnelledAddress`. At the declaration layer, listener allocation can
use the handle to replace an overlay while preserving every tunnel:
```python
async with open_bindspace(
bindspace_spec,
role='listen',
) as bindspace:
listen_decl = declared_addr.get_random(
bindspace=bindspace,
)
transport_addr = strip_tunnels(listen_decl)
```
That sketch intentionally leaves the `.get_random()`/bindspace value
contract open. A concrete transport call returns a concrete overlay;
a declaration-level call may replace the overlay and return a new
`TunnelledAddress`. In either case wrappers remain until the final
transport bind/dial boundary, where `strip_tunnels()` is mandatory.
Per-platform provisioning still composes one resource context per
tunnel/bindspace layer:
```python ```python
@acm @acm
async def open_netns(name: str) -> AsyncGenerator[None, None]: ... async def open_netns(
spec: BindspaceSpec,
role: Literal['listen', 'dial'],
) -> AsyncGenerator[BindspaceHandle, None]: ...
@acm @acm
async def open_wg_iface(spec: WGTunnelSpec) -> AsyncGenerator[WGTunnelSpec, None]: ... async def open_wg_iface(
spec: WGTunnelSpec,
bindspace: BindspaceHandle,
role: Literal['listen', 'dial'],
) -> AsyncGenerator[WGTunnelSpec, None]: ...
``` ```
and a driver that folds a list of specs into nested contexts and a driver that folds a list of specs into nested contexts
(`contextlib.AsyncExitStack` for the N-deep case). The (`contextlib.AsyncExitStack` for the N-deep case). The
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door: `parse_endpoints()` API (`_multiaddr.py:189`) is the front door:
it already returns `dict[name, list[Address]]` and the its `ParsedEndpoints` values already contain
`multiaddr_declare_eps.md` sketch anticipates the recursive `Address|TunnelledAddress` declarations and preserve each tunnel
`dict[str, list[Address]]|dict[...]` return for tunnelled stack for the eventual bindspace handler. It carries declarations;
entries. Extend it to carry the tunnel stack, not to *enter* it. it does not *enter* their bindspaces.
The caller supplies `role`; do not infer it from maddr shape. The same
composed maddr can name a server source or client destination, and the
required local provisioning/ownership differs (§5.3).
### 5.2 `Address.namespace`, at last ### 5.2 `Address.namespace`, at last
@ -369,6 +450,13 @@ entries. Extend it to carry the tunnel stack, not to *enter* it.
`Endpoint.pformat()`, `_server.py:645`). Fill that in; it's `Endpoint.pformat()`, `_server.py:645`). Fill that in; it's
the cheapest possible proof the layer is wired. the cheapest possible proof the layer is wired.
Use `github/ns_aware@e4688cad` as prototype evidence, not code to
cherry-pick unchanged. Its `/proc/<pid>/ns/<type>` inode reader and
`ip netns identify` probe establish the useful `(key, inode)` identity
pair. Layer C should move that shape into `BindspaceIdentity`, avoid a
subprocess where netlink/procfs suffices, and hold the namespace FD in
`BindspaceHandle` to pin the identity.
### 5.3 the netns/process reality — read this before designing ### 5.3 the netns/process reality — read this before designing
**The headline consequence, stated up front**: netns is a **The headline consequence, stated up front**: netns is a
@ -396,26 +484,52 @@ server bound in the old namespace.
- entering a netns is *process-global-ish and irreversible-ish* - entering a netns is *process-global-ish and irreversible-ish*
in practice. Therefore: **netns membership belongs to the in practice. Therefore: **netns membership belongs to the
actor process, decided before the runtime binds**, not to a actor process, decided before the runtime binds**, not to a
mid-life `@acm`. Design: mid-life actor API. Design:
- the root/parent decides the netns for a subactor and passes - the root/parent decides the `BindspaceSpec`, provisions or
it in the spawn spec (there's already borrows it, and passes the spec plus an inherited/transferred
namespace-FD capability through the spawn backend (there's already
`enable_transports`/`accept_addrs` plumbing at `enable_transports`/`accept_addrs` plumbing at
`_runtime.py:1595-1615` — the netns rides alongside). `_runtime.py:1595-1615` — the netns rides alongside).
- the child, in `_runtime.async_main()` **before** - the child spawn/bootstrap trampoline calls `setns()` **before**
`IPCServer.listen_on()`, enters it. `_runtime.async_main()`, `IPCServer.listen_on()`, parent-channel
- the mid-life `@acm` form is then only for the *root* / connection, or creation of any worker thread/socket.
single-actor case, and for iface creation (which is - only after successful entry does the child drop namespace-entry
genuinely scoped). privileges and initialize the actor runtime.
- a root/single-actor process follows the same ordering: enter during
root bootstrap, never after actor runtime startup.
- iface/route/WG provisioning is genuinely scoped and remains under
the parent/supervisor's `BindspaceHandle` context.
- document the constraint rather than hiding it; a - document the constraint rather than hiding it; a
`RuntimeError` if `open_netns()` is entered after any `RuntimeError` if namespace entry is attempted after bootstrap.
listener exists. - capabilities: iface/route/WG configuration needs `CAP_NET_ADMIN`;
- privileges: iface/netns creation needs `CAP_NET_ADMIN`. creating or entering a Linux network namespace normally requires
Never `sudo` from inside the runtime. Two supported modes: `CAP_SYS_ADMIN` in the owning user namespace. Never `sudo` from
inside the runtime. A privileged parent/helper should provision the
stack and open the namespace FD; the child receives only the scoped
capability and temporary authority needed to enter it, then drops
that authority before actor code runs. This separates create/config
authority from enter/use authority and fits user-namespace/capability
deployments without granting every actor broad ambient caps.
Two supported modes remain:
(i) pre-provisioned out-of-band (layers A/B — the default, (i) pre-provisioned out-of-band (layers A/B — the default,
and what #482 documents), (ii) runtime-managed when the and what #482 documents), (ii) runtime-managed when the supervising
process already holds the cap. Detect with a cheap process/helper holds the required caps. Probe exact required caps and
`os.geteuid()==0 or CAP_NET_ADMIN in /proc/self/status` *fail loudly with an actionable message* otherwise.
probe and *fail loudly with an actionable message* otherwise. - role semantics are explicit:
- `listen`: may create/own the local bindspace, iface, routes, WG
peer/listener state, and random local overlay; lifetime normally
extends through all listeners and the actor process.
- `dial`: may borrow an actor-wide bindspace or ensure local routing
and tunnel state reaches the remote stack; it does not own the
remote maddr and may need no new local resource at all.
- source/destination use is an operation property, never permanently
encoded into the maddr or inferred from segment ordering.
- teardown follows capability ownership, not just address type:
- owned listener bindspaces tear down after endpoints/channels and
the actor process have exited;
- borrowed dial/actor-wide bindspaces only release their handle;
- nested resources exit inside-out, but shared resources remain until
their owning supervisor drops the final capability.
- teardown must be idempotent and tolerant: an iface/netns - teardown must be idempotent and tolerant: an iface/netns
already gone must not strand the rest of the teardown — the already gone must not strand the rest of the teardown — the
exact lesson `_uds.close_listener()`'s `FileNotFoundError` exact lesson `_uds.close_listener()`'s `FileNotFoundError`
@ -426,14 +540,22 @@ server bound in the old namespace.
- unit: fold-N-tunnel-specs-into-nested-`@acm`s, with fakes; assert - unit: fold-N-tunnel-specs-into-nested-`@acm`s, with fakes; assert
enter/exit ordering (outermost-last-out) via a trace list. enter/exit ordering (outermost-last-out) via a trace list.
- integration, gated on `CAP_NET_ADMIN` (skip otherwise, and in - integration, gated on `CAP_NET_ADMIN` plus `CAP_SYS_ADMIN` in the
CI run it in a `--cap-add NET_ADMIN` container job): create two owning user namespace (or a tested userns/helper equivalent; skip
netns + a wg pair entirely in-process, boot a `tractor` root in otherwise). In CI, grant both capabilities explicitly. Create two
one and a subactor in the other, `find_actor()` across the netns + a wg pair entirely in-process, boot a `tractor` root in one
tunnel. This is a *fantastic* test to have and is fully and a subactor in the other, then `find_actor()` across the tunnel.
self-contained — no second host, no `sudo` in the test body. This is fully self-contained — no second host and no `sudo` in the
test body.
- the `to_thread`-netns-mismatch regression from §5.3, written - the `to_thread`-netns-mismatch regression from §5.3, written
**first** (red), then the fix (green), per project convention. **first** (red), then the fix (green), per project convention.
- bootstrap ordering: assert the child reports the expected namespace
inode before parent-channel connect and listener creation.
- FD capability: rename/unlink the namespace name after opening its FD
and prove child entry still selects the pinned inode.
- privilege drop: prove actor code lacks provisioning caps after entry.
- role/ownership: fake listen/dial resources and assert owned listener
teardown versus borrowed dial-handle release.
--- ---
@ -467,8 +589,12 @@ consider doing it *first* for exactly that reason.
| risk | mitigation | | risk | mitigation |
| --- | --- | | --- | --- |
| `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first | | `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first |
| py-multiaddr#108 merged but unreleased | `[tool.uv.sources]` `rev` pin + `_have_wg_maddr_proto()` gate; layer A's overlay-addr path works regardless | | namespace name is renamed/replaced between provision and spawn | pass an open namespace FD; verify `(key, inode)` after child entry |
| `TunnelledAddress` leaks into `Endpoint` and breaks `inspect.getmodule()` | unwrap at parse/bindspace boundary; assert `not isinstance(ep.addr, TunnelledAddress)` in `Endpoint.__post_init__` | | child starts sockets/threads before `setns()` | enter in the spawn bootstrap trampoline before `_runtime.async_main()`; assert inode ordering |
| ambient capabilities leak into actor app code | split provision/enter authority and drop caps before runtime initialization |
| dial path tears down a shared actor bindspace | encode ownership in `BindspaceHandle`; borrowed handles never remove resources |
| py-multiaddr#108 merged but unreleased | PEP 621 direct-revision pin + `_wg_proto_code()` gate; replace with a release floor once published |
| `TunnelledAddress` leaks into transport reflection/type dispatch | keep wrappers through declaration/bindspace handling, call `strip_tunnels()` at channel/endpoint boundaries, and retain the boundary regressions |
| privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default | | privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default |
| pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) | | pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) |
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` | | netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |

View File

@ -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, the 10-item registration checklist, the test-harness duck-type, registration and address-selection wiring, the
plumbing, the code-style rules). The three plans assume it and test-harness plumbing, the code-style rules). The three plans
document only their own deltas. assume it and document only their own deltas.
| plan | issue | dep | size | lands | | plan | issue | dep | size | lands |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
@ -23,10 +23,12 @@ 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 contract boilerplate, zero new deps, and it buys kernel-native
*kernel-native* service discovery: `bind()` publishes, service primitives: `bind()` publishes, known-address
`connect()`-by-name resolves — no registrar in the loop. `connect()` resolves, and topology events report publication
(`modprobe tipc` is required; hard-gate everything.) changes. Actor-name lookup, registrar state and split-brain-safe
election remain separate work. (`modprobe tipc` is required;
hard-gate everything.)
- **QUIC's cost is entirely in two adapters**, not in QUIC. The - **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 —

View File

@ -21,16 +21,16 @@ Supersedes the example set in gh
/ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616 /ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616
\____ wg bearer ___________/\__ key __/\____ tractor ep _____/ \____ wg bearer ___________/\__ key __/\____ tractor ep _____/
underlay, wg `ListenPort` overlay, on the wg iface underlay, wg `ListenPort` overlay, on the wg iface
(kernel/`wg(8)` owns it) (the ONLY part tractor binds) (kernel owns the socket) (`MsgTransport` binds this)
``` ```
Three parts, three different owners: Three parts, three different owners:
| part | who binds it | in the runtime? | | part | socket owner / provisioner | runtime role |
| --- | --- | --- | | --- | --- | --- |
| `/ip4/../udp/51820` bearer | kernel via `wg-quick`/`pyroute2` | no | | `/ip4/../udp/51820` bearer | kernel-owned; `wg-quick` now, tractor bindspace later | control-plane metadata |
| `/wg/u<key>` | nothing — it's an identity | no, verified out-of-band | | `/wg/u<key>` | nothing — it's an identity | parsed, verified explicitly |
| `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | **yes**, as `.overlay` | | `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | application `MsgTransport` |
Verified against py-multiaddr Verified against py-multiaddr
[#108](https://github.com/multiformats/py-multiaddr/pull/108): [#108](https://github.com/multiformats/py-multiaddr/pull/108):
@ -41,31 +41,29 @@ this composed form parses and round-trips
py-multiaddr #108 is **merged** (2026-07-28) but ships in no py-multiaddr #108 is **merged** (2026-07-28) but ships in no
release yet — the latest `0.2.0` (2026-03-17) predates it and has release yet — the latest `0.2.0` (2026-03-17) predates it and has
no `wg` codec. So `pyproject.toml` carries a temporary no `wg` codec. So `pyproject.toml` temporarily pins the merge commit
`[tool.uv.sources]` `rev` pin at the merge commit, and a plain in its PEP 621 dependency metadata, and a plain
```bash ```bash
uv sync 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. You also need `multibase`: release carries the codec. `py-multibase` is a direct project
dependency, so 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
hand-split fallback. `_have_wg_maddr_proto()` is the predicate. hand-split fallback. `_wg_proto_code()` performs the capability
check before parsing.
Every peel and re-compose here goes through `py-multiaddr`'s own Every peel and re-compose here goes through `py-multiaddr`'s own
tunnel API (`.decapsulate_code()`, `.split()`, `.join()`, 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 a tunnel stack just as much parser, and that applies to peeling nested tunnel stacks just as
as to decoding one proto. much as to decoding one proto.
## 0. tunnel setup (out-of-band, both hosts) ## 0. tunnel setup (out-of-band, both hosts)
@ -106,8 +104,11 @@ AllowedIPs = 10.0.11.1/32
PersistentKeepalive = 25 PersistentKeepalive = 25
``` ```
Note how `ListenPort` and `Endpoint` are exactly the maddr's This example configures host A's `ListenPort` and host B's
bearer segment, and `[Interface] Address` is its overlay host. `Endpoint` from the maddr bearer, and configures host A's
`[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
@ -118,16 +119,40 @@ ping -c1 10.0.11.1 # from B
```bash ```bash
python -c " python -c "
import base64, multibase from tractor.discovery import mb_pubkey
key = open('wg_pub.key').read().strip() key = open('wg_pub.key').read().strip()
print(multibase.encode('base64url', base64.b64decode(key)).decode()) print(mb_pubkey(key))
" "
``` ```
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. run ## 2. verify the keys
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
@ -137,6 +162,17 @@ 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.
@ -153,24 +189,23 @@ 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_peer()` is now a separate, explicitly to the parse; `verify_wg_key()` is now a separate, explicitly
composed step that the caller invokes. A parser that shells composed step for inspection-capable callers. A parser that
out is a nasty surprise. shells 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. `wg show` works unprivileged for read on most never escalate or run `tractor` as root. Privileged tunnel
setups; if yours needs root, run the script as root rather setup and key inspection are separate shell steps.
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 `TunnelledAddress`, 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 table is a `bidict` registered in `_address_types` — that registry maps available
(1:1 proto-key↔type) and `_addr_to_transport` wants a transport keys to concrete address types, and
`MsgTransport` per addr-type, which `wg` doesn't have. `_addr_to_transport` wants a `MsgTransport` per addr-type,
which `wg` doesn't have.
## next ## next
`WGTunnelledAddr` is deliberately example-local. Promoting it to Layer A's `TunnelledAddress` and native maddr parser now live in
`tractor.discovery` as a `TunnelledAddress` whose `tractor.discovery`. Next, replace this example's `wg(8)` verification
`.proto_key`/`.unwrap()` delegate to `.overlay`, plus probe with `pyroute2`, then add `open_bindspace()` `@acm`s which
`open_bindspace()` `@acm`s that create/tear down the iface + create/tear down the iface and netns.
netns via `pyroute2`, is layers A→C of the plan doc.

View File

@ -8,15 +8,18 @@ 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 tractor.discovery import (
from wg_maddr import ( TunnelledAddress,
mk_maddr,
parse_wg_maddr, parse_wg_maddr,
verify_wg_peer,
WGTunnelledAddr,
) )
from wg_maddr import verify_wg_key
# bearer = host A's underlay `(ip, wg ListenPort)` # bearer = host A's underlay `(ip, wg ListenPort)`
# key = host A's OWN tunnel pubkey # key = host A's OWN tunnel pubkey
# overlay = the ep `tractor` binds, on the wg iface's addr # overlay = the ep `tractor` binds, on the wg iface's addr
@ -33,27 +36,36 @@ async def echo(msg: str) -> str:
async def main(): async def main():
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR) addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
assert verify_wg_peer(addr), ( inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
f'wg pubkey from maddr not active on wg0 !\n' if not await verify_wg_key(
addr,
role='local',
inspection=inspection,
):
raise RuntimeError(
f'Maddr key is not wg0 local public key!\n'
f'maddr: {WG_MADDR}\n' f'maddr: {WG_MADDR}\n'
f'key: {addr.peer_pubkey}\n' f'key: {addr.tunnel.peer_pubkey}\n'
) )
print( print(
f'wg bearer (kernel-owned): {addr.bearer}\n' f'wg bearer (kernel-owned): {addr.tunnel.bearer}\n'
f'tractor overlay ep: {addr.overlay}\n' f'tractor overlay ep: {addr.overlay}\n'
) )
async with tractor.open_nursery( async with tractor.open_nursery(
# XXX only `.overlay` crosses into the runtime; the bearer # XXX only `.overlay` crosses into the runtime; the bearer
# + key are iface-layer concerns `tractor` never binds. # + key are bindspace metadata, never `Endpoint` addrs.
registry_addrs=[addr.overlay], registry_addrs=[addr.overlay],
enable_transports=[addr.overlay_proto], enable_transports=[addr.overlay.proto_key],
) as an: ) as an:
overlay_host, _ = addr.unwrap()
await an.start_actor( await an.start_actor(
'echo_srv', 'echo_srv',
enable_modules=[__name__], bind_addrs=[(overlay_host, 0)],
enable_transports=[addr.overlay.proto_key],
enable_modules=['host_a_srv'],
) )
print(f'echo_srv up on\n {addr.maddr}\n') print(f'echo_srv up on\n {mk_maddr(addr)}\n')
await trio.sleep_forever() await trio.sleep_forever()

View File

@ -6,15 +6,17 @@ Host B: workstation dialing host A's actor tree through the
''' '''
from __future__ import annotations from __future__ import annotations
import os
import tractor import tractor
import trio import trio
from tractor.discovery import (
TunnelledAddress,
parse_wg_maddr,
)
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 verify_wg_key
parse_wg_maddr,
verify_wg_peer,
WGTunnelledAddr,
)
# same maddr as host A: A's bearer, A's key, A's overlay ep # same maddr as host A: A's bearer, A's key, A's overlay ep
WG_MADDR: str = ( WG_MADDR: str = (
@ -22,23 +24,33 @@ 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: TunnelledAddress = parse_wg_maddr(WG_MADDR)
assert verify_wg_peer(addr), ( inspection: str | None = os.environ.get('WG_KEY_INSPECTION')
f'wg pubkey from maddr not a peer on wg0 !\n' if not await verify_wg_key(
addr,
role='peer',
inspection=inspection,
):
raise RuntimeError(
f'Maddr key is not a configured wg0 peer!\n'
f'maddr: {WG_MADDR}\n' f'maddr: {WG_MADDR}\n'
f'key: {addr.tunnel.peer_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_key],
), ),
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(

View File

@ -1,299 +1,85 @@
# tractor: distributed structured concurrency. # tractor: distributed structured concurrency.
r''' r'''
Parse `wg`-tunnelled multiaddrs into `tractor`-ready addrs. Verify `wg` keys declared by tractor's multiaddr parser.
The canonical form (per py-multiaddr #108, verified against its `tractor.discovery.parse_wg_maddr()` owns pure parsing and delegates
upstream merge) nests the *overlay* endpoint **after** the `/wg/` all tunnel peeling to `py-multiaddr`. This example keeps only the
segment: explicit impure probe used by the two-host demo; parsing never shells
out or verifies local interface state implicitly.
The canonical maddr form is:
/ip4/10.0.0.1/udp/51820/wg/u<key>/ip4/10.0.11.1/tcp/1616 /ip4/10.0.0.1/udp/51820/wg/u<key>/ip4/10.0.11.1/tcp/1616
\_______ wg bearer ______/\_ key _/\____ tractor ep _____/ \_______ wg bearer ______/\_ key _/\____ tractor ep _____/
(underlay, wg
`ListenPort`)
Naming follows `py-multiaddr`'s own encapsulation model, where The kernel owns the bearer socket. A future tractor bindspace may
earlier segments *wrap* later ones (`.encapsulate()` appends), so provision it through netlink, but only the overlay is an application
the two roles are: `MsgTransport` endpoint.
- **bearer**: the segs *before* `/wg/`, i.e. the underlay
`(ip, udp-port)` that `wg(8)` itself listens on. Nothing in
`tractor` ever binds this the kernel/`wg` iface owns it.
- **overlay**: the segs *after*, i.e. the addr `tractor` actually
binds/dials. The only part the runtime ever sees.
We deliberately avoid `inner`/`outer` for these two: in a *call*
stack "inner" reads as higher-up and later-called, whereas here
the encapsulated addr is bound *first* and sits deeper in the
maddr two opposite intuitions on one word.
`/wg/u<key>` itself carries the tunnel peer's Curve25519 pubkey
as multibase base64url (std base64 from `wg(8)` contains `/` and
so can't go in a `/`-delimited maddr). It binds nothing at all;
it's an identity, verified out-of-band.
XXX NOTE, `tractor`'s own `parse_maddr()` can't parse this yet
(`ValueError('Unsupported multiaddr protocol combo')`), which is
why this module exists: peel here, hand `.overlay` to the
runtime.
Design rules this module follows (see
`ai/tpt-backends/03_wg_tunnel_bindspace.md`):
- **let `py-multiaddr` do the parsing**. Every peel/compose goes
through `.decapsulate_code()`, `.split()`, `.join()`,
`.encapsulate()` and `.value_for_protocol()`. We hand-roll no
segment splitting whatsoever the whole point of gh #429 was
dropping the NIH parser, and that applies to *peeling a tunnel
stack* every bit as much as to decoding a single proto.
- **parsing is pure**. `parse_wg_maddr()` does no I/O, no
`subprocess`, no netlink. A parser that shells out is a nasty
surprise.
- **verification is an explicit, separate step**. The caller
composes `verify_wg_peer()` when it wants it; nothing implicit.
- **no new `Address` proto-type**. `wg` gets no entry in
`tractor.discovery._addr._address_types` (a `bidict`, so 1:1
proto-key<->type) bc it has no `MsgTransport` of its own. The
tunnel is a *bindspace*, so we carry it beside the overlay
addr and strip to `.overlay` at bind/dial time.
''' '''
from __future__ import annotations from __future__ import annotations
import base64
import subprocess
from typing import Literal from typing import Literal
import msgspec import trio
from multiaddr import Multiaddr
from multiaddr.protocols import P_WG
from tractor.discovery import (
IPProto = Literal['ip4', 'ip6'] TunnelledAddress,
WGTunnelSpec,
class WGTunnelledAddr(
msgspec.Struct,
frozen=True,
):
'''
A `wg`-tunnelled endpoint: the underlay bearer, the tunnel
peer key, and the overlay addr `tractor` binds/dials.
'''
# underlay, owned by `wg(8)`/the kernel — NEVER bound by us
bearer: tuple[str, int]
# tunnel peer pubkey in the std-base64 `wg(8)` form, i.e.
# directly comparable to `wg show <if> peers` output
peer_pubkey: str
# overlay ep: an `UnwrappedAddress` as accepted by
# `tractor.discovery.wrap_address()`
overlay: tuple[str, int]
overlay_proto: Literal['tcp'] = 'tcp'
# kept so `.as_multiaddr()` re-renders the same ip family it
# was parsed from, rather than assuming v4
bearer_ip: IPProto = 'ip4'
overlay_ip: IPProto = 'ip4'
def as_multiaddr(self) -> Multiaddr:
'''
Re-compose the canonical `Multiaddr`, bearer outward-in,
using `.encapsulate()` exactly as py-multiaddr's own
tunneling example does.
'''
b_host, b_port = self.bearer
o_host, o_port = self.overlay
return (
Multiaddr(f'/{self.bearer_ip}/{b_host}/udp/{b_port}')
.encapsulate(
Multiaddr(f'/wg/{mb_pubkey(self.peer_pubkey)}')
)
.encapsulate(
Multiaddr(
f'/{self.overlay_ip}/{o_host}'
f'/{self.overlay_proto}/{o_port}'
)
)
)
@property
def maddr(self) -> str:
'''
The canonical maddr `str` form.
'''
return str(self.as_multiaddr())
def mb_pubkey(wg8_key: str) -> str:
'''
`wg(8)` std-base64 pubkey -> multibase base64url (`u`-prefixed).
'''
import multibase
raw: bytes = base64.b64decode(wg8_key)
return multibase.encode('base64url', raw).decode('ascii')
def wg8_pubkey(mb_key: str) -> str:
'''
Inverse of `mb_pubkey()`: multibase -> `wg(8)` std-base64.
'''
import multibase
raw: bytes = multibase.decode(mb_key)
return base64.b64encode(raw).decode('ascii')
_wg_proto_known: bool|None = None
def _have_wg_maddr_proto() -> bool:
'''
True iff the installed `py-multiaddr` knows the `/wg/` proto,
i.e. carries py-multiaddr#108.
Merged upstream 2026-07-28 (`f86519da`) but in no release as
of `0.2.0`, hence the `[tool.uv.sources]` `rev` pin in
`pyproject.toml`.
Pure predicate; result cached since it can't change without a
reinstall.
'''
global _wg_proto_known
if _wg_proto_known is None:
from multiaddr.protocols import protocol_with_name
from multiaddr.exceptions import ProtocolNotFoundError
try:
protocol_with_name('wg')
_wg_proto_known = True
except ProtocolNotFoundError:
_wg_proto_known = False
return _wg_proto_known
def parse_wg_maddr(
maddr: str|Multiaddr,
) -> WGTunnelledAddr:
'''
Peel a `wg`-tunnelled maddr into its bearer/key/overlay
parts. Pure no I/O.
Every cut is made by `py-multiaddr`, so a malformed maddr
(incl. a `wg` key that isn't exactly 32B) raises out of
`Multiaddr()` rather than yielding a struct quietly built
from garbage segs.
'''
if not _have_wg_maddr_proto():
raise RuntimeError(
f'Installed `py-multiaddr` has no `/wg/` proto!\n'
f'Needs py-multiaddr#108, merged upstream but not\n'
f'yet released; a `uv sync` picks up the pinned rev.\n'
f'maddr: {maddr!r}\n'
)
ma: Multiaddr = (
maddr
if isinstance(maddr, Multiaddr)
else Multiaddr(maddr)
)
segs: list[Multiaddr] = ma.split()
names: list[str] = [
proto.name
for seg in segs
for proto in seg.protocols()
]
if 'wg' not in names:
raise ValueError(
f'Not a `wg`-tunnelled maddr, no `/wg/` segment ??\n'
f'maddr: {ma}\n'
)
# NOTE, `.decapsulate_code()` cuts at the LAST occurrence of
# the proto and keeps the *prefix*, which is exactly the
# bearer. It handles `/wg/` cleanly precisely bc it cuts on
# proto-code and never tries to match an addr value — the
# key seg has no addr of its own.
bearer_ma: Multiaddr = ma.decapsulate_code(P_WG)
overlay_ma: Multiaddr = Multiaddr.join(
*segs[names.index('wg') + 1:]
)
match [proto.name for proto in bearer_ma.protocols()]:
case [('ip4' | 'ip6') as b_ip, 'udp']:
bearer = (
bearer_ma.value_for_protocol(b_ip),
int(bearer_ma.value_for_protocol('udp')),
)
case _:
raise ValueError(
f'Bad `wg` bearer, expected `/ip4|ip6/<h>/udp/<p>`\n'
f'got: {bearer_ma}\n'
f'from maddr: {ma}\n'
)
match [proto.name for proto in overlay_ma.protocols()]:
case [('ip4' | 'ip6') as o_ip, ('tcp') as l4]:
overlay = (
overlay_ma.value_for_protocol(o_ip),
int(overlay_ma.value_for_protocol(l4)),
)
case []:
raise ValueError(
f'`wg` maddr declares no overlay endpoint!\n'
f'A bare `/…/wg/<key>` names only the tunnel; '
f'append the ep `tractor` should bind, e.g.\n'
f' {ma}/ip4/10.0.11.1/tcp/1616\n'
)
case _:
raise ValueError(
f'Unsupported `wg` overlay proto combo\n'
f'got: {overlay_ma}\n'
f'from maddr: {ma}\n'
)
return WGTunnelledAddr(
bearer=bearer,
peer_pubkey=wg8_pubkey(ma.value_for_protocol('wg')),
overlay=overlay,
overlay_proto=l4,
bearer_ip=b_ip,
overlay_ip=o_ip,
) )
def verify_wg_peer( async def verify_wg_key(
addr: WGTunnelledAddr, addr: TunnelledAddress,
iface: str = 'wg0', role: Literal['local', 'peer'],
iface: str | None = None,
timeout: float = 5,
inspection: str | None = None,
) -> bool: ) -> bool:
''' '''
True iff `addr.peer_pubkey` is a configured peer (or our own Verify the declared key in the role required on this host.
pubkey) on `iface`.
IMPURE + explicit by design: never called from A bearer host uses `role='local'`; a dialer uses `role='peer'`.
`parse_wg_maddr()`. 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: neither `parse_wg_maddr()` nor
`tractor.discovery.parse_maddr()` calls this probe.
?TODO, per plan-03 layer B, swap this body for `pyroute2` ?TODO, per plan-03 layer B, swap this body for `pyroute2`
(keeping the signature) and note `setns(2)` is *per-thread*, while retaining the explicit verification boundary.
so a query issued via `trio.to_thread` lands in the ORIGINAL
netns unless `netns=` is passed down.
''' '''
def _wg(*args: str) -> str: spec = addr.tunnel
return subprocess.run( if not isinstance(spec, WGTunnelSpec):
['wg', 'show', iface, *args], raise TypeError(
capture_output=True, f'Unsupported tunnel spec: {type(spec)!r}'
text=True,
check=True,
).stdout
return (
addr.peer_pubkey in _wg('peers').split()
or
addr.peer_pubkey == _wg('public-key').strip()
) )
iface = iface or spec.iface
match role:
case 'local':
field = 'public-key'
case 'peer':
field = 'peers'
case _:
raise ValueError(
f'Unknown WireGuard key role: {role!r}'
)
if inspection is None:
with trio.fail_after(timeout):
proc = await trio.run_process(
['wg', 'show', iface, field],
capture_stdout=True,
check=True,
)
inspection = proc.stdout.decode()
if role == 'local':
return spec.peer_pubkey == inspection.strip()
return spec.peer_pubkey in inspection.split()

View File

@ -48,7 +48,10 @@ dependencies = [
# typed IPC msging # typed IPC msging
"msgspec>=0.20.0", "msgspec>=0.20.0",
"bidict>=0.23.1", "bidict>=0.23.1",
"multiaddr>=0.2.0", # unreleased `/wg/` codec from py-multiaddr#108
"multiaddr @ git+https://github.com/multiformats/py-multiaddr.git@f86519daaa21699023d0037c58cdff600313dd09",
# encode/decode `wg` pubkeys carried by multiaddrs
"py-multibase>=2.0.0,<3",
"platformdirs>=4.4.0", "platformdirs>=4.4.0",
# per-actor `argv[0]` proc-title for OS-level diag tools # per-actor `argv[0]` proc-title for OS-level diag tools
# (`ps`, `top`, `psutil`-backed tooling like `acli.pytree`). # (`ps`, `top`, `psutil`-backed tooling like `acli.pytree`).
@ -169,17 +172,6 @@ sync_pause = {requires-python = ">=3.13, <3.14"}
# linux kernel networking # linux kernel networking
# 'pyroute2 # 'pyroute2
# XXX TEMP, the `/wg/u<key>` maddr proto is MERGED upstream (in
# py-multiaddr#108, 2026-07-28) but is in NO release yet; the
# latest `0.2.0` (2026-03-17) predates the merge by ~4 months.
# Pinned by `rev` (not `branch`) so CI stays reproducible.
#
# Drop this pin (and bump the `multiaddr` dep floor above) the
# moment a release carries the `wg` codec; the only consumer is
# `examples/multihost/wg_lan/`.
# |_https://github.com/multiformats/py-multiaddr/pull/108
multiaddr = { git = 'https://github.com/multiformats/py-multiaddr.git', rev = 'f86519daaa21699023d0037c58cdff600313dd09' }
# ------ tool.uv.sources ------ # ------ tool.uv.sources ------
[tool.uv] [tool.uv]
@ -194,6 +186,9 @@ python-preference = 'system'
# ------ tool.uv ------ # ------ tool.uv ------
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
include = ["tractor"] include = ["tractor"]

View File

@ -0,0 +1,100 @@
'''
Canonical tagged-address decoding and legacy input compatibility.
'''
from pathlib import Path
import pytest
from tractor.discovery._addr import wrap_address
from tractor.ipc._tcp import TCPAddress
from tractor.ipc._uds import UDSAddress
@pytest.mark.parametrize(
'value',
[
('tcp', '127.0.0.1', 1616),
['tcp', '127.0.0.1', 1616],
],
)
def test_decode_tagged_tcp_address(value):
'''
Shape-only decoding cannot distinguish future transport address
forms. Feed canonical tuple and msgpack-style list values through
the compatibility boundary and prove the explicit `tcp` tag
selects the TCP backend and emits the canonical tagged form.
'''
addr = wrap_address(value)
assert type(addr) is TCPAddress
assert addr.unwrap() == ('tcp', '127.0.0.1', 1616)
@pytest.mark.parametrize(
'tag',
['unix', 'uds'],
)
@pytest.mark.parametrize('container', [tuple, list])
def test_decode_tagged_unix_address(
tag: str,
container: type,
):
'''
Multiaddr calls the protocol `unix` while tractor's transport key
remains `uds`. Decode both spellings from tuple/list containers,
normalize them to one `UDSAddress`, and emit the canonical `unix`
spelling.
'''
value = container((tag, '/tmp/tractor/registry.sock'))
addr = wrap_address(value)
assert type(addr) is UDSAddress
assert addr.sockpath == Path('/tmp/tractor/registry.sock')
assert addr.unwrap() == (
'unix',
'/tmp/tractor/registry.sock',
)
@pytest.mark.parametrize(
'value, expected_type',
[
(('127.0.0.1', 1616), TCPAddress),
(['127.0.0.1', 1616], TCPAddress),
(('/tmp/tractor', 'registry.sock'), UDSAddress),
(['/tmp/tractor', 'registry.sock'], UDSAddress),
],
)
def test_decode_legacy_address_forms(
value,
expected_type: type,
):
'''
Existing callers, config, and older msgpack payloads still
provide untagged pairs. Keep tuple/list forms readable while
canonical tagged emission is introduced, proving the writer
migration does not break shipped input behavior.
'''
addr = wrap_address(value)
assert type(addr) is expected_type
assert addr.unwrap()[0] in {'tcp', 'unix'}
def test_tcp_from_native_ipv6_sockname():
'''
`socket.getsockname()` returns a four-item IPv6 sockaddr which is
neither a wire form nor a legacy two-item pair. Preserve it as an
OS compatibility boundary and intentionally ignore unsupported
flow-info/scope-id fields when constructing `TCPAddress`.
'''
addr = TCPAddress.from_addr(
('::1', 1616, 0, 0)
)
assert addr.unwrap() == ('tcp', '::1', 1616)

View File

@ -10,6 +10,14 @@ from types import SimpleNamespace
import pytest import pytest
from multiaddr import Multiaddr from multiaddr import Multiaddr
from tractor.discovery import (
TunnelledAddress,
WGTunnelSpec,
mb_pubkey,
mk_wg_maddr,
parse_wg_maddr,
tunnels_of,
)
from tractor.ipc._tcp import TCPAddress from tractor.ipc._tcp import TCPAddress
from tractor.ipc._uds import UDSAddress from tractor.ipc._uds import UDSAddress
from tractor.discovery._multiaddr import ( from tractor.discovery._multiaddr import (
@ -22,6 +30,19 @@ from tractor.discovery._multiaddr import (
from tractor.discovery._addr import wrap_address from tractor.discovery._addr import wrap_address
_WG_PUBKEY: str = (
'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
)
_WG_PUBKEY_2: str = (
'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8='
)
_WG_MADDR: str = (
f'/ip4/192.168.1.50/udp/51820'
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
f'/ip4/10.0.11.1/tcp/1616'
)
def test_tpt_proto_to_maddr_mapping(): def test_tpt_proto_to_maddr_mapping():
''' '''
`_tpt_proto_to_maddr` maps all supported `proto_key` `_tpt_proto_to_maddr` maps all supported `proto_key`
@ -167,7 +188,7 @@ def test_parse_maddr_tcp_ipv4():
result = parse_maddr('/ip4/127.0.0.1/tcp/1234') result = parse_maddr('/ip4/127.0.0.1/tcp/1234')
assert isinstance(result, TCPAddress) assert isinstance(result, TCPAddress)
assert result.unwrap() == ('127.0.0.1', 1234) assert result.unwrap() == ('tcp', '127.0.0.1', 1234)
def test_parse_maddr_tcp_ipv6(): def test_parse_maddr_tcp_ipv6():
@ -179,7 +200,7 @@ def test_parse_maddr_tcp_ipv6():
result = parse_maddr('/ip6/::1/tcp/5678') result = parse_maddr('/ip6/::1/tcp/5678')
assert isinstance(result, TCPAddress) assert isinstance(result, TCPAddress)
assert result.unwrap() == ('::1', 5678) assert result.unwrap() == ('tcp', '::1', 5678)
def test_parse_maddr_uds(): def test_parse_maddr_uds():
@ -192,9 +213,10 @@ def test_parse_maddr_uds():
result = parse_maddr('/unix/tmp/tractor_test/test.sock') result = parse_maddr('/unix/tmp/tractor_test/test.sock')
assert isinstance(result, UDSAddress) assert isinstance(result, UDSAddress)
filedir, filename = result.unwrap() assert result.unwrap() == (
assert filename == 'test.sock' 'unix',
assert str(filedir) == '/tmp/tractor_test' '/tmp/tractor_test/test.sock',
)
def test_parse_maddr_unsupported(): def test_parse_maddr_unsupported():
@ -210,6 +232,181 @@ def test_parse_maddr_unsupported():
parse_maddr('/ip4/127.0.0.1/udp/1234') parse_maddr('/ip4/127.0.0.1/udp/1234')
def test_parse_wg_maddr():
'''
`parse_maddr()` previously rejected the canonical infix `/wg/`
grammar even though `py-multiaddr` parsed it. Feed a bearer,
identity, and TCP overlay through both the WG-specific and public
parsers, then prove they produce the same local-only tunnel
annotation without changing the bindable overlay.
'''
parsed = parse_wg_maddr(_WG_MADDR)
assert parse_maddr(_WG_MADDR) == parsed
assert isinstance(parsed, TunnelledAddress)
assert parsed.tunnel == WGTunnelSpec(
peer_pubkey=_WG_PUBKEY,
bearer=('192.168.1.50', 51820),
)
assert isinstance(parsed.overlay, TCPAddress)
assert parsed.overlay.unwrap() == ('tcp', '10.0.11.1', 1616)
def test_mk_wg_maddr_roundtrip():
'''
`mk_maddr()` previously saw only the wrapper's delegated TCP
proto-key and silently dropped all tunnel metadata. Parse the
canonical maddr, compose it through both public entry points, and
prove bearer, key, and overlay survive byte-for-byte.
'''
parsed = parse_wg_maddr(_WG_MADDR)
assert str(mk_wg_maddr(parsed)) == _WG_MADDR
assert str(mk_maddr(parsed)) == _WG_MADDR
def test_nested_wg_maddr_roundtrip():
'''
A single first-match lookup confuses nested WG keys and bearers.
Arrange an IPv4 outer bearer around an IPv6 inner bearer, parse
from the last `/wg/` outward, and assert tunnel ordering plus an
exact re-composition of the original stack.
'''
nested_maddr: str = (
f'/ip4/192.168.1.50/udp/51820'
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
f'/ip6/2001:db8::2/udp/51821'
f'/wg/{mb_pubkey(_WG_PUBKEY_2)}'
f'/ip4/10.0.11.1/tcp/1616'
)
parsed = parse_maddr(nested_maddr)
specs = tunnels_of(parsed)
assert len(specs) == 2
assert specs[0].peer_pubkey == _WG_PUBKEY
assert specs[0].bearer == ('192.168.1.50', 51820)
assert specs[1].peer_pubkey == _WG_PUBKEY_2
assert specs[1].bearer == ('2001:db8::2', 51821)
assert str(mk_maddr(parsed)) == nested_maddr
@pytest.mark.parametrize(
'maddr, match',
[
pytest.param(
(
f'/ip4/192.168.1.50/tcp/51820'
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
f'/ip4/10.0.11.1/tcp/1616'
),
'Bad `wg` bearer',
id='non-udp-bearer',
),
pytest.param(
(
f'/ip4/192.168.1.50/udp/51820'
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
),
'no overlay endpoint',
id='missing-overlay',
),
pytest.param(
(
f'/ip4/192.168.1.50/udp/51820'
f'/wg/{mb_pubkey(_WG_PUBKEY)}'
f'/ip4/10.0.11.1/udp/1616'
),
'Unsupported `wg` overlay',
id='non-tcp-overlay',
),
],
)
def test_parse_wg_maddr_rejects_bad_grammar(
maddr: str,
match: str,
):
'''
Accepting an invalid bearer or overlay assigns an endpoint to the
wrong runtime owner. Exercise parseable but unsupported protocol
combinations and prove each fails before constructing a wrapper,
with an error identifying the violated WG grammar boundary.
'''
with pytest.raises(ValueError, match=match):
parse_maddr(maddr)
def test_parse_wg_maddr_rejects_malformed_key():
'''
A truncated multibase key used to be vulnerable to silent
identity corruption in hand-written parsers. Give the upstream
`/wg/` codec a short key and prove `Multiaddr()` rejects it
before tractor's wrapper parser runs.
'''
maddr: str = (
'/ip4/192.168.1.50/udp/51820'
'/wg/udG9vIHNob3J0'
'/ip4/10.0.11.1/tcp/1616'
)
with pytest.raises(ValueError):
parse_maddr(maddr)
def test_parse_wg_maddr_reports_missing_codec(
monkeypatch,
):
'''
Released `multiaddr==0.2.0` does not know `/wg/` and emits an
opaque unknown-protocol parse error. Simulate that registry and
prove an actual WG stack reports the dependency action while a
Unix path containing a `wg` directory remains ordinary UDS data.
'''
from multiaddr.exceptions import ProtocolNotFoundError
from multiaddr import protocols
def no_wg_proto(name: str):
raise ProtocolNotFoundError(name)
monkeypatch.setattr(
protocols,
'protocol_with_name',
no_wg_proto,
)
uds = parse_maddr('/unix/tmp/wg/service.sock')
assert isinstance(uds, UDSAddress)
with pytest.raises(
RuntimeError,
match='py-multiaddr#108',
):
parse_maddr(_WG_MADDR)
def test_mk_wg_maddr_requires_bearer():
'''
A key-only tunnel spec relies on local configuration and cannot
be reconstructed as the canonical bearer-first maddr. Build that
incomplete annotation and prove composition raises instead of
emitting a misleading overlay-only address.
'''
addr = TunnelledAddress(
overlay=TCPAddress('10.0.11.1', 1616),
tunnel=WGTunnelSpec(peer_pubkey=_WG_PUBKEY),
)
with pytest.raises(
ValueError,
match='without a bearer',
):
mk_maddr(addr)
@pytest.mark.parametrize( @pytest.mark.parametrize(
'addr', 'addr',
[ [
@ -249,7 +446,22 @@ def test_wrap_address_maddr_str():
result = wrap_address('/ip4/127.0.0.1/tcp/9999') result = wrap_address('/ip4/127.0.0.1/tcp/9999')
assert isinstance(result, TCPAddress) assert isinstance(result, TCPAddress)
assert result.unwrap() == ('127.0.0.1', 9999) assert result.unwrap() == ('tcp', '127.0.0.1', 9999)
def test_wrap_address_wg_maddr_str():
'''
`wrap_address()` delegates slash-prefixed strings to
`parse_maddr()`. Pass a canonical WG maddr through that public
boundary and prove it preserves the tunnel annotation rather than
rejecting the protocol stack or returning only its TCP overlay.
'''
result = wrap_address(_WG_MADDR)
assert isinstance(result, TunnelledAddress)
assert result.tunnel.peer_pubkey == _WG_PUBKEY
assert result.overlay.unwrap() == ('tcp', '10.0.11.1', 1616)
# ------ parse_endpoints() tests ------ # ------ parse_endpoints() tests ------
@ -270,11 +482,11 @@ def test_parse_endpoints_tcp_only():
reg_addr = result['registry'][0] reg_addr = result['registry'][0]
assert isinstance(reg_addr, TCPAddress) assert isinstance(reg_addr, TCPAddress)
assert reg_addr.unwrap() == ('127.0.0.1', 1616) assert reg_addr.unwrap() == ('tcp', '127.0.0.1', 1616)
feed_addr = result['data_feed'][0] feed_addr = result['data_feed'][0]
assert isinstance(feed_addr, TCPAddress) assert isinstance(feed_addr, TCPAddress)
assert feed_addr.unwrap() == ('0.0.0.0', 5555) assert feed_addr.unwrap() == ('tcp', '0.0.0.0', 5555)
def test_parse_endpoints_mixed_tpts(): def test_parse_endpoints_mixed_tpts():
@ -294,12 +506,37 @@ def test_parse_endpoints_mixed_tpts():
assert len(addrs) == 2 assert len(addrs) == 2
assert isinstance(addrs[0], TCPAddress) assert isinstance(addrs[0], TCPAddress)
assert addrs[0].unwrap() == ('127.0.0.1', 4040) assert addrs[0].unwrap() == ('tcp', '127.0.0.1', 4040)
assert isinstance(addrs[1], UDSAddress) assert isinstance(addrs[1], UDSAddress)
filedir, filename = addrs[1].unwrap() assert addrs[1].unwrap() == (
assert filename == 'broker.sock' 'unix',
assert str(filedir) == '/tmp/tractor' '/tmp/tractor/broker.sock',
)
def test_parse_endpoints_wg_maddr():
'''
Service endpoint tables previously rejected WG protocol stacks.
Put a tunnelled maddr beside a plain TCP address and prove
`parse_endpoints()` retains input order while delivering the
wrapper needed by the future bindspace lifecycle.
'''
table = {
'registry': [
_WG_MADDR,
'/ip4/127.0.0.1/tcp/1616',
],
}
addrs = parse_endpoints(table)['registry']
assert isinstance(addrs[0], TunnelledAddress)
assert addrs[0].tunnel.bearer == (
'192.168.1.50',
51820,
)
assert isinstance(addrs[1], TCPAddress)
def test_parse_endpoints_unwrapped_tuples(): def test_parse_endpoints_unwrapped_tuples():
@ -315,7 +552,7 @@ def test_parse_endpoints_unwrapped_tuples():
addr = result['ems'][0] addr = result['ems'][0]
assert isinstance(addr, TCPAddress) assert isinstance(addr, TCPAddress)
assert addr.unwrap() == ('127.0.0.1', 6666) assert addr.unwrap() == ('tcp', '127.0.0.1', 6666)
def test_parse_endpoints_mixed_str_and_tuple(): def test_parse_endpoints_mixed_str_and_tuple():
@ -335,10 +572,10 @@ def test_parse_endpoints_mixed_str_and_tuple():
assert len(addrs) == 2 assert len(addrs) == 2
assert isinstance(addrs[0], TCPAddress) assert isinstance(addrs[0], TCPAddress)
assert addrs[0].unwrap() == ('127.0.0.1', 7777) assert addrs[0].unwrap() == ('tcp', '127.0.0.1', 7777)
assert isinstance(addrs[1], TCPAddress) assert isinstance(addrs[1], TCPAddress)
assert addrs[1].unwrap() == ('127.0.0.1', 8888) assert addrs[1].unwrap() == ('tcp', '127.0.0.1', 8888)
def test_parse_endpoints_unsupported_proto(): def test_parse_endpoints_unsupported_proto():

View File

@ -371,7 +371,7 @@ def test_non_registrar_root_tpt_bind_addrs(
for uw_addr in bound: for uw_addr in bound:
w = wrap_address(uw_addr) w = wrap_address(uw_addr)
if w.proto_key == 'tcp': if w.proto_key == 'tcp':
_host, port = uw_addr _, _host, port = uw_addr
assert port > 0 assert port > 0
trio.run(_main) trio.run(_main)
@ -443,7 +443,7 @@ def test_tpt_bind_addrs_as_maddr_str(
for uw_addr in actor.accept_addrs: for uw_addr in actor.accept_addrs:
w = wrap_address(uw_addr) w = wrap_address(uw_addr)
if w.proto_key == 'tcp': if w.proto_key == 'tcp':
_host, port = uw_addr _, _host, port = uw_addr
assert port > 0 assert port > 0
trio.run(_main) trio.run(_main)
@ -475,7 +475,7 @@ def test_registrar_merge_binds_union(
# actually differ (always true for TCP, may # actually differ (always true for TCP, may
# collide for UDS). # collide for UDS).
expect_disjoint: bool = ( expect_disjoint: bool = (
tuple(reg_addr) != rando.unwrap() reg_wrapped.unwrap() != rando.unwrap()
) )
async def _main(): async def _main():

View File

@ -0,0 +1,234 @@
'''
`TunnelledAddress` delegation + peeling semantics.
A tunnel annotates an existing L4 addr rather than being its own
transport, so the contract under test is mostly *delegation*: the
runtime must not be able to tell a tunnelled addr from its
overlay, and **nothing** about the tunnel may cross the wire.
See `ai/tpt-backends/03_wg_tunnel_bindspace.md` §3.1/§3.4.
'''
from __future__ import annotations
import msgspec
import pytest
from tractor.discovery import (
TunnelledAddress,
WGTunnelSpec,
mb_pubkey,
strip_tunnels,
tunnels_of,
wg8_pubkey,
)
from tractor.discovery._addr import (
is_wrapped_addr,
wrap_address,
)
from tractor.ipc._tcp import TCPAddress
# a valid-looking std-base64 `wg(8)` pubkey (32B -> 44 chars)
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
@pytest.fixture
def overlay() -> TCPAddress:
return TCPAddress('10.0.11.1', 1616)
@pytest.fixture
def spec() -> WGTunnelSpec:
return WGTunnelSpec(
peer_pubkey=_PUBKEY,
bearer=('192.168.1.50', 51820),
)
@pytest.fixture
def tunnelled(
overlay: TCPAddress,
spec: WGTunnelSpec,
) -> TunnelledAddress:
return TunnelledAddress(overlay=overlay, tunnel=spec)
def test_wg_pubkey_codec_roundtrip():
'''
Standard `wg(8)` base64 keys can contain `/`, which cannot be
embedded unchanged in a slash-delimited maddr. Prove the helper
emits `u`-prefixed multibase base64url without `/` and decodes
it back to the exact original 32-byte key.
'''
mb_key: str = mb_pubkey(_PUBKEY)
assert mb_key.startswith('u')
assert '/' not in mb_key
assert wg8_pubkey(mb_key) == _PUBKEY
@pytest.mark.parametrize(
'key, converter',
[
pytest.param(
'dG9vIHNob3J0',
mb_pubkey,
id='wg8-base64',
),
pytest.param(
'udG9vIHNob3J0',
wg8_pubkey,
id='multibase',
),
],
)
def test_wg_pubkey_codec_rejects_wrong_size(
key: str,
converter,
):
'''
WireGuard silently-corrupt key handling would let an invalid
identity reach peer verification. Exercise both input encodings
with a short payload and prove conversion rejects it before a
tunnel spec or maddr can be constructed.
'''
with pytest.raises(
ValueError,
match='must decode to 32 bytes',
):
converter(key)
def test_proto_key_delegates(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
):
'''
A tunnel has no transport of its own, so every table lookup
must see the *overlay's* proto-key.
'''
assert tunnelled.proto_key == overlay.proto_key == 'tcp'
def test_unwrap_is_identical_to_overlay(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
):
'''
The whole point: nothing new crosses the wire, so a peer
never has to understand tunnels.
'''
assert tunnelled.unwrap() == overlay.unwrap()
# and it must survive msgpack as-is
enc: bytes = msgspec.msgpack.encode(tunnelled.unwrap())
assert msgspec.msgpack.decode(enc) == list(overlay.unwrap())
def test_unwrap_roundtrips_back_to_plain_overlay(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
):
'''
`wrap_address()` on a tunnelled addr's unwrapped form yields
the *plain* overlay type the tunnel is simply absent, which
is correct: it was never on the wire.
'''
rewrapped = wrap_address(tunnelled.unwrap())
assert type(rewrapped) is TCPAddress
assert rewrapped == overlay
assert not isinstance(rewrapped, TunnelledAddress)
def test_bindspace_and_validity_delegate(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
):
assert tunnelled.bindspace == overlay.bindspace
assert tunnelled.is_valid == overlay.is_valid
def test_is_wrapped_addr_accepts_tunnelled(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
):
'''
`TunnelledAddress` is deliberately absent from
`_address_types`, so `is_wrapped_addr()` needs its own
clause.
'''
assert is_wrapped_addr(overlay)
assert is_wrapped_addr(tunnelled)
# the unwrapped form is NOT a wrapped addr
assert not is_wrapped_addr(tunnelled.unwrap())
def test_namespace_comes_from_the_tunnel(
overlay: TCPAddress,
):
'''
First real consumer of `Address.namespace`, spec'd in the
protocol since day one and implemented by no backend.
'''
# XXX, "no backend implements it" is literal — the member
# isn't even declared, so this is `AttributeError` not `None`.
# This assert is the guard: when a backend finally declares
# `.namespace`, it fails and the `getattr()` fallback in
# `TunnelledAddress.namespace` can go.
assert not hasattr(overlay, 'namespace')
no_ns = TunnelledAddress(
overlay=overlay,
tunnel=WGTunnelSpec(peer_pubkey=_PUBKEY),
)
assert no_ns.namespace is None
in_ns = TunnelledAddress(
overlay=overlay,
tunnel=WGTunnelSpec(peer_pubkey=_PUBKEY, netns='wg-test'),
)
assert in_ns.namespace == ('netns', 'wg-test')
def test_strip_tunnels(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
spec: WGTunnelSpec,
):
# idempotent on a plain addr
assert strip_tunnels(overlay) is overlay
# peels one
assert strip_tunnels(tunnelled) is overlay
# and collapses a nested stack in one call
nested = TunnelledAddress(overlay=tunnelled, tunnel=spec)
assert strip_tunnels(nested) is overlay
def test_tunnels_of_is_outermost_first(
tunnelled: TunnelledAddress,
overlay: TCPAddress,
):
assert tunnels_of(overlay) == ()
assert tunnels_of(tunnelled) == (tunnelled.tunnel,)
inner_spec = WGTunnelSpec(peer_pubkey=_PUBKEY, iface='wg1')
nested = TunnelledAddress(
overlay=tunnelled,
tunnel=inner_spec,
)
assert tunnels_of(nested) == (inner_spec, tunnelled.tunnel)
def test_frozen(
tunnelled: TunnelledAddress,
):
with pytest.raises(AttributeError):
tunnelled.overlay = TCPAddress('127.0.0.1', 1)

View File

@ -0,0 +1,91 @@
'''
Tunnel annotation peeling at the outbound IPC transport boundary.
'''
from __future__ import annotations
import pytest
import trio
from tractor.discovery import (
TunnelledAddress,
WGTunnelSpec,
tunnels_of,
)
from tractor.ipc import _chan
from tractor.ipc._tcp import TCPAddress
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
@pytest.fixture
def overlay() -> TCPAddress:
return TCPAddress('127.0.0.1', 0)
@pytest.fixture
def tunnelled(
overlay: TCPAddress,
) -> TunnelledAddress:
return TunnelledAddress(
overlay=overlay,
tunnel=WGTunnelSpec(
peer_pubkey=_PUBKEY,
bearer=('192.168.1.50', 51820),
),
)
@pytest.mark.parametrize('use_tunnel', [False, True])
def test_channel_peels_before_transport_dispatch(
monkeypatch,
overlay: TCPAddress,
tunnelled: TunnelledAddress,
use_tunnel: bool,
):
'''
Exact-type transport lookup cannot dispatch a `TunnelledAddress`,
and passing one onward would make TCP dial the wrong object. Feed
both a plain overlay and its annotated wrapper into
`Channel.from_addr()`, capture lookup and connect arguments, and
prove both transport operations receive only the same bindable
TCP address while the caller's tunnel metadata remains intact.
'''
seen: list[tuple[str, TCPAddress]] = []
class FakeTransport:
@classmethod
async def connect_to(
cls,
addr: TCPAddress,
**kwargs,
) -> FakeTransport:
seen.append(('connect', addr))
return cls()
def fake_transport_from_addr(
addr: TCPAddress,
) -> type[FakeTransport]:
seen.append(('lookup', addr))
return FakeTransport
monkeypatch.setattr(
_chan,
'transport_from_addr',
fake_transport_from_addr,
)
async def main() -> None:
declared = tunnelled if use_tunnel else overlay
chan = await _chan.Channel.from_addr(declared)
assert isinstance(chan.transport, FakeTransport)
trio.run(main)
assert seen == [
('lookup', overlay),
('connect', overlay),
]
assert tunnels_of(tunnelled) == (tunnelled.tunnel,)

View File

@ -703,17 +703,20 @@ def test_uds_bindspace_created_implicitly(
root: Actor = tractor.current_actor() root: Actor = tractor.current_actor()
assert root.is_registrar assert root.is_registrar
canonical_addr = _addr.wrap_address(
registry_addr,
).unwrap()
assert registry_addr in root.reg_addrs assert canonical_addr in root.reg_addrs
assert ( assert (
registry_addr canonical_addr
in in
_state._runtime_vars['_registry_addrs'] _state._runtime_vars['_registry_addrs']
) )
assert ( assert (
_addr.wrap_address(registry_addr) canonical_addr
in in
root.registry_addrs [addr.unwrap() for addr in root.registry_addrs]
) )
trio.run(main) trio.run(main)

View File

@ -0,0 +1,59 @@
'''
Tunnel annotation peeling at the inbound IPC transport boundary.
'''
from __future__ import annotations
import trio
from tractor.discovery import (
TunnelledAddress,
WGTunnelSpec,
tunnels_of,
)
from tractor.ipc._server import open_ipc_server
from tractor.ipc._tcp import TCPAddress
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
def test_server_peels_before_endpoint_construction():
'''
`Endpoint.start_listener()` reflects on its address's declaring
module, so retaining a tunnel wrapper there selects `._tunnel`
instead of the TCP backend. Start a real listener from the
wrapper, assert the resulting `Endpoint` contains only a resolved
`TCPAddress`, and prove the original declaration still carries
its tunnel spec for the future bindspace lifecycle.
'''
overlay = TCPAddress('127.0.0.1', 0)
tunnelled = TunnelledAddress(
overlay=overlay,
tunnel=WGTunnelSpec(
peer_pubkey=_PUBKEY,
bearer=('192.168.1.50', 51820),
),
)
async def main() -> None:
async with open_ipc_server() as server:
eps = await server.listen_on(
accept_addrs=[tunnelled],
)
assert len(eps) == 1
endpoint = eps[0]
assert type(endpoint.addr) is TCPAddress
_, host, port = endpoint.addr.unwrap()
assert host == overlay.unwrap()[1]
assert port > 0
assert endpoint.addr is not tunnelled
assert tunnels_of(tunnelled) == (
tunnelled.tunnel,
)
server.cancel()
trio.run(main)

View File

@ -8,6 +8,7 @@ import trio
import tractor import tractor
from tractor._testing import tractor_test from tractor._testing import tractor_test
from tractor.discovery._addr import wrap_address
def test_no_runtime(): def test_no_runtime():
@ -48,7 +49,7 @@ async def test_self_is_registered_localportal(reg_addr):
with trio.fail_after(0.2): with trio.fail_after(0.2):
sockaddr = await portal.run_from_ns( sockaddr = await portal.run_from_ns(
'self', 'wait_for_actor', name='root') 'self', 'wait_for_actor', name='root')
assert sockaddr[0] == reg_addr assert sockaddr[0] == wrap_address(reg_addr).unwrap()
def test_local_actor_async_func(reg_addr): def test_local_actor_async_func(reg_addr):

View File

@ -55,6 +55,7 @@ from .discovery._addr import (
mk_uuid, mk_uuid,
wrap_address, wrap_address,
) )
from .discovery._tunnel import strip_tunnels
from .trionics import ( from .trionics import (
is_multi_cancelled, is_multi_cancelled,
collapse_eg, collapse_eg,
@ -505,6 +506,7 @@ async def open_root_actor(
# proto if not already provided. # proto if not already provided.
if not tpt_bind_addrs: if not tpt_bind_addrs:
for addr in ponged_addrs: for addr in ponged_addrs:
bindable_addr: Address = strip_tunnels(addr)
tpt_bind_addrs.append( tpt_bind_addrs.append(
# XXX, these are `Address` NOT `UnwrappedAddress`. # XXX, these are `Address` NOT `UnwrappedAddress`.
# #
@ -512,8 +514,8 @@ async def open_root_actor(
# protos we allocate port=0 such that the system # protos we allocate port=0 such that the system
# allocates a random value at bind time; this # allocates a random value at bind time; this
# happens in the `.ipc.*` stack's backend. # happens in the `.ipc.*` stack's backend.
addr.get_random( bindable_addr.get_random(
bindspace=addr.bindspace, bindspace=bindable_addr.bindspace,
) )
) )

View File

@ -18,11 +18,10 @@
Discovery (protocols) API for automatic addressing Discovery (protocols) API for automatic addressing
and location management of (service) actors. and location management of (service) actors.
NOTE: this ``__init__`` only eagerly imports the NOTE: this ``__init__`` only eagerly imports the lightweight
``._multiaddr`` submodule (for public re-exports). ``._multiaddr`` and ``._tunnel`` submodules for public re-exports.
Heavier submodules like ``._addr`` and ``._api`` Heavier submodules like ``._addr`` and ``._api`` are NOT imported
are NOT imported here to avoid circular imports; here to avoid circular imports; use direct module paths for those.
use direct module paths for those.
''' '''
from ._multiaddr import ( from ._multiaddr import (
@ -30,3 +29,14 @@ from ._multiaddr import (
parse_maddr as parse_maddr, parse_maddr as parse_maddr,
mk_maddr as mk_maddr, mk_maddr as mk_maddr,
) )
from ._tunnel import (
TunnelledAddress as TunnelledAddress,
TunnelSpec as TunnelSpec,
WGTunnelSpec as WGTunnelSpec,
mb_pubkey as mb_pubkey,
mk_wg_maddr as mk_wg_maddr,
parse_wg_maddr as parse_wg_maddr,
strip_tunnels as strip_tunnels,
tunnels_of as tunnels_of,
wg8_pubkey as wg8_pubkey,
)

View File

@ -19,7 +19,9 @@ from typing import (
Any, Any,
Protocol, Protocol,
ClassVar, ClassVar,
Literal,
Type, Type,
TypeAlias,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -40,9 +42,13 @@ from ..ipc._uds import (
if TYPE_CHECKING: if TYPE_CHECKING:
# ONLY type-annots, the eager import costs ~4.5ms # ONLY type-annots, the eager import costs ~4.5ms
# of `import tractor` wall-time (gh #470). # of `import tractor` wall-time (gh #470).
from ._tunnel import (
TunnelledAddress,
)
from ..runtime._runtime import Actor from ..runtime._runtime import Actor
else: else:
Actor = Any Actor = Any
TunnelledAddress = Any
log = get_logger() log = get_logger()
@ -69,25 +75,44 @@ log = get_logger()
# seems like the right name as per, # seems like the right name as per,
# https://www.geeksforgeeks.org/introduction-to-address-descriptor/ # https://www.geeksforgeeks.org/introduction-to-address-descriptor/
# #
UnwrappedAddress = ( TaggedTCPAddress: TypeAlias = tuple[
# tcp/udp/uds Literal['tcp'],
tuple[ str,
str, # host/domain(tcp), filesys-dir(uds) int,
int|str, # port/path(uds)
] ]
# ?TODO? should we also include another 2 fields from TaggedUnixAddress: TypeAlias = tuple[
# our `Aid` msg such that we include the runtime `Actor.uid` Literal['unix'],
# of `.name` and `.uuid`? str,
# - would ensure uniqueness across entire net? ]
# - allows for easier runtime-level filtering of "actors by TaggedUDSAlias: TypeAlias = tuple[
# service name" Literal['uds'],
str,
]
TaggedAddress: TypeAlias = (
TaggedTCPAddress
|TaggedUnixAddress
) )
# Input-only compatibility forms retained for older callers and
# serialized payloads.
LegacyTCPAddress: TypeAlias = tuple[str, int]
LegacyUDSAddress: TypeAlias = tuple[str, str]
LegacyUnwrappedAddress: TypeAlias = (
LegacyTCPAddress
|LegacyUDSAddress
)
UnwrappedAddress = TaggedAddress
# ?TODO? should we also include another 2 fields from our `Aid` msg
# such that we include the runtime `Actor.uid` of `.name` and `.uuid`?
# - would ensure uniqueness across entire net?
# - allows for easier runtime-level filtering of "actors by service
# name"
# TODO, maybe rename to `SocketAddress`? # TODO, maybe rename to `SocketAddress`?
class Address(Protocol): class Address(Protocol):
proto_key: ClassVar[str] proto_key: ClassVar[str]
unwrapped_type: ClassVar[UnwrappedAddress] unwrapped_type: ClassVar[type]
# TODO, i feel like an `.is_bound()` is a better thing to # TODO, i feel like an `.is_bound()` is a better thing to
# support? # support?
@ -99,7 +124,7 @@ class Address(Protocol):
# TODO, maybe `.netns` is a better name? # TODO, maybe `.netns` is a better name?
@property @property
def namespace(self) -> tuple[str, int]|None: def namespace(self) -> tuple[str, str|int]|None:
''' '''
The if-available, OS-specific "network namespace" key. The if-available, OS-specific "network namespace" key.
@ -209,7 +234,16 @@ def get_address_cls(name: str) -> Type[Address]:
def is_wrapped_addr(addr: any) -> bool: def is_wrapped_addr(addr: any) -> bool:
return type(addr) in _address_types.values() # XXX NOTE, a `TunnelledAddress` is genuinely "wrapped" but is
# deliberately NOT in `_address_types`: it has no
# `MsgTransport` of its own (a tunnel is transparent to
# `socket(2)`), so it gets no proto-key entry. See `._tunnel`.
from ._tunnel import TunnelledAddress
return (
type(addr) in _address_types.values()
or
isinstance(addr, TunnelledAddress)
)
def mk_uuid() -> str: def mk_uuid() -> str:
@ -223,8 +257,16 @@ def mk_uuid() -> str:
def wrap_address( def wrap_address(
addr: UnwrappedAddress|str, addr: (
) -> Address: TaggedAddress
|TaggedUDSAlias
|LegacyUnwrappedAddress
|list[str|int]
|str
|Address
|TunnelledAddress
),
) -> Address|TunnelledAddress:
''' '''
Wrap an `UnwrappedAddress` as an `Address`-type based Wrap an `UnwrappedAddress` as an `Address`-type based
on matching builtin python data-structures which we adhoc on matching builtin python data-structures which we adhoc
@ -246,6 +288,20 @@ def wrap_address(
# import pdbp; pdbp.set_trace() # import pdbp; pdbp.set_trace()
match addr: match addr:
case (
('tcp', str(), int())
|
['tcp', str(), int()]
):
return TCPAddress.from_addr(addr)
case (
(('unix' | 'uds'), str())
|
[('unix' | 'uds'), str()]
):
return UDSAddress.from_addr(addr)
# classic network socket-address as tuple/list # classic network socket-address as tuple/list
case ( case (
(str(), int()) (str(), int())

View File

@ -38,9 +38,13 @@ if TYPE_CHECKING:
# `import tractor` path (gh #470). # `import tractor` path (gh #470).
from multiaddr import Multiaddr from multiaddr import Multiaddr
from tractor.discovery._addr import Address from tractor.discovery._addr import Address
from tractor.discovery._tunnel import (
TunnelledAddress,
)
else: else:
Multiaddr = Any Multiaddr = Any
Address = Any Address = Any
TunnelledAddress = Any
# map from tractor-internal `proto_key` identifiers # map from tractor-internal `proto_key` identifiers
# to the standard multiaddr protocol name strings. # to the standard multiaddr protocol name strings.
@ -57,7 +61,7 @@ _maddr_to_tpt_proto: dict[str, str] = {
def mk_maddr( def mk_maddr(
addr: 'Address', addr: 'Address|TunnelledAddress',
) -> Multiaddr: ) -> Multiaddr:
''' '''
Construct a `Multiaddr` from a tractor `Address` instance, Construct a `Multiaddr` from a tractor `Address` instance,
@ -67,6 +71,13 @@ def mk_maddr(
''' '''
from multiaddr import Multiaddr from multiaddr import Multiaddr
from ._tunnel import (
TunnelledAddress,
mk_wg_maddr,
)
if isinstance(addr, TunnelledAddress):
return mk_wg_maddr(addr)
proto_key: str = addr.proto_key proto_key: str = addr.proto_key
maddr_proto: str|None = _tpt_proto_to_maddr.get(proto_key) maddr_proto: str|None = _tpt_proto_to_maddr.get(proto_key)
if maddr_proto is None: if maddr_proto is None:
@ -76,7 +87,7 @@ def mk_maddr(
match proto_key: match proto_key:
case 'tcp': case 'tcp':
host, port = addr.unwrap() _, host, port = addr.unwrap()
ip = ipaddress.ip_address(host) ip = ipaddress.ip_address(host)
net_proto: str = ( net_proto: str = (
'ip4' if ip.version == 4 'ip4' if ip.version == 4
@ -87,13 +98,12 @@ def mk_maddr(
) )
case 'uds': case 'uds':
filedir, filename = addr.unwrap() _, sockpath = addr.unwrap()
filepath = Path(filedir) / filename
# NOTE, strip any leading `/` to avoid # NOTE, strip any leading `/` to avoid
# double-slash `/unix//run/..` which the # double-slash `/unix//run/..` which the
# multiaddr parser rejects as "empty # multiaddr parser rejects as "empty
# protocol path". # protocol path".
fpath_str: str = str(filepath).lstrip('/') fpath_str: str = sockpath.lstrip('/')
return Multiaddr( return Multiaddr(
f'/{maddr_proto}/{fpath_str}' f'/{maddr_proto}/{fpath_str}'
) )
@ -101,7 +111,7 @@ def mk_maddr(
def parse_maddr( def parse_maddr(
maddr_str: str, maddr_str: str,
) -> 'Address': ) -> 'Address|TunnelledAddress':
''' '''
Parse a multiaddr string into a tractor `Address`. Parse a multiaddr string into a tractor `Address`.
@ -113,7 +123,16 @@ def parse_maddr(
from tractor.ipc._tcp import TCPAddress from tractor.ipc._tcp import TCPAddress
from tractor.ipc._uds import UDSAddress from tractor.ipc._uds import UDSAddress
try:
maddr = Multiaddr(maddr_str) maddr = Multiaddr(maddr_str)
except ValueError:
# Diagnose an unavailable WG codec after upstream parsing
# fails. Pre-checking the raw string would misclassify valid
# values such as `/unix/tmp/wg/service.sock`.
if '/wg/' in maddr_str:
from ._tunnel import _wg_proto_code
_wg_proto_code()
raise
proto_names: list[str] = [ proto_names: list[str] = [
p.name for p in maddr.protocols() p.name for p in maddr.protocols()
] ]
@ -136,6 +155,10 @@ def parse_maddr(
filename=sockpath.name, filename=sockpath.name,
) )
case _ if 'wg' in proto_names:
from ._tunnel import parse_wg_maddr
return parse_wg_maddr(maddr)
case _: case _:
raise ValueError( raise ValueError(
f'Unsupported multiaddr protocol combo: ' f'Unsupported multiaddr protocol combo: '
@ -154,11 +177,11 @@ EndpointsTable = dict[
list[str|tuple], # maddr strs or UnwrappedAddress list[str|tuple], # maddr strs or UnwrappedAddress
] ]
# output table: actor/service name -> list of wrapped # output table: actor/service name -> list of wrapped address
# `Address` instances ready for transport binding. # declarations ready for bindspace handling.
ParsedEndpoints = dict[ ParsedEndpoints = dict[
str, # actor/service name str, # actor/service name
list['Address'], list['Address|TunnelledAddress'],
] ]
@ -167,7 +190,7 @@ def parse_endpoints(
) -> ParsedEndpoints: ) -> ParsedEndpoints:
''' '''
Parse a service-endpoint config table into wrapped Parse a service-endpoint config table into wrapped
`Address` instances suitable for transport binding. address declarations suitable for bindspace handling.
Each key is an actor/service name and each value is Each key is an actor/service name and each value is
a list of addresses in any format accepted by a list of addresses in any format accepted by
@ -179,6 +202,8 @@ def parse_endpoints(
``/uds/`` proto_key) ``/uds/`` proto_key)
- raw unwrapped tuples: ``('127.0.0.1', 1616)`` - raw unwrapped tuples: ``('127.0.0.1', 1616)``
- pre-wrapped `Address` objects (passed through) - pre-wrapped `Address` objects (passed through)
- `wg` maddrs, returned as `TunnelledAddress` wrappers which
must be peeled at the eventual bind/dial boundary
Returns a new `dict` with the same keys, where each Returns a new `dict` with the same keys, where each
value list contains the corresponding `Address` value list contains the corresponding `Address`

View File

@ -0,0 +1,502 @@
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
r'''
Tunnelled addresses: an `Address` that rides *inside* a tunnel.
A tunnel (`wg`, and later plain ip-in-udp, `veth`-in-netns, ..) is
**not** a `MsgTransport`. Its data plane is transparent to the
application's `socket(2)`, so it never gets its own entry in
`._addr._address_types` nor a `MsgpackTransport` impl. Instead it
*annotates* an existing L4 addr, and this module carries that
annotation beside it.
That does not mean tractor can never provision the tunnel. Layer A
assumes an externally configured iface; a later bindspace lifecycle
may create its iface, netns, routes, and kernel-owned UDP listener
through netlink/`pyroute2`. The distinction is that this
control-plane work does not turn the bearer into an application
`Endpoint`.
Naming follows `py-multiaddr`'s encapsulation model, where earlier
maddr segs wrap later ones (`.encapsulate()` appends):
/ip4/192.168.1.50/udp/51820/wg/u<key>/ip4/10.0.11.1/tcp/1616
\_______ bearer __________/\__ key __/\______ overlay ______/
- **bearer**: the underlay ep the tunnel iface listens on
(`wg(8)`'s `ListenPort`). The kernel owns this data-plane socket;
tractor may later provision it through a bindspace lifecycle but
never treats it as a `MsgTransport` listener.
- **overlay**: the ep `tractor` actually binds/dials, i.e. the
application IPC endpoint handled by `Endpoint`/`MsgTransport`.
We avoid `inner`/`outer` deliberately: in a *call* stack "inner"
reads as higher-up and later-called, whereas here the
encapsulated addr is bound *first* and sits deeper in the maddr.
XXX XXX READ THIS BEFORE USING XXX XXX
--------------------------------------
A `TunnelledAddress` **must be unwrapped to `.overlay` before it
reaches `Endpoint`**. `Endpoint.start_listener()` resolves its
listener fns by `inspect.getmodule(self.addr)`, so a wrapper
would resolve to *this* module rather than the transport's and
silently fail to find `start_listener()`.
If a wrapper reaches `Endpoint`, its backend lookup resolves this
module instead of the overlay transport module:
tpt_mod = inspect.getmodule(self.addr)
await tpt_mod.start_listener(addr=self.addr)
This module intentionally does not impersonate that transport API.
Unwrap at the parse or bindspace boundary; see `.overlay` and
`strip_tunnels()`.
'''
from __future__ import annotations
import base64
import ipaddress
from typing import (
Any,
ClassVar,
TYPE_CHECKING,
)
import msgspec
import multibase
if TYPE_CHECKING:
from multiaddr import Multiaddr
from ._addr import (
Address,
UnwrappedAddress,
)
else:
Address = Any
Multiaddr = Any
UnwrappedAddress = Any
class WGTunnelSpec(
msgspec.Struct,
frozen=True,
):
'''
The `wg`-specific half of a tunnel annotation.
Everything here is an *interface-layer* concern owned by
`wg(8)`/the kernel. A later tractor bindspace lifecycle may
provision it through netlink, but it is never an application
`MsgTransport` endpoint.
'''
# tunnel peer pubkey in the std-base64 `wg(8)` form, i.e.
# directly comparable to `wg show <if> peers` output
peer_pubkey: str
# the underlay `(ip, udp-port)` the wg iface listens on, i.e.
# wg's `ListenPort`. The kernel owns the socket even when a
# tractor bindspace lifecycle provisions it. `None` when the
# maddr declared only a key (identity) and the bearer is
# implied by local cfg.
bearer: tuple[str, int]|None = None
iface: str = 'wg0'
netns: str|None = None
# layer-C-only fields, unset in layer A
maybe_allowed_ips: tuple[str, ...] = ()
# the `multiaddr` proto name for this tunnel kind
tunnel_key: ClassVar[str] = 'wg'
# the tunnel-spec union; grows as new tunnel kinds land
# (plain ip-in-udp, `veth`-in-netns, ..)
TunnelSpec = WGTunnelSpec
def mb_pubkey(
wg8_key: str,
) -> str:
'''
Encode a `wg(8)` public key as multibase base64url.
WireGuard public keys are exactly 32 bytes. Enforce that here
before handing the `u`-prefixed result to `py-multiaddr`'s
`/wg/` codec.
'''
raw: bytes = base64.b64decode(
wg8_key,
validate=True,
)
if (nbytes := len(raw)) != 32:
raise ValueError(
f'A `wg` public key must decode to 32 bytes, '
f'not {nbytes}!'
)
return multibase.encode(
'base64url',
raw,
).decode('ascii')
def wg8_pubkey(
mb_key: str,
) -> str:
'''
Decode a multibase public key to `wg(8)` standard base64.
'''
raw: bytes = multibase.decode(mb_key)
if (nbytes := len(raw)) != 32:
raise ValueError(
f'A `wg` public key must decode to 32 bytes, '
f'not {nbytes}!'
)
return base64.b64encode(raw).decode('ascii')
def _wg_proto_code() -> int:
'''
Deliver the installed `py-multiaddr` `/wg/` protocol code.
`wg` support is merged upstream but not yet in a release, so
fail clearly when tractor was installed without the pinned rev.
'''
from multiaddr.exceptions import ProtocolNotFoundError
from multiaddr.protocols import protocol_with_name
try:
return protocol_with_name('wg').code
except ProtocolNotFoundError as exc:
raise RuntimeError(
'Installed `py-multiaddr` has no `/wg/` protocol!\n'
'Install py-multiaddr#108 or use tractor\'s pinned '
'dependency revision.\n'
) from exc
class TunnelledAddress(
msgspec.Struct,
frozen=True,
):
'''
An `Address` annotated with the tunnel it must be reached
*through*.
Address-level properties delegate to `.overlay`, so proto-key
guards and `.unwrap()` retain their existing meaning and
**nothing new crosses the wire**. Transport boundaries which
dispatch on exact type or declaring module must first call
`strip_tunnels()`.
'''
overlay: Address|TunnelledAddress
tunnel: TunnelSpec
# ---- delegated, so the runtime can't tell the difference ----
@property
def proto_key(self) -> str:
'''
The *overlay's* proto-key — a tunnel has no transport of
its own.
NOTE, this is a property whereas `Address.proto_key` is
spec'd as a `ClassVar`. That's deliberate: the value is
only knowable per-instance here, and this type is never
registered in `_address_types`, so no class-level access
of it should ever occur.
'''
return self.overlay.proto_key
@property
def is_valid(self) -> bool:
return self.overlay.is_valid
@property
def bindspace(self) -> str:
return self.overlay.bindspace
def unwrap(self) -> UnwrappedAddress:
'''
Delegate to `.overlay`, so the tunnel annotation is
**not** serialized and no peer needs to understand it.
'''
return self.overlay.unwrap()
# ---- the tunnel's own contribution ----
@property
def namespace(self) -> tuple[str, str|int]|None:
'''
The tunnel's netns, when it declares one.
This is the first real consumer of `Address.namespace`,
spec'd in the `Address` protocol since day one and
implemented by no backend.
XXX NOTE, "implemented by no backend" is literal: neither
`TCPAddress` nor `UDSAddress` defines `.namespace` at all,
so a plain attr access on an overlay raises
`AttributeError` rather than yielding `None`. Hence the
`getattr()` drop it once the backends actually declare
the member.
'''
if (netns := self.tunnel.netns) is None:
return getattr(self.overlay, 'namespace', None)
return ('netns', netns)
def __repr__(self) -> str:
return (
f'{type(self).__name__}(\n'
f' overlay={self.overlay!r},\n'
f' via={self.tunnel.tunnel_key!r} '
f'iface={self.tunnel.iface!r},\n'
f')'
)
def _wg_bearer(
bearer_ma: Multiaddr,
source_ma: Multiaddr,
) -> tuple[str, int]:
'''
Parse one kernel-owned `wg` bearer endpoint.
'''
proto_names: list[str] = [
proto.name
for proto in bearer_ma.protocols()
]
match proto_names:
case [('ip4' | 'ip6') as ip_proto, 'udp']:
return (
bearer_ma.value_for_protocol(ip_proto),
int(bearer_ma.value_for_protocol('udp')),
)
case _:
raise ValueError(
f'Bad `wg` bearer, expected '
f'`/ip4|ip6/<host>/udp/<port>`\n'
f'got: {bearer_ma}\n'
f'from maddr: {source_ma}\n'
)
def parse_wg_maddr(
maddr: str|Multiaddr,
) -> TunnelledAddress:
'''
Parse a `wg` maddr stack into nested tunnel annotations.
Pure: every segment operation delegates to `py-multiaddr`.
Repeated `.decapsulate_code()` calls peel the last `/wg/`
first, while `.split()` and `.join()` isolate that tunnel's
bearer without parsing slash-delimited strings ourselves.
'''
from multiaddr import Multiaddr
ma: Multiaddr = (
maddr
if isinstance(maddr, Multiaddr)
else Multiaddr(maddr)
)
wg_code: int = _wg_proto_code()
segs: list[Multiaddr] = ma.split()
proto_names: list[str] = [
proto.name
for seg in segs
for proto in seg.protocols()
]
if 'wg' not in proto_names:
raise ValueError(
f'Not a `wg`-tunnelled maddr; no `/wg/` segment!\n'
f'maddr: {ma}\n'
)
final_wg_i: int = len(proto_names) - 1
final_wg_i -= proto_names[::-1].index('wg')
overlay_ma: Multiaddr = Multiaddr.join(
*segs[final_wg_i + 1:]
)
overlay_names: list[str] = [
proto.name
for proto in overlay_ma.protocols()
]
match overlay_names:
case [('ip4' | 'ip6'), 'tcp']:
from ._multiaddr import parse_maddr
overlay: Address|TunnelledAddress = parse_maddr(
str(overlay_ma)
)
case []:
raise ValueError(
f'`wg` maddr declares no overlay endpoint!\n'
f'Append the endpoint tractor should bind.\n'
f'maddr: {ma}\n'
)
case _:
raise ValueError(
f'Unsupported `wg` overlay protocol combo: '
f'{overlay_names!r}\n'
f'overlay: {overlay_ma}\n'
f'from maddr: {ma}\n'
)
cursor: Multiaddr = ma
while any(
proto.name == 'wg'
for proto in cursor.protocols()
):
cursor_segs: list[Multiaddr] = cursor.split()
cursor_names: list[str] = [
proto.name
for seg in cursor_segs
for proto in seg.protocols()
]
wg_i: int = len(cursor_names) - 1
wg_i -= cursor_names[::-1].index('wg')
mb_key: str = cursor_segs[wg_i].value_for_protocol('wg')
bearer_prefix: Multiaddr = cursor.decapsulate_code(
wg_code
)
prefix_segs: list[Multiaddr] = bearer_prefix.split()
prefix_names: list[str] = [
proto.name
for seg in prefix_segs
for proto in seg.protocols()
]
prior_wg_i: int = (
len(prefix_names) - 1
- prefix_names[::-1].index('wg')
if 'wg' in prefix_names
else -1
)
bearer_ma: Multiaddr = Multiaddr.join(
*prefix_segs[prior_wg_i + 1:]
)
overlay = TunnelledAddress(
overlay=overlay,
tunnel=WGTunnelSpec(
peer_pubkey=wg8_pubkey(mb_key),
bearer=_wg_bearer(bearer_ma, ma),
),
)
cursor = bearer_prefix
return overlay
def mk_wg_maddr(
addr: TunnelledAddress,
) -> Multiaddr:
'''
Compose nested tunnel annotations as a canonical `wg` maddr.
Only the peer key and bearer have maddr representations. Local
interface, namespace, and allowed-IP config remains local.
'''
from multiaddr import Multiaddr
_wg_proto_code()
if (bearer := addr.tunnel.bearer) is None:
raise ValueError(
f'Can not compose a `wg` maddr without a bearer!\n'
f'tunnel: {addr.tunnel!r}\n'
)
bindable: Address = strip_tunnels(addr)
if bindable.proto_key != 'tcp':
raise ValueError(
f'Unsupported `wg` overlay proto-key: '
f'{bindable.proto_key!r}\n'
f'overlay: {bindable!r}\n'
)
host, port = bearer
ip = ipaddress.ip_address(host)
ip_proto: str = (
'ip4'
if ip.version == 4
else 'ip6'
)
bearer_ma = Multiaddr(
f'/{ip_proto}/{host}/udp/{port}'
)
key_ma = Multiaddr(
f'/wg/{mb_pubkey(addr.tunnel.peer_pubkey)}'
)
from ._multiaddr import mk_maddr
overlay_ma: Multiaddr = mk_maddr(addr.overlay)
return (
bearer_ma
.encapsulate(key_ma)
.encapsulate(overlay_ma)
)
def strip_tunnels(
addr: Address|TunnelledAddress,
) -> Address:
'''
Deliver the bindable `Address`, peeling any tunnel
annotation(s).
Pure. Idempotent on an un-tunnelled `Address`, and loops so
a nested (tunnel-in-tunnel) stack collapses in one call.
Call this at every bind/dial boundary.
'''
while isinstance(addr, TunnelledAddress):
addr = addr.overlay
return addr
def tunnels_of(
addr: Address|TunnelledAddress,
) -> tuple[TunnelSpec, ...]:
'''
Deliver every tunnel spec wrapping `addr`, outermost first.
Pure; empty for an un-tunnelled `Address`.
'''
specs: list[TunnelSpec] = []
while isinstance(addr, TunnelledAddress):
specs.append(addr.tunnel)
addr = addr.overlay
return tuple(specs)

View File

@ -46,6 +46,10 @@ from tractor.discovery._addr import (
Address, Address,
UnwrappedAddress, UnwrappedAddress,
) )
from tractor.discovery._tunnel import (
TunnelledAddress,
strip_tunnels,
)
from tractor.log import get_logger from tractor.log import get_logger
from tractor._exceptions import ( from tractor._exceptions import (
MsgTypeError, MsgTypeError,
@ -182,16 +186,17 @@ class Channel:
@classmethod @classmethod
async def from_addr( async def from_addr(
cls, cls,
addr: UnwrappedAddress, addr: UnwrappedAddress|Address|TunnelledAddress,
**kwargs **kwargs
) -> Channel: ) -> Channel:
if not is_wrapped_addr(addr): if not is_wrapped_addr(addr):
addr: Address = wrap_address(addr) addr = wrap_address(addr)
transport_cls = transport_from_addr(addr) transport_addr: Address = strip_tunnels(addr)
transport_cls = transport_from_addr(transport_addr)
transport = await transport_cls.connect_to( transport = await transport_cls.connect_to(
addr, transport_addr,
**kwargs, **kwargs,
) )
# XXX, for UDS *no!* since we recv the peer-pid and build out # XXX, for UDS *no!* since we recv the peer-pid and build out
@ -551,7 +556,7 @@ class Channel:
@acm @acm
async def _connect_chan( async def _connect_chan(
addr: UnwrappedAddress, addr: UnwrappedAddress|Address|TunnelledAddress,
close_timeout: float|None = None, close_timeout: float|None = None,
) -> typing.AsyncGenerator[Channel, None]: ) -> typing.AsyncGenerator[Channel, None]:
''' '''

View File

@ -59,12 +59,16 @@ from ..msg import (
from ..trionics import maybe_open_nursery from ..trionics import maybe_open_nursery
from ..runtime import _state from ..runtime import _state
from .. import log from .. import log
from ..discovery._addr import Address from ..discovery._addr import (
Address,
UnwrappedAddress,
)
from ._chan import Channel from ._chan import Channel
from ._transport import MsgTransport from ._transport import MsgTransport
if TYPE_CHECKING: if TYPE_CHECKING:
from ..discovery._tunnel import TunnelledAddress
from ..runtime._runtime import Actor from ..runtime._runtime import Actor
from ..runtime._supervise import ActorNursery from ..runtime._supervise import ActorNursery
@ -965,7 +969,9 @@ class Server(Struct):
async def listen_on( async def listen_on(
self, self,
*, *,
accept_addrs: list[tuple[str, int|str]]|None = None, accept_addrs: list[
UnwrappedAddress|Address|TunnelledAddress
]|None = None,
stream_handler_nursery: Nursery|None = None, stream_handler_nursery: Nursery|None = None,
) -> list[Endpoint]: ) -> list[Endpoint]:
''' '''
@ -1048,7 +1054,7 @@ async def _serve_ipc_eps(
*, *,
server: IPCServer, server: IPCServer,
stream_handler_tn: Nursery, stream_handler_tn: Nursery,
listen_addrs: list[tuple[str, int|str]], listen_addrs: list[Address|TunnelledAddress],
task_status: TaskStatus[ task_status: TaskStatus[
Nursery, Nursery,
@ -1064,6 +1070,8 @@ async def _serve_ipc_eps(
`.cancel_server()` is called. `.cancel_server()` is called.
''' '''
from ..discovery._tunnel import strip_tunnels
try: try:
listen_tn: Nursery listen_tn: Nursery
async with trio.open_nursery() as listen_tn: async with trio.open_nursery() as listen_tn:
@ -1072,7 +1080,8 @@ async def _serve_ipc_eps(
# XXX NOTE, required to call `serve_listeners()` below. # XXX NOTE, required to call `serve_listeners()` below.
# ?TODO, maybe just pass `list(eps.values()` tho? # ?TODO, maybe just pass `list(eps.values()` tho?
listeners: list[trio.abc.Listener] = [] listeners: list[trio.abc.Listener] = []
for addr in listen_addrs: for declared_addr in listen_addrs:
addr: Address = strip_tunnels(declared_addr)
ep = Endpoint( ep = Endpoint(
addr=addr, addr=addr,
listen_tn=listen_tn, listen_tn=listen_tn,

View File

@ -47,8 +47,10 @@ if TYPE_CHECKING:
# ONLY type-annots, the eager import costs # ONLY type-annots, the eager import costs
# `import tractor` wall-time (gh #470). # `import tractor` wall-time (gh #470).
from multiaddr import Multiaddr from multiaddr import Multiaddr
from tractor.discovery._addr import TaggedTCPAddress
else: else:
Multiaddr = Any Multiaddr = Any
TaggedTCPAddress = Any
log = get_logger() log = get_logger()
@ -70,7 +72,7 @@ class TCPAddress(
) from valerr ) from valerr
proto_key: ClassVar[str] = 'tcp' proto_key: ClassVar[str] = 'tcp'
unwrapped_type: ClassVar[type] = tuple[str, int] unwrapped_type: ClassVar[type] = tuple
def_bindspace: ClassVar[str] = '127.0.0.1' def_bindspace: ClassVar[str] = '127.0.0.1'
# ?TODO, actually validate ipv4/6 with stdlib's `ipaddress` # ?TODO, actually validate ipv4/6 with stdlib's `ipaddress`
@ -112,19 +114,35 @@ class TCPAddress(
@classmethod @classmethod
def from_addr( def from_addr(
cls, cls,
addr: tuple[str, int] addr: tuple|list,
) -> TCPAddress: ) -> TCPAddress:
match addr: match addr:
case (str(), int()): case (
return TCPAddress(addr[0], addr[1]) ('tcp', str() as host, int() as port)
|
['tcp', str() as host, int() as port]
|
(str() as host, int() as port)
|
[str() as host, int() as port]
|
(
str() as host,
int() as port,
int(),
int(),
)
):
return TCPAddress(host, port)
case _: case _:
raise ValueError( raise ValueError(
f'Invalid unwrapped address for {cls}\n' f'Invalid unwrapped address for {cls}\n'
f'{addr}\n' f'{addr}\n'
) )
def unwrap(self) -> tuple[str, int]: def unwrap(self) -> TaggedTCPAddress:
return ( return (
self.proto_key,
self._host, self._host,
self._port, self._port,
) )
@ -223,7 +241,8 @@ class MsgpackTCPStream(MsgpackTransport):
**kwargs **kwargs
) -> MsgpackTCPStream: ) -> MsgpackTCPStream:
stream = await trio.open_tcp_stream( stream = await trio.open_tcp_stream(
*destaddr.unwrap(), destaddr._host,
destaddr._port,
**kwargs **kwargs
) )
return MsgpackTCPStream( return MsgpackTCPStream(

View File

@ -77,10 +77,12 @@ if TYPE_CHECKING:
# ONLY type-annots, the eager import costs # ONLY type-annots, the eager import costs
# `import tractor` wall-time (gh #470). # `import tractor` wall-time (gh #470).
from multiaddr import Multiaddr from multiaddr import Multiaddr
from tractor.discovery._addr import TaggedUnixAddress
from tractor.runtime._runtime import Actor from tractor.runtime._runtime import Actor
else: else:
Multiaddr = Any Multiaddr = Any
Actor = Any Actor = Any
TaggedUnixAddress = Any
# Platform-specific credential passing constants # Platform-specific credential passing constants
@ -147,7 +149,7 @@ class UDSAddress(
# -[ ] need to check what other mult-transport frameworks do # -[ ] need to check what other mult-transport frameworks do
# like zmq, nng, uri-spec et al! # like zmq, nng, uri-spec et al!
proto_key: ClassVar[str] = 'uds' proto_key: ClassVar[str] = 'uds'
unwrapped_type: ClassVar[type] = tuple[str, int] unwrapped_type: ClassVar[type] = tuple
def_bindspace: ClassVar[Path] = get_rt_dir() def_bindspace: ClassVar[Path] = get_rt_dir()
@property @property
@ -165,7 +167,7 @@ class UDSAddress(
@property @property
def sockpath(self) -> Path: def sockpath(self) -> Path:
return self.bindspace / self.filename return Path(self.bindspace) / self.filename
@property @property
def is_valid(self) -> bool: def is_valid(self) -> bool:
@ -179,16 +181,26 @@ class UDSAddress(
def from_addr( def from_addr(
cls, cls,
addr: ( addr: (
tuple[Path|str, Path|str]|Path|str tuple|list|Path|str
), ),
) -> UDSAddress: ) -> UDSAddress:
match addr: match addr:
case tuple()|list(): case (
filedir = Path(addr[0]) (('unix' | 'uds'), str()|Path() as sockpath)
filename = Path(addr[1]) |
[('unix' | 'uds'), str()|Path() as sockpath]
):
path = Path(sockpath)
return UDSAddress(*unwrap_sockpath(path))
case (
(str()|Path() as filedir, str()|Path() as filename)
|
[str()|Path() as filedir, str()|Path() as filename]
):
return UDSAddress( return UDSAddress(
filedir=filedir, filedir=Path(filedir),
filename=filename, filename=Path(filename),
# maybe_pid=pid, # maybe_pid=pid,
) )
# NOTE, in case we ever decide to just `.unwrap()` # NOTE, in case we ever decide to just `.unwrap()`
@ -203,12 +215,10 @@ class UDSAddress(
f'{addr!r}\n' f'{addr!r}\n'
) )
def unwrap(self) -> tuple[str, int]: def unwrap(self) -> TaggedUnixAddress:
# XXX NOTE, since this gets passed DIRECTLY to
# `.ipc._uds.open_unix_socket_w_passcred()`
return ( return (
str(self.filedir), 'unix',
str(self.filename), str(self.sockpath),
) )
@classmethod @classmethod

View File

@ -213,10 +213,11 @@ class SpawnSpec(
# module import capability # module import capability
enable_modules: dict[str, str] enable_modules: dict[str, str]
# TODO: not just sockaddr pairs? # Tagged addresses have protocol-specific tuple shapes which
# -[ ] abstract into a `TransportAddr` type? # msgspec cannot express as one decodable union. `wrap_address()`
reg_addrs: list[tuple[str, str|int]] # validates each tuple at the transport boundary.
bind_addrs: list[tuple[str, str|int]]|None reg_addrs: list[tuple]
bind_addrs: list[tuple]|None
# TODO: caps based RPC support in the payload? # TODO: caps based RPC support in the payload?

14
uv.lock
View File

@ -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", marker = "python_full_version < '3.14'" }, { name = "greenlet" },
{ name = "outcome", marker = "python_full_version < '3.14'" }, { name = "outcome" },
{ name = "sniffio", marker = "python_full_version < '3.14'" }, { name = "sniffio" },
] ]
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 = { registry = "https://pypi.org/simple" } source = { git = "https://github.com/multiformats/py-multiaddr.git?rev=f86519daaa21699023d0037c58cdff600313dd09#f86519daaa21699023d0037c58cdff600313dd09" }
dependencies = [ dependencies = [
{ name = "base58" }, { name = "base58" },
{ name = "dnspython" }, { name = "dnspython" },
@ -533,10 +533,6 @@ 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"
@ -1193,7 +1189,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", specifier = ">=0.2.0" }, { name = "multiaddr", git = "https://github.com/multiformats/py-multiaddr.git?rev=f86519daaa21699023d0037c58cdff600313dd09" },
{ 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" },