Compare commits
35 Commits
a24772e4a6
...
d097090f2e
| Author | SHA1 | Date |
|---|---|---|
|
|
d097090f2e | |
|
|
93dbbd5a02 | |
|
|
4152b23d3a | |
|
|
299e044d49 | |
|
|
fbab40fa6d | |
|
|
bf96e0f622 | |
|
|
a0f2891c4b | |
|
|
4e1c1f7a27 | |
|
|
76d4fda335 | |
|
|
36658efec7 | |
|
|
e04081440d | |
|
|
34e47af8a9 | |
|
|
71772b228d | |
|
|
736c6f1a3a | |
|
|
0fbaeb44b1 | |
|
|
9f9a695d79 | |
|
|
68730b4e22 | |
|
|
ac39314cfd | |
|
|
1305c720b3 | |
|
|
3a737f960b | |
|
|
9ec5b1e702 | |
|
|
62119cf132 | |
|
|
bc2b236619 | |
|
|
0d95a9e63c | |
|
|
c857ad09f2 | |
|
|
46a2daff23 | |
|
|
ffb87d351e | |
|
|
b517f7f523 | |
|
|
22e154900b | |
|
|
a26a0a48b6 | |
|
|
cc1bb333e6 | |
|
|
0c52f2770a | |
|
|
e4e27ec71d | |
|
|
7c2de6c359 | |
|
|
83e4af169c |
|
|
@ -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
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-21T23:32:04Z
|
||||||
|
git_ref: 5d92595f
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260821T233204Z_5d92595f_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human asked to proceed with WireGuard Layer B from PR #505's
|
||||||
|
head, using the established one-change-per-turn workflow and
|
||||||
|
finishing with a commit plan. This turn was limited to read-only
|
||||||
|
pyroute2 inspection; peer verification and bindspace provisioning
|
||||||
|
remain later changes.
|
||||||
|
|
||||||
|
During review, the human required the generated Python to follow the
|
||||||
|
deployed `/py-codestyle` rules and asked for the medium-term plan to
|
||||||
|
remove pyroute2's asyncio I/O runtime from the Trio read path.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added Linux-only read helpers for WireGuard device and peer public
|
||||||
|
keys. Pyroute2's synchronous API is fully contained in a Trio worker
|
||||||
|
thread, supports named netns reads without creation side effects,
|
||||||
|
validates decoded keys and always closes the netlink client.
|
||||||
|
Follow-up edits added the required local annotations, boolean layout,
|
||||||
|
helper docstrings and 69-column source formatting.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `pyproject.toml` - Linux-only `wg` optional dependency.
|
||||||
|
- `uv.lock` - resolved pyroute2 0.9.6 metadata.
|
||||||
|
- `tractor/discovery/_tunnel.py` - read-only WireGuard helpers.
|
||||||
|
- `tractor/discovery/__init__.py` - public helper exports.
|
||||||
|
- `tests/discovery/test_wg_inspection.py` - fake-backed netlink and
|
||||||
|
worker-thread regressions.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human chose the exact #505 head as a stacked base, required
|
||||||
|
incremental changes ending in commit plans, and limited this turn to
|
||||||
|
read-only pyroute2 inspection. The human then identified that Ruff
|
||||||
|
success had not established `/py-codestyle` compliance and directed
|
||||||
|
the agent to correct the Python-specific annotation, documentation
|
||||||
|
and layout rules. No direct manual source edits were observed.
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-21T23:32:04Z
|
||||||
|
git_ref: 5d92595f
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - read WireGuard state through pyroute2
|
||||||
|
|
||||||
|
The human authorized the first incremental WireGuard Layer B change
|
||||||
|
as a stacked branch from tractor PR #505, with one atomic change and
|
||||||
|
a commit plan at the end of the turn.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- pyproject.toml uv.lock tractor/discovery/__init__.py tractor/discovery/_tunnel.py tests/discovery/test_wg_inspection.py`
|
||||||
|
|
||||||
|
Added a Linux-only `wg` extra using pyroute2 0.9.6, plus
|
||||||
|
asynchronous public helpers for reading one interface's public key
|
||||||
|
and peer keys. The complete synchronous open/read/parse/close
|
||||||
|
sequence runs in a Trio worker thread because pyroute2 owns a private
|
||||||
|
asyncio loop internally.
|
||||||
|
|
||||||
|
Named namespace reads pass `flags=0` to override pyroute2's `O_CREAT`
|
||||||
|
default, ensuring inspection cannot create a missing namespace. Fake
|
||||||
|
netlink messages cover multipart dumps, key validation, stable peer
|
||||||
|
deduplication, worker-thread execution, netns selection and cleanup
|
||||||
|
on success/error.
|
||||||
|
|
||||||
|
Ruff and lock checks passed. Focused tunnel/multiaddr coverage passed
|
||||||
|
47 tests; the complete discovery suite passed 88 tests with 2
|
||||||
|
xpasses.
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-22T02:32:26Z
|
||||||
|
git_ref: 59a8ecfd
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260822T023226Z_59a8ecfd_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing the read-only pyroute2 helpers and `wgman` design
|
||||||
|
update, the human authorized the next isolated Layer B change:
|
||||||
|
explicit `verify_wg_peer()` composition over WireGuard inspection.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added and exported async `verify_wg_peer()` using one validated
|
||||||
|
WireGuard key snapshot. It recognizes local-interface and configured
|
||||||
|
peer identities without coupling kernel inspection to address
|
||||||
|
parsing. Updated the multihost examples to use the production helper
|
||||||
|
and removed their subprocess-based probe.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_tunnel.py` - shared async snapshot reader and
|
||||||
|
explicit verification helper.
|
||||||
|
- `tractor/discovery/__init__.py` - public verification export.
|
||||||
|
- `tests/discovery/test_wg_inspection.py` - local, peer, absent and
|
||||||
|
malformed-key verification coverage.
|
||||||
|
- `examples/multihost/wg_lan/host_a_srv.py` - async local-key check.
|
||||||
|
- `examples/multihost/wg_lan/host_b_client.py` - async peer-key check.
|
||||||
|
- `examples/multihost/wg_lan/wg_maddr.py` - removed obsolete
|
||||||
|
subprocess probe.
|
||||||
|
- `examples/multihost/wg_lan/README.md` - pyroute2 requirements and
|
||||||
|
verification workflow.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - async API contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected this pre-agreed verification layer as the next
|
||||||
|
atomic change after reviewing and committing the preceding read and
|
||||||
|
architecture changes. The agent implemented the source changes; no
|
||||||
|
direct manual edits or follow-up corrections were observed.
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-22T02:32:26Z
|
||||||
|
git_ref: 59a8ecfd
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - verify declared WireGuard identities
|
||||||
|
|
||||||
|
The human authorized the next incremental Layer B change after
|
||||||
|
committing the read-only pyroute2 helpers and first-child `wgman`
|
||||||
|
design update.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_inspection.py examples/multihost/wg_lan ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added async `verify_wg_peer()` over one WireGuard key snapshot. It
|
||||||
|
validates the declared `WGTunnelSpec.peer_pubkey` before I/O, forwards
|
||||||
|
the spec's iface/netns, and accepts either the local interface key for
|
||||||
|
a source/listen declaration or a configured peer key for a
|
||||||
|
destination/dial declaration.
|
||||||
|
|
||||||
|
Refactored worker offload behind one shared async reader so
|
||||||
|
verification cannot compare two different netlink snapshots. Exported
|
||||||
|
the helper, added local/peer/absent/malformed-key coverage, and moved
|
||||||
|
the multihost examples from their local `wg show` subprocess probe to
|
||||||
|
the production API.
|
||||||
|
|
||||||
|
Ruff and lock checks passed. Focused WireGuard/tunnel/multiaddr
|
||||||
|
coverage passed 51 tests; the complete discovery suite passed 92
|
||||||
|
tests with 2 xpasses.
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-22T03:25:20Z
|
||||||
|
git_ref: d35c802b
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260822T032520Z_d35c802b_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human reported the explicit WireGuard verification commit done,
|
||||||
|
asked the agent to check it, and authorized the next incremental
|
||||||
|
Layer C change.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Verified commit `d35c802b`, made plain transport namespace behavior
|
||||||
|
explicit, and retained each original listener declaration beside its
|
||||||
|
peeled transport address. Endpoint and server diagnostics can now
|
||||||
|
report a tunnel's namespace without violating the transport boundary.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/ipc/_tcp.py` - explicit plain-address namespace property.
|
||||||
|
- `tractor/ipc/_uds.py` - explicit plain-address namespace property.
|
||||||
|
- `tractor/discovery/_tunnel.py` - direct overlay namespace delegation.
|
||||||
|
- `tractor/ipc/_server.py` - retained declaration and namespace
|
||||||
|
diagnostics.
|
||||||
|
- `tests/discovery/test_tunnelled_addr.py` - plain and tunnel namespace
|
||||||
|
behavior.
|
||||||
|
- `tests/ipc/test_server_tunnel_boundary.py` - declaration retention
|
||||||
|
and diagnostic coverage.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - concrete endpoint
|
||||||
|
boundary contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected continued incremental implementation after
|
||||||
|
reviewing and committing the preceding verification layer. The agent
|
||||||
|
implemented this dependency-ordered namespace slice; no direct manual
|
||||||
|
edits or follow-up corrections were observed.
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-22T03:25:20Z
|
||||||
|
git_ref: d35c802b
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - retain endpoint namespace declarations
|
||||||
|
|
||||||
|
The human reported the explicit WireGuard verification commit done,
|
||||||
|
asked for it to be checked, and authorized the next incremental
|
||||||
|
change.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/ipc/_tcp.py tractor/ipc/_uds.py tractor/discovery/_tunnel.py tractor/ipc/_server.py tests/discovery/test_tunnelled_addr.py tests/ipc/test_server_tunnel_boundary.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Confirmed commit `d35c802b` and a clean worktree, then implemented the
|
||||||
|
smallest dependency-ordered Layer C slice. Plain TCP and UDS addresses
|
||||||
|
now explicitly report no namespace, allowing `TunnelledAddress` to
|
||||||
|
delegate without an attribute fallback.
|
||||||
|
|
||||||
|
Added required `Endpoint.declared_addr` metadata beside the peeled,
|
||||||
|
resolved `Endpoint.addr`. Endpoint and server diagnostics expose the
|
||||||
|
declaration's namespace without passing a tunnel wrapper into
|
||||||
|
transport reflection. Updated the Layer C plan and tests for plain,
|
||||||
|
tunnelled, endpoint and server namespace behavior.
|
||||||
|
|
||||||
|
Ruff passed. Focused namespace tests passed 13 tests; combined
|
||||||
|
discovery and IPC coverage passed 101 tests with 2 xpasses.
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-22T04:20:26Z
|
||||||
|
git_ref: 29141f0b
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260822T042026Z_29141f0b_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing endpoint namespace visibility, the human authorized
|
||||||
|
continued Layer C implementation.
|
||||||
|
|
||||||
|
During staged review, the human requested Literal-derived validation,
|
||||||
|
ownership documentation, stable-inode clarification, explicit
|
||||||
|
non-serialization rationale and consolidated invalid-model tests.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added the foundational bindspace model: serializable declarations and
|
||||||
|
stable identities are separated from a process-local live capability.
|
||||||
|
The handle validates names, ownership and FD identity. A global
|
||||||
|
`ProcessLocal` sentinel blocks default encoding while retaining
|
||||||
|
msgspec struct behavior.
|
||||||
|
Review fixes require a positive inode for every realized netns,
|
||||||
|
derive runtime choices from the Literal aliases and clarify that an FD
|
||||||
|
integer is not transferable capability authority.
|
||||||
|
The human then clarified that msgspec structs are useful generic
|
||||||
|
storage independently of serialization policy, so the live handle now
|
||||||
|
uses a struct while remaining process-local by contract.
|
||||||
|
The human first selected an opaque FD wrapper, then recognized that
|
||||||
|
future process-local handles need the same guard and directed a global
|
||||||
|
marker under `tractor.msg` instead.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_bindspace.py` - declaration, identity and live
|
||||||
|
capability models.
|
||||||
|
- `tractor/discovery/__init__.py` - public bindspace exports.
|
||||||
|
- `tractor/msg/_local.py` - reusable process-local struct marker.
|
||||||
|
- `tractor/msg/__init__.py` - public `ProcessLocal` export.
|
||||||
|
- `tests/discovery/test_bindspace.py` - serialization and capability
|
||||||
|
invariant coverage.
|
||||||
|
- `tests/msg/test_process_local.py` - direct and nested wire rejection.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - concrete initial model
|
||||||
|
contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected the foundational Layer C capability model, then
|
||||||
|
reviewed the staged patch and directed all eight follow-up changes:
|
||||||
|
remove distracting ownership variation, consolidate invalid-model
|
||||||
|
tests, derive containment checks from Literal aliases, require a
|
||||||
|
stable inode and explain why the live handle is not a msgspec struct.
|
||||||
|
The human subsequently corrected that last design choice: struct use
|
||||||
|
does not imply IPC authorization, so `BindspaceHandle` should retain
|
||||||
|
msgspec's generic storage benefits. The agent applied those
|
||||||
|
human-directed edits. The human first chose an opaque process-local FD
|
||||||
|
wrapper, then refined that decision to a reusable global marker under
|
||||||
|
`tractor.msg` for future local handles. No direct manual source edits
|
||||||
|
were observed.
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-22T04:20:26Z
|
||||||
|
git_ref: 29141f0b
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - model bindspace capabilities
|
||||||
|
|
||||||
|
The human authorized the next incremental Layer C change after
|
||||||
|
committing endpoint namespace visibility.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added frozen, serializable `BindspaceSpec` and
|
||||||
|
`BindspaceIdentity` models for the initial Linux netns kind. Added a
|
||||||
|
plain slotted `BindspaceHandle` which carries ownership and an optional
|
||||||
|
namespace FD without being serializable by default msgspec encoding.
|
||||||
|
|
||||||
|
The live handle validates declaration/identity kind and name, ownership
|
||||||
|
values, non-negative FDs and FD/inode correspondence. Serializable
|
||||||
|
models reject empty identities and kinds without implementations.
|
||||||
|
|
||||||
|
Added round-trip, non-serialization, identity-mismatch, empty-identity
|
||||||
|
and unsupported-kind tests. Ruff passed; discovery plus
|
||||||
|
message-serialization coverage passed 125 tests with 2 xpasses.
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-25T04:55:57Z
|
||||||
|
git_ref: fdccfd7e
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260825T045557Z_fdccfd7e_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing the foundational bindspace capability model, the
|
||||||
|
human authorized the next incremental Layer C change.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added a borrow-only existing-netns context manager which pins stable
|
||||||
|
identity through an open FD and always releases that local descriptor.
|
||||||
|
The API does not create, enter or remove namespaces and rejects named
|
||||||
|
path traversal.
|
||||||
|
Staged review renamed the context to `attach_netns()`, introduced the
|
||||||
|
readable `CURRENT_NETNS` alias and made key validation errors identify
|
||||||
|
their exact owning field.
|
||||||
|
Follow-up review made the empty-key error spell out that `None` is
|
||||||
|
written as `CURRENT_NETNS` for `BindspaceSpec.key` declarations.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_bindspace.py` - existing-netns lifecycle and key
|
||||||
|
validation.
|
||||||
|
- `tractor/discovery/__init__.py` - public lifecycle export.
|
||||||
|
- `tests/discovery/test_bindspace.py` - current, named, missing and
|
||||||
|
traversal coverage.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - borrow-only lifecycle
|
||||||
|
contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected the previously deferred borrow-only netns lifecycle
|
||||||
|
as the next incremental Layer C change. The agent implemented the
|
||||||
|
source changes. During staged review, the human selected
|
||||||
|
`attach_netns()` terminology, requested explicit
|
||||||
|
`BindspaceSpec.key = CURRENT_NETNS` semantics and field-specific key
|
||||||
|
validation. Follow-up review requested the validation error itself
|
||||||
|
connect `None` to `CURRENT_NETNS`. The agent applied those
|
||||||
|
human-directed edits; no direct manual source edits were observed.
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-25T04:55:57Z
|
||||||
|
git_ref: fdccfd7e
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - borrow existing netns bindspaces
|
||||||
|
|
||||||
|
The human reported the bindspace capability model committed and
|
||||||
|
authorized the next incremental Layer C change.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added async `open_existing_netns()` as a borrow-only context manager.
|
||||||
|
It opens the current process netns or a named entry under the standard
|
||||||
|
iproute2 run directory, derives stable identity from the opened FD,
|
||||||
|
and yields a borrowed process-local `BindspaceHandle`.
|
||||||
|
|
||||||
|
The context uses `O_CLOEXEC`, never creates, enters or removes a
|
||||||
|
namespace, and synchronously closes only its FD on exit. Netns keys
|
||||||
|
reject paths to keep named lookup beneath the run directory.
|
||||||
|
|
||||||
|
Added current, named, missing and traversal tests. Ruff and lock checks
|
||||||
|
passed; discovery plus message coverage passed 132 tests with 2
|
||||||
|
xpasses.
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-25T19:05:29Z
|
||||||
|
git_ref: e1007547
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260825T190529Z_e1007547_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing the attach-only network namespace lifecycle, the
|
||||||
|
human authorized the next incremental Layer C change.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added owned `open_netns()` creation and teardown through shielded
|
||||||
|
pyroute2 worker calls. The context pins identity through
|
||||||
|
`attach_netns()`, yields an owned handle, closes its FD before removal
|
||||||
|
and cleans up despite caller cancellation.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_bindspace.py` - owned netns lifecycle.
|
||||||
|
- `tractor/discovery/__init__.py` - public lifecycle export.
|
||||||
|
- `tests/discovery/test_bindspace.py` - ownership, ordering,
|
||||||
|
cancellation and name requirements.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - owned lifecycle and
|
||||||
|
spawn-boundary contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected owned network namespace creation as the next
|
||||||
|
incremental Layer C change. The agent implemented the source changes;
|
||||||
|
no direct manual edits or follow-up corrections were observed.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-25T19:05:29Z
|
||||||
|
git_ref: e1007547
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - own created netns bindspaces
|
||||||
|
|
||||||
|
The human reported the attach-only netns lifecycle committed and
|
||||||
|
authorized the next incremental Layer C change.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added async `open_netns()` as the owned counterpart to
|
||||||
|
`attach_netns()`. It requires a named spec, creates through pyroute2 in
|
||||||
|
a shielded worker call, attaches the resulting namespace FD and yields
|
||||||
|
an owned process-local `BindspaceHandle`.
|
||||||
|
|
||||||
|
FD closure occurs before shielded namespace removal on normal,
|
||||||
|
exceptional and cancelled exits. The context never calls `setns()`;
|
||||||
|
namespace entry remains a spawn/bootstrap responsibility.
|
||||||
|
|
||||||
|
Privileged operations are faked in tests. Ruff and lock checks passed;
|
||||||
|
discovery plus message coverage passed 137 tests with 2 xpasses.
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-25T19:18:45Z
|
||||||
|
git_ref: 5b2a064a
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260825T191845Z_5b2a064a_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human pointed out that the agent had produced no changes after the
|
||||||
|
previous commit and expected the next incremental implementation.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Corrected the sequencing mistake and added explicit serialized
|
||||||
|
bindspace lifecycle policy plus generic `open_bindspace()` dispatch.
|
||||||
|
Borrowed and owned netns contexts share a policy-neutral FD-pinning
|
||||||
|
primitive while validating their public lifecycle before side effects.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_bindspace.py` - lifecycle policy, ownership
|
||||||
|
invariants, shared pinning and dispatch.
|
||||||
|
- `tractor/discovery/__init__.py` - public policy and dispatcher exports.
|
||||||
|
- `tests/discovery/test_bindspace.py` - lifecycle serialization,
|
||||||
|
validation and both dispatcher branches.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - explicit lifecycle
|
||||||
|
policy independent of transport role.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human identified that the agent had accidentally repeated a
|
||||||
|
summary of already committed work instead of implementing the next
|
||||||
|
slice. That correction directly caused this lifecycle/dispatcher
|
||||||
|
change to be implemented. The agent wrote the source changes; no
|
||||||
|
direct manual edits were observed.
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-25T19:18:45Z
|
||||||
|
git_ref: 5b2a064a
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - dispatch explicit bindspace lifecycle
|
||||||
|
|
||||||
|
The human noticed the agent had re-reported already committed work
|
||||||
|
without changing the worktree. The agent confirmed the sequencing
|
||||||
|
error and proceeded to the actual next Layer C slice.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added serialized `BindspaceSpec.lifecycle` policy with explicit
|
||||||
|
`attach` and `open` choices. Added `open_bindspace()` dispatch without
|
||||||
|
inferring resource ownership from listen/dial role.
|
||||||
|
|
||||||
|
Refactored FD attachment into policy-neutral `_pin_netns()` so
|
||||||
|
borrowed and owned public lifecycles can share identity pinning while
|
||||||
|
enforcing their own policy before side effects. Handle construction
|
||||||
|
also verifies lifecycle and resulting ownership agree.
|
||||||
|
|
||||||
|
One composition error introduced during implementation was caught by
|
||||||
|
the focused test: owned creation initially called the newly guarded
|
||||||
|
public attach API. `_pin_netns()` fixed that layering error. Ruff and
|
||||||
|
lock checks passed; discovery plus message coverage passed 138 tests
|
||||||
|
with 2 xpasses.
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-25T23:46:31Z
|
||||||
|
git_ref: b973e78c
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260825T234631Z_b973e78c_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human pointed out that the agent had again produced no worktree
|
||||||
|
changes after the latest commit and expected actual next-layer work.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Corrected the sequencing mistake and added a process-local WireGuard
|
||||||
|
interface configuration boundary. Secret/routing policy is separated
|
||||||
|
from serializable maddr-derived tunnel declarations, redacted from
|
||||||
|
repr and blocked from default wire encoding.
|
||||||
|
Staged review clarified that address/network parser calls validate
|
||||||
|
every declaration and do not select or consume a first address.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_tunnel.py` - local WG config and validation.
|
||||||
|
- `tractor/discovery/__init__.py` - public config export.
|
||||||
|
- `tests/discovery/test_wg_config.py` - secrecy and validation coverage.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - secret/config boundary
|
||||||
|
for future interface provisioning.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human identified the second sequencing failure where the agent
|
||||||
|
summarized an already committed slice instead of implementing the next
|
||||||
|
one. That correction directly caused this new WG configuration layer
|
||||||
|
to be implemented. During staged review, the human requested explicit
|
||||||
|
explanation of the address and allowed-route validation loops. The
|
||||||
|
agent applied that human-directed clarification; no direct manual edits
|
||||||
|
were observed.
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-25T23:46:31Z
|
||||||
|
git_ref: b973e78c
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - separate local WireGuard configuration
|
||||||
|
|
||||||
|
The human again noticed the agent had re-reported already committed
|
||||||
|
work without changing the worktree. The agent confirmed the sequencing
|
||||||
|
mistake and moved to the next unimplemented dependency.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_config.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added process-local `WGInterfaceConfig` for private/preshared keys,
|
||||||
|
local interface CIDRs, peer allowed CIDRs, listen port and persistent
|
||||||
|
keepalive. Secrets are redacted from repr and the global
|
||||||
|
`ProcessLocal` marker blocks default wire encoding.
|
||||||
|
|
||||||
|
Removed the unused serialized `WGTunnelSpec.maybe_allowed_ips`
|
||||||
|
placeholder so maddr-derived declarations retain only public identity,
|
||||||
|
endpoint and interface selection. Added validation for keys, CIDRs,
|
||||||
|
ports and keepalive before future kernel mutation.
|
||||||
|
|
||||||
|
Ruff and lock checks passed. Focused WG coverage passed 25 tests;
|
||||||
|
discovery plus message coverage passed 145 tests with 2 xpasses.
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-26T00:14:42Z
|
||||||
|
git_ref: dcdf4d82
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260826T001442Z_dcdf4d82_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human selected an explicit peer-list model rather than a
|
||||||
|
dial-only single-peer shortcut before `open_wg_iface()`.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added process-local per-peer configuration and refactored interface
|
||||||
|
configuration to own a unique peer tuple. This supports multi-peer
|
||||||
|
listeners and dial targets without overloading role-dependent tunnel
|
||||||
|
maddr identity.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_tunnel.py` - peer model and interface peer list.
|
||||||
|
- `tractor/discovery/__init__.py` - public peer-config export.
|
||||||
|
- `tests/discovery/test_wg_config.py` - peer secrecy and validation.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - explicit multi-peer
|
||||||
|
provisioning contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human chose explicit peer lists over a dial-only implementation so
|
||||||
|
listeners can represent multiple client public keys and routing
|
||||||
|
policies. The agent implemented that human-selected design; no direct
|
||||||
|
manual source edits were observed.
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-26T00:14:42Z
|
||||||
|
git_ref: dcdf4d82
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - model explicit WireGuard peers
|
||||||
|
|
||||||
|
After committing local WG interface configuration, the human selected
|
||||||
|
an explicit peer-list model before interface provisioning.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_config.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added process-local `WGPeerConfig` with public key, allowed CIDRs,
|
||||||
|
optional endpoint, preshared key and keepalive. Refactored
|
||||||
|
`WGInterfaceConfig` to own a tuple of unique peers beside its private
|
||||||
|
key, local addresses and listen port.
|
||||||
|
|
||||||
|
Peer PSKs remain redacted and nested `ProcessLocal` sentinels prevent
|
||||||
|
default wire encoding. Validation covers keys, routes, endpoints,
|
||||||
|
ports, keepalive and duplicate peers.
|
||||||
|
|
||||||
|
Ruff and lock checks passed. Focused peer/config coverage passed 11
|
||||||
|
tests. A broad unrelated registrar cancellation case timed out after
|
||||||
|
86 passes and 2 xpasses; its isolated rerun passed.
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-26T00:34:30Z
|
||||||
|
git_ref: 6dd39da0
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260826T003430Z_6dd39da0_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing explicit WireGuard peer configuration, the human
|
||||||
|
authorized the next incremental lifecycle change.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added owned WireGuard interface provisioning and teardown through
|
||||||
|
cancellation-shielded pyroute2 worker calls. Role-specific policy is
|
||||||
|
validated before mutation, local addresses and explicit peers are
|
||||||
|
configured, and owned interfaces are removed on exit.
|
||||||
|
Staged review clarified role-specific test assertions, added the
|
||||||
|
second peer's endpoint-port check, included conflicting values in
|
||||||
|
policy errors and aligned annotated assignment with `/py-codestyle`.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_tunnel.py` - role policy and WG iface lifecycle.
|
||||||
|
- `tractor/discovery/__init__.py` - public lifecycle and role exports.
|
||||||
|
- `tests/discovery/test_wg_iface_lifecycle.py` - policy and cancellation
|
||||||
|
coverage with privileged calls faked.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - initial owned WG iface
|
||||||
|
lifecycle contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected continuation from explicit peer modeling into the
|
||||||
|
owned WireGuard interface lifecycle. During staged review, the human
|
||||||
|
requested clearer role assertions, complete endpoint checks, concrete
|
||||||
|
error values and inline annotated assignment. The agent applied those
|
||||||
|
human-directed edits; no direct manual source edits were observed.
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-26T00:34:30Z
|
||||||
|
git_ref: 6dd39da0
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - own WireGuard interface lifecycle
|
||||||
|
|
||||||
|
After committing explicit peer configuration, the human authorized the
|
||||||
|
next incremental lifecycle change.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added owned async `open_wg_iface()` lifecycle with role-policy
|
||||||
|
validation before side effects. Listen bearer ports configure the local
|
||||||
|
listener; dial bearers fill omitted endpoints only for the selected
|
||||||
|
maddr peer.
|
||||||
|
|
||||||
|
Pyroute2 worker code creates the WireGuard link, assigns local
|
||||||
|
addresses, configures private key/listen port and every explicit peer,
|
||||||
|
raises the link, and removes it on exit. Partial create failures clean
|
||||||
|
up synchronously; async create/remove calls are cancellation-shielded.
|
||||||
|
|
||||||
|
Privileged operations are faked in tests. Ruff and lock checks passed;
|
||||||
|
focused WG lifecycle/config/inspection coverage passed 22 tests. A
|
||||||
|
broad unrelated registrar cancellation parameter timed out after 76
|
||||||
|
passes and 2 xpasses; its isolated rerun passed.
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-26T02:24:34Z
|
||||||
|
git_ref: 2245f094
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260826T022434Z_2245f094_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing the owned WireGuard interface lifecycle, the human
|
||||||
|
asked to continue with the next established incremental step.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added a WG-specific composition context which owns one declared
|
||||||
|
bindspace and an ordered interface stack. It snapshots caller-owned
|
||||||
|
layer order before checkpointing, enters interfaces outermost-first,
|
||||||
|
and guarantees inside-out interface teardown before bindspace release.
|
||||||
|
Its inbound type shows the `(WGTunnelSpec, WGInterfaceConfig)` pairing
|
||||||
|
directly, and its docstring depicts the stack and unwind order.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_tunnel.py` - bindspace/interface stack driver.
|
||||||
|
- `tractor/discovery/__init__.py` - public layer and driver exports.
|
||||||
|
- `tests/discovery/test_wg_iface_lifecycle.py` - mutable-input,
|
||||||
|
cancellation and nested teardown-order coverage.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - implemented composition
|
||||||
|
and ownership-order contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human accepted and committed the preceding reviewed lifecycle
|
||||||
|
boundary, then explicitly requested the next step. That selected the
|
||||||
|
previously established bindspace/WireGuard composition boundary. In
|
||||||
|
staged review, the human requested an explicit interface-stack analogy
|
||||||
|
and a small ASCII diagram explaining entry and exit order. The human
|
||||||
|
then identified `WGInterfaceLayer` as an unnecessary single-use alias
|
||||||
|
which obscured the inbound tuple shape. The agent applied both
|
||||||
|
human-directed edits. The human accepted the fake lifecycle test as a
|
||||||
|
focused control-flow check which avoids pyroute2 overhead, while noting
|
||||||
|
their general preference for end-to-end coverage. No direct manual
|
||||||
|
source edits were observed.
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-26T02:24:34Z
|
||||||
|
git_ref: 2245f094
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - compose WireGuard bindspace lifecycles
|
||||||
|
|
||||||
|
After committing the owned WireGuard interface lifecycle, the human
|
||||||
|
requested the next incremental change.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added `open_wg_bindspace()` to compose one declared bindspace with an
|
||||||
|
ordered sequence of WireGuard interface layers. The context enters the
|
||||||
|
bindspace first, enters interfaces outermost-first through
|
||||||
|
`AsyncExitStack`, yields the live `BindspaceHandle`, and removes
|
||||||
|
interfaces inside-out before releasing or removing the namespace.
|
||||||
|
|
||||||
|
The caller's layer sequence is snapshotted before the first checkpoint,
|
||||||
|
so concurrent mutation cannot alter resource entry or unwind order.
|
||||||
|
Fake lifecycle tests clear a mutable input list during bindspace entry,
|
||||||
|
cancel from the yielded scope, and prove the original stack unwinds in
|
||||||
|
dependency-safe order.
|
||||||
|
|
||||||
|
Ruff passed and focused bindspace/WireGuard coverage passed 39 tests.
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-26T03:05:34Z
|
||||||
|
git_ref: d130431c
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260826T030534Z_d130431c_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing the reviewed bindspace/interface stack, the human
|
||||||
|
asked to continue with the next established Layer C step.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Retained a serializable realized `BindspaceRef` on frozen tunnelled
|
||||||
|
address declarations without carrying the process-local `Bindspace`
|
||||||
|
capability. The namespace API remains tuple-shaped, using the declared
|
||||||
|
key before realization and stable inode afterward. Existing transport
|
||||||
|
peeling and unrealized serialization remain unchanged.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_tunnel.py` - realized bindspace-ref retention and
|
||||||
|
immutable annotation method.
|
||||||
|
- `tests/discovery/test_tunnelled_addr.py` - ref, compatibility and
|
||||||
|
mismatch coverage.
|
||||||
|
- `tests/ipc/test_server_tunnel_boundary.py` - real listener diagnostic
|
||||||
|
coverage for the realized inode.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - realized namespace
|
||||||
|
visibility contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human accepted and committed the preceding reviewed composition
|
||||||
|
boundary, then requested the next step. Their stated preference for
|
||||||
|
end-to-end tests informed use of the existing real TCP listener path to
|
||||||
|
verify endpoint/server diagnostics, while focused unit coverage handles
|
||||||
|
metadata invariants without pyroute2 overhead. During review, the human
|
||||||
|
chose the final unshipped terminology: `BindspaceRef` for the
|
||||||
|
serializable non-owning reference, `Bindspace` for the live
|
||||||
|
process-local capability, `.ref` for that capability's reference, and
|
||||||
|
`bindspace_ref` at the tunnel declaration API.
|
||||||
|
They then requested the prerequisite model rename as a separate commit
|
||||||
|
before this feature. The human also replaced the module-level helper
|
||||||
|
with `TunnelledAddress.with_bindspace_ref()` and requested inline
|
||||||
|
msgspec encode/decode expressions in the serialization assertions. The
|
||||||
|
human chose not to add a second typed `.namespace` projection, and
|
||||||
|
requested native tagged `TunnelledAddress` decoding remain as a separate
|
||||||
|
design-plan follow-up. The agent applied those human-directed changes;
|
||||||
|
no direct manual source edits were observed.
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-26T03:05:34Z
|
||||||
|
git_ref: d130431c
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - retain realized bindspace identity
|
||||||
|
|
||||||
|
After committing WireGuard bindspace composition, the human requested
|
||||||
|
the next incremental Layer C change.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_tunnelled_addr.py tests/ipc/test_server_tunnel_boundary.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added optional `BindspaceIdentity` metadata to frozen
|
||||||
|
`TunnelledAddress` declarations and a pure
|
||||||
|
`with_bindspace_identity()` annotation helper. Unrealized declarations
|
||||||
|
retain their prior serialized shape. Realized declarations retain only
|
||||||
|
serializable key/inode identity, never the FD-bearing capability.
|
||||||
|
|
||||||
|
The existing `.namespace` tuple contract remains compatible:
|
||||||
|
unrealized declarations report `(kind, key)`, while realized
|
||||||
|
declarations report the stable `(kind, inode)`. Name mismatches between
|
||||||
|
the tunnel declaration and realized bindspace are rejected.
|
||||||
|
|
||||||
|
Unit coverage verifies immutability, serialization, delegation and
|
||||||
|
mismatch handling. The existing real TCP listener test proves endpoint
|
||||||
|
and server diagnostics expose the retained inode without implying that
|
||||||
|
the process entered that namespace. Ruff passed and focused tunnel,
|
||||||
|
listener and bindspace coverage passed 30 tests.
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-27T21:11:15Z
|
||||||
|
git_ref: d130431c
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260827T211115Z_d130431c_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
During review, the human requested concrete names for the existing
|
||||||
|
bindspace abstractions and asked to isolate that rename before realized
|
||||||
|
reference retention.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Renamed the serializable resource record to `BindspaceRef`, the live
|
||||||
|
FD-backed capability to `Bindspace`, and the capability's record field
|
||||||
|
to `.ref`. Updated existing lifecycle APIs, tests and active design
|
||||||
|
contracts without compatibility aliases.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/discovery/_bindspace.py` - renamed models and `.ref` field.
|
||||||
|
- `tractor/discovery/_tunnel.py` - existing lifecycle annotations.
|
||||||
|
- `tractor/discovery/__init__.py` - renamed public model exports.
|
||||||
|
- `tests/discovery/test_bindspace.py` - renamed model contracts.
|
||||||
|
- `tests/discovery/test_wg_iface_lifecycle.py` - live bindspace usage.
|
||||||
|
- `ai/tpt-backends/00_shared_backend_contract.md` - shared terminology.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - request, ref and live
|
||||||
|
capability terminology.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human identified the earlier identity/handle terminology as too
|
||||||
|
abstract for an IPC-passed non-owning reference and live resource. They
|
||||||
|
selected `BindspaceRef`, `Bindspace`, and `.ref`, then requested this
|
||||||
|
rename as a prerequisite commit separate from the realized-ref feature.
|
||||||
|
The agent applied those human-directed names; no direct manual source
|
||||||
|
edits were observed.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-27T21:11:15Z
|
||||||
|
git_ref: d130431c
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - simplify bindspace model names
|
||||||
|
|
||||||
|
While reviewing realized bindspace metadata, the human requested more
|
||||||
|
concrete names for the existing bindspace model family and asked that
|
||||||
|
the rename remain separate from the subsequent feature.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/00_shared_backend_contract.md ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Renamed `BindspaceIdentity` to `BindspaceRef` for the serializable,
|
||||||
|
non-owning, host-local resource record. Renamed `BindspaceHandle` to
|
||||||
|
`Bindspace` for the live process-local FD-backed capability, and renamed
|
||||||
|
its `.identity` field to `.ref`.
|
||||||
|
|
||||||
|
No compatibility aliases were added because the models are unshipped.
|
||||||
|
Active design contracts now distinguish the serialized request, the
|
||||||
|
wire-safe non-owning ref, and the live capability. Ruff passed, 518
|
||||||
|
tests collected, and 53 focused bindspace/WireGuard/listener tests
|
||||||
|
passed across the final two-commit tree.
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-addr-unpacking
|
||||||
|
timestamp: 2026-08-27T23:25:00Z
|
||||||
|
git_ref: d067505a
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260827T232500Z_d067505a_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing the reviewed bindspace-reference changes, the human
|
||||||
|
asked to continue with the next Layer C increment.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added the private Linux network-namespace entry primitive required by
|
||||||
|
future child bootstrap wiring. It verifies the inherited capability
|
||||||
|
before `setns()`, constrains entry to network namespaces, and verifies
|
||||||
|
the resulting process namespace before runtime initialization proceeds.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/spawn/_netns.py` - synchronous verified netns entry.
|
||||||
|
- `tests/test_netns_spawn.py` - stale-FD, syscall and post-entry guards.
|
||||||
|
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - primitive ownership and
|
||||||
|
remaining integration boundary.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human requested the next incremental implementation after accepting
|
||||||
|
the prior split commits. Their established preference for end-to-end
|
||||||
|
tests informed use of real FDs where possible; only privileged `setns()`
|
||||||
|
and procfs observation are replaced to keep the suite unprivileged. In
|
||||||
|
staged review, the human requested the full project license header,
|
||||||
|
explicit comments on both intentional inode mismatches, and independent
|
||||||
|
call traces proving both fake syscall and procfs hooks execute. The
|
||||||
|
agent applied those human-directed revisions; no direct manual source
|
||||||
|
edits were observed.
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-27T23:25:00Z
|
||||||
|
git_ref: d067505a
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - verify pre-runtime netns entry
|
||||||
|
|
||||||
|
After committing realized bindspace references and their deferred codec
|
||||||
|
plan, the human requested the next Layer C increment.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_netns.py tests/test_netns_spawn.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||||
|
|
||||||
|
Added a private synchronous `enter_netns()` bootstrap primitive. It
|
||||||
|
validates the inherited FD and expected inode, constrains `setns()` to
|
||||||
|
`CLONE_NEWNET`, verifies `/proc/self/ns/net` afterward, and leaves FD
|
||||||
|
closure to the future spawn-bootstrap caller.
|
||||||
|
|
||||||
|
Tests use real stand-in FDs while replacing only the privileged syscall
|
||||||
|
and post-entry procfs observation. They prove stale FDs fail before
|
||||||
|
entry, the syscall receives the exact namespace type, and bootstrap
|
||||||
|
rejects an unexpected post-entry namespace. Ruff and all three focused
|
||||||
|
tests passed.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: wg-netns-bootstrap-entry
|
||||||
|
timestamp: 2026-08-28T01:22:05Z
|
||||||
|
git_ref: 9ae7cd86
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260828T012205Z_9ae7cd86_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After completing and auditing the stacked rebase, the human approved
|
||||||
|
the next WireGuard Layer C slice: integrate the verified network
|
||||||
|
namespace entry primitive into child spawn bootstrap.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Wire an optional child-owned namespace FD and expected inode into both
|
||||||
|
child entrypoints. Keep namespace entry and FD closure synchronous and
|
||||||
|
ordered before actor runtime initialization, while deliberately
|
||||||
|
deferring backend FD transport and public API plumbing.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/spawn/_entry.py` - enter and release an optional inherited
|
||||||
|
namespace capability before multiprocessing child runtime startup.
|
||||||
|
- `tractor/_child.py` - consume the capability at the start of the
|
||||||
|
Trio child bootloader, before patching or actor construction.
|
||||||
|
- `tests/test_netns_spawn.py` - prove entry ordering, failure isolation,
|
||||||
|
and exact FD ownership for both child entrypoints.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected spawn-time namespace integration as the next work
|
||||||
|
slice and authorized implementation. No direct manual source edits
|
||||||
|
were observed during generation.
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-28T01:22:05Z
|
||||||
|
git_ref: 9ae7cd86
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - enter inherited netns during child bootstrap
|
||||||
|
|
||||||
|
The human asked to continue the WireGuard Layer C implementation after
|
||||||
|
the rebased branch landed the verified `spawn._netns.enter_netns()`
|
||||||
|
primitive.
|
||||||
|
|
||||||
|
## Generated code
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/_child.py tractor/spawn/_entry.py tests/test_netns_spawn.py`
|
||||||
|
|
||||||
|
Add one optional atomic `(namespace_fd, expected_inode)` bootstrap
|
||||||
|
capability to the multiprocessing entrypoint and Trio child bootloader.
|
||||||
|
Enter and verify the namespace before Trio patching, actor construction,
|
||||||
|
runtime state, logging, Trio startup, parent connection, sockets, or
|
||||||
|
worker threads, then close the child-owned inherited FD before
|
||||||
|
continuing.
|
||||||
|
|
||||||
|
Add privilege-free direct-entrypoint tests using real stand-in FDs and
|
||||||
|
a fake namespace syscall boundary. Cover helper-level no-op behavior,
|
||||||
|
successful entry ordering, exact FD closure, malformed-FD isolation,
|
||||||
|
primary-error preservation, and failure cleanup for both child
|
||||||
|
bootstrap paths.
|
||||||
|
|
||||||
|
## Scope boundary
|
||||||
|
|
||||||
|
This increment does not transfer FDs through spawn backends or expose a
|
||||||
|
public actor API. Backend-specific FD duplication, bootstrap failure
|
||||||
|
reporting, and root-process namespace entry remain follow-up work.
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: trio-pre-handshake-child-death
|
||||||
|
timestamp: 2026-08-28T04:51:19Z
|
||||||
|
git_ref: fb6d81d3
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260828T045119Z_fb6d81d3_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human approved the next WireGuard Layer C implementation step after
|
||||||
|
committing child-side network namespace bootstrap.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Restore and harden the boot-race design originally implemented by
|
||||||
|
`3b0724eba85b4014170ed95773e1e41a60d5c513` before transferring
|
||||||
|
namespace FDs. Race the initial peer handshake against child-process
|
||||||
|
death so a pre-connection bootstrap failure surfaces instead of
|
||||||
|
parking `ActorNursery.start_actor()` forever.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/spawn/_spawn.py` - provide the generic handshake/process
|
||||||
|
death race.
|
||||||
|
- `tractor/spawn/_trio.py` - use the race during Trio child startup.
|
||||||
|
- `tests/test_spawning.py` - cover handshake-first and death-first
|
||||||
|
schedules, simultaneous outcomes, waiter errors, and backend cleanup.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human approved continued Layer C implementation. The agent narrowed
|
||||||
|
the next slice to failure detection after analysis showed FD transfer
|
||||||
|
would otherwise introduce a parent hang. The human then identified the
|
||||||
|
earlier `wait_for_peer_or_proc_death()` implementation and required
|
||||||
|
explicit credit; the source docstring and commit message now cite its
|
||||||
|
original commit rather than a later cherry-pick. The human directly
|
||||||
|
reflowed `_trio.py` to the project line limit and normalized nearby
|
||||||
|
quotes. The human also required naming the race helper in its lifecycle
|
||||||
|
comment, explicit `trio.Cancelled` handling in waiter tests, expected
|
||||||
|
cancellation diagnostics, and a `trio.Process`-constrained test double.
|
||||||
|
After mainline portability verification, the human chose to commit the
|
||||||
|
fix on the current WireGuard branch and require its cherry-pick onto
|
||||||
|
`main` as a blocker in the eventual WireGuard pull request.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-28T04:51:19Z
|
||||||
|
git_ref: fb6d81d3
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - detect child death before parent handshake
|
||||||
|
|
||||||
|
The human asked to continue WireGuard Layer C after child entrypoints
|
||||||
|
learned to consume inherited network namespace capabilities.
|
||||||
|
|
||||||
|
## Generated code
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_spawn.py tractor/spawn/_trio.py tests/test_spawning.py`
|
||||||
|
|
||||||
|
Race a spawned child's initial parent handshake against its process
|
||||||
|
wait operation. Return the established channel when the handshake wins;
|
||||||
|
raise `ActorFailure` with the exit status when the process dies first.
|
||||||
|
Use the race in the Trio exec backend instead of waiting indefinitely
|
||||||
|
on `IPCServer.wait_for_peer()`.
|
||||||
|
|
||||||
|
Add deterministic Trio tests for handshake-first, death-first,
|
||||||
|
simultaneous, and waiter-error schedules, controlling readiness with
|
||||||
|
events rather than sleeps. Exercise the full Trio backend with a fake
|
||||||
|
dead process to prove pre-publication event registration and exact
|
||||||
|
failed-startup cleanup.
|
||||||
|
|
||||||
|
## Scope boundary
|
||||||
|
|
||||||
|
This prerequisite prevents pre-connection namespace-entry failures
|
||||||
|
from hanging their parent. Namespace FD transfer itself remains the
|
||||||
|
next commit.
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: trio-bindspace-fd-transport
|
||||||
|
timestamp: 2026-08-28T17:29:43Z
|
||||||
|
git_ref: 2ca8c570
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260828T172943Z_2ca8c570_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After committing generic pre-handshake child-death detection, the human
|
||||||
|
approved the proposed next Layer C slice: transport a live bindspace
|
||||||
|
capability through the Trio spawn backend.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Plumb an optional process-local `Bindspace` through actor spawn APIs.
|
||||||
|
Give a Trio exec child its own inherited namespace descriptor, preserve
|
||||||
|
existing caller `pass_fds`, transport the FD/inode pair through the CLI,
|
||||||
|
and close the temporary parent descriptor on every open-process outcome.
|
||||||
|
Reject unsupported multiprocessing transport explicitly. Exercise the
|
||||||
|
complete Trio path with a real actor that enters a distinct network
|
||||||
|
namespace and reports its namespace and inherited-FD inodes over RPC.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_supervise.py` - accept and relay a child bindspace.
|
||||||
|
- `tractor/to_actor/_api.py` - expose bindspace spawn configuration.
|
||||||
|
- `tractor/spawn/_spawn.py` - relay the bindspace to spawn backends.
|
||||||
|
- `tractor/spawn/_trio.py` - duplicate, transfer, and close the FD.
|
||||||
|
- `tractor/spawn/_mp.py` - reject unsupported MP transport.
|
||||||
|
- `tractor/_child.py` - parse and forward the atomic CLI capability.
|
||||||
|
- `tests/test_netns_spawn.py` - cover transport and cleanup schedules.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human approved the Trio-first transport boundary after reviewing
|
||||||
|
the proposed dependency order, then supplied staged-diff comments across
|
||||||
|
multiple review passes. Human-directed revisions replaced abstract
|
||||||
|
ownership wording with concrete FD and process terminology, clarified
|
||||||
|
descriptor cleanup, preserved existing `pass_fds` explicitly, and
|
||||||
|
replaced the mocked success-path test with a real child actor, real
|
||||||
|
`setns()`, handshake, and RPC round trip inside unprivileged user/network
|
||||||
|
namespaces. Follow-up review added direct parent FD-table verification
|
||||||
|
that the temporary child-bound duplicate is closed after successful
|
||||||
|
spawn.
|
||||||
|
|
||||||
|
Multiprocessing FD reduction and root-process namespace entry remain
|
||||||
|
separate follow-up slices. No direct manual source edits were observed;
|
||||||
|
the review-driven source revisions were applied by the agent.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-28T17:29:43Z
|
||||||
|
git_ref: 2ca8c570
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - transport bindspace FD through Trio spawn
|
||||||
|
|
||||||
|
The human approved the next WireGuard Layer C increment after landing
|
||||||
|
pre-handshake child-death detection.
|
||||||
|
|
||||||
|
## Generated code
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/_child.py tractor/runtime/_supervise.py tractor/spawn/_spawn.py tractor/spawn/_trio.py tractor/spawn/_mp.py tractor/to_actor/_api.py tests/test_netns_spawn.py`
|
||||||
|
|
||||||
|
Thread an optional live `Bindspace` through actor spawn APIs. For the
|
||||||
|
Trio backend, duplicate its namespace FD, append that temporary
|
||||||
|
descriptor to existing `pass_fds`, and send the atomic descriptor/inode
|
||||||
|
pair through the child CLI. Close the parent temporary duplicate as
|
||||||
|
soon as `open_process()` returns, raises, or is cancelled, while the
|
||||||
|
child consumes its inherited descriptor before runtime startup.
|
||||||
|
|
||||||
|
Reject bindspace transport explicitly in multiprocessing backends until
|
||||||
|
their descriptor-reduction slice is implemented. Add privilege-free
|
||||||
|
tests for CLI forwarding, merged pass-FD state, caller immutability,
|
||||||
|
source/duplicate ownership, exec failure, cancellation, absent live
|
||||||
|
authority, death-before-handshake cleanup, and MP rejection.
|
||||||
|
|
||||||
|
## Scope boundary
|
||||||
|
|
||||||
|
Multiprocessing FD reduction and root-process namespace entry remain
|
||||||
|
follow-up work.
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: tractor-net-api-extraction
|
||||||
|
timestamp: 2026-08-30T02:52:01Z
|
||||||
|
git_ref: b1f6ade8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260830T025201Z_b1f6ade8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
After rebasing the WireGuard Layer C stack, the human questioned the
|
||||||
|
placement of bindspace APIs under `tractor.discovery`, considered new
|
||||||
|
public package names, selected `tractor.net`, and authorized the
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Extracted bindspace and tunnel implementations into a lazy
|
||||||
|
`tractor.net` package. Moved public multiaddr, bindspace, tunnel, and
|
||||||
|
WireGuard symbols out of `tractor.discovery`, updated internal imports,
|
||||||
|
tests, examples, and documentation, and added cold-import regressions
|
||||||
|
that keep optional networking dependencies off the root import path.
|
||||||
|
Current-netns attachment pins `/proc/thread-self/ns/net` so calls from
|
||||||
|
non-leader threads retain the caller's namespace rather than the process
|
||||||
|
leader's.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/net/` - lazy public network API and implementation modules.
|
||||||
|
- `tractor/discovery/` - retain actor discovery and internal address parsing.
|
||||||
|
- `tractor/__init__.py` - expose `tractor.net` lazily.
|
||||||
|
- `tests/net/` - network declaration and lifecycle coverage.
|
||||||
|
- `tests/test_lazy_imports.py` - enforce the lazy package boundary.
|
||||||
|
- `examples/multihost/wg_lan/` - use the public network package.
|
||||||
|
- `docs/` - document network APIs separately from actor discovery.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human rejected `tractor.discovery` as the long-term public home,
|
||||||
|
considered tunnel- and namespace-specific alternatives, and selected
|
||||||
|
the broader `tractor.net` boundary because bindspaces may include plain
|
||||||
|
netns, WireGuard, VRF, veth, and later network resources. The agent
|
||||||
|
applied the resulting source changes; no direct manual edits were
|
||||||
|
observed.
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-30T02:52:01Z
|
||||||
|
git_ref: b1f6ade8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - extract public network APIs
|
||||||
|
|
||||||
|
The human questioned whether bindspace and WireGuard lifecycle APIs
|
||||||
|
belonged under actor discovery, selected the proposed `tractor.net`
|
||||||
|
boundary, and authorized implementation.
|
||||||
|
|
||||||
|
## Generated code
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/net tractor/discovery tractor/__init__.py`
|
||||||
|
|
||||||
|
Move bindspace and tunnel implementations into a lazy public network
|
||||||
|
package. Keep actor discovery focused on registry and lookup behavior,
|
||||||
|
while exposing bindspace, tunnel, WireGuard, and multiaddr declarations
|
||||||
|
through `tractor.net` without loading optional networking dependencies
|
||||||
|
during `import tractor`.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/net tests/test_lazy_imports.py tests/ipc examples/multihost/wg_lan docs`
|
||||||
|
|
||||||
|
Move network-focused tests to `tests/net`, update internal and public
|
||||||
|
imports, verify lazy symbol resolution and removed discovery exports,
|
||||||
|
and document the new package boundary.
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: root-bindspace-bootstrap
|
||||||
|
timestamp: 2026-08-30T02:52:02Z
|
||||||
|
git_ref: b1f6ade8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260830T025202Z_b1f6ade8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
The human chose to defer multiprocessing bindspace FD transport because
|
||||||
|
that backend may be removed, selected root/single-actor namespace
|
||||||
|
bootstrap as the next stage, approved caller-thread restoration and
|
||||||
|
public `tractor.net.open_wg_bindspace()` composition, and authorized
|
||||||
|
implementation after the stack rebase.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Added `bindspace=` to `tractor.open_root_actor()`. Root startup now
|
||||||
|
duplicates and validates the live namespace FD, enters before any
|
||||||
|
debugger, registry, IPC, or actor-runtime work, and synchronously
|
||||||
|
restores the caller thread's original namespace after complete root
|
||||||
|
teardown. Added deterministic failure/cancellation coverage, a real UDS
|
||||||
|
root actor E2E across two network namespaces, and an executable public
|
||||||
|
WireGuard-bindspace/root composition regression. Bound roots reject the
|
||||||
|
persistent `mp_forkserver` backend because a helper from an earlier
|
||||||
|
runtime may retain a stale namespace; the public annotation also remains
|
||||||
|
runtime-resolvable without importing `tractor.net` eagerly.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/_root.py` - public root bindspace lifecycle integration.
|
||||||
|
- `tractor/spawn/_netns.py` - temporary thread-local netns enter/restore.
|
||||||
|
- `tests/test_netns_spawn.py` - deterministic and real root regressions.
|
||||||
|
- `tests/net/test_wg_iface_lifecycle.py` - public root composition test.
|
||||||
|
- `docs/api/net.rst` - root ownership and restoration contract.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human explicitly deferred multiprocessing support, selected root
|
||||||
|
bootstrap as the next implementation stage, approved the requirement to
|
||||||
|
restore the caller's original namespace so owned teardown can complete,
|
||||||
|
and approved composition through public `tractor.net` contexts rather
|
||||||
|
than a new one-shot convenience API. The human later rewrote
|
||||||
|
`_enter_netns_temporarily()` to separate restoration from FD cleanup and
|
||||||
|
make the transition flow easier to inspect. The agent retained that
|
||||||
|
intent with nested lexical FD ownership while fixing setup-error masking,
|
||||||
|
conditional restoration, and duplicate-FD cleanup found during the
|
||||||
|
requested audit.
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-30T02:52:02Z
|
||||||
|
git_ref: b1f6ade8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
# Raw output - bootstrap the root actor in a bindspace
|
||||||
|
|
||||||
|
The human deferred multiprocessing descriptor transport, selected root
|
||||||
|
namespace bootstrap as the next Layer C stage, approved enter/restore
|
||||||
|
semantics and public `tractor.net` composition, and authorized
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
## Generated code
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/_root.py tractor/spawn/_netns.py`
|
||||||
|
|
||||||
|
Add an optional realized `Bindspace` to `open_root_actor()`. Duplicate
|
||||||
|
its namespace FD, enter before debugger, registry, IPC, or runtime
|
||||||
|
startup, and restore the caller thread's original namespace after root
|
||||||
|
teardown. Preserve caller FD ownership and primary errors across
|
||||||
|
restoration and close failures.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/test_netns_spawn.py tests/net/test_wg_iface_lifecycle.py docs/api/net.rst`
|
||||||
|
|
||||||
|
Exercise same-netns behavior, body errors, cancellation, restoration
|
||||||
|
failures, missing and closed FDs, real root entry/restoration under an
|
||||||
|
unprivileged user namespace, and public WireGuard-bindspace/root
|
||||||
|
composition.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -47,7 +47,7 @@ Here is a small example from piker,
|
||||||
We should take whatever common API is needed to support this and
|
We should take whatever common API is needed to support this and
|
||||||
distill it into a
|
distill it into a
|
||||||
```python
|
```python
|
||||||
tractor.discovery.parse_endpoints(
|
tractor.net.parse_endpoints(
|
||||||
) -> dict[
|
) -> dict[
|
||||||
str,
|
str,
|
||||||
list[Address]
|
list[Address]
|
||||||
|
|
|
||||||
|
|
@ -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,21 @@ 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` request and host-local `BindspaceRef`. A scoped,
|
||||||
|
non-serializable `Bindspace` carries that ref and owns the FD/lifetime
|
||||||
|
used during 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 +194,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 +257,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 +289,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 +301,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 +386,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 +403,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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -19,35 +19,37 @@ 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)`, explicitly verify the declared pubkey against the
|
||||||
hand the overlay addr to `registry_addrs=`/`tpt_bind_addrs=`.
|
local interface key or configured peers with async
|
||||||
#482 already contains working example code for exactly this.
|
`verify_wg_peer()`, then hand the overlay addr to
|
||||||
|
`registry_addrs=`/`tpt_bind_addrs=`.
|
||||||
|
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 +75,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 +102,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 +121,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 +135,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.net.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 +147,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 +165,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 +181,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 +200,27 @@ 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 and async production verifier live in
|
||||||
*pure functions* + one impure probe, cleanly separated:
|
`tractor/net/_tunnel.py`; parsing remains pure while verification
|
||||||
|
is an explicit, impure caller step:
|
||||||
|
|
||||||
```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_peer(spec: WGTunnelSpec) -> bool: ... # layer B
|
||||||
```
|
```
|
||||||
|
|
||||||
In layer A `verify_wg_peer()` may shell out (`wg show <if>
|
Layer A's example-local `verify_wg_key()` used role-specific
|
||||||
peers`), but it must be a *single* function so layer B swaps
|
`wg show <if> public-key|peers` queries. Layer B replaces it with
|
||||||
only its body. Never call it implicitly from
|
`verify_wg_peer()`, backed by one pyroute2 key snapshot selected by
|
||||||
|
`spec.iface` and `spec.netns`. It validates the declared key before
|
||||||
|
I/O and accepts either the interface's own public key or a configured
|
||||||
|
peer key. It does not enforce a host-specific role and verifies key
|
||||||
|
presence only, not `Endpoint`, `AllowedIPs`, handshake state, or
|
||||||
|
routing. 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).
|
||||||
|
|
@ -243,7 +243,7 @@ side-effect-free; verification is the *caller's* explicit step
|
||||||
(`proto_key`/`unwrap` identical to overlay), `wrap_address()`
|
(`proto_key`/`unwrap` identical to overlay), `wrap_address()`
|
||||||
regression (a tunnelled maddr `str` → `TunnelledAddress`; a
|
regression (a tunnelled maddr `str` → `TunnelledAddress`; a
|
||||||
plain one → unchanged), and **a real end-to-end over a
|
plain one → unchanged), and **a real end-to-end over a
|
||||||
locally-created wg pair** gated on `CAP_NET_ADMIN` (see §5.3).
|
locally-created wg pair** gated on `CAP_NET_ADMIN` (see §5.4).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -281,17 +281,20 @@ 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 replacement of the example-local verification probe with
|
||||||
body; then land (2) as a follow-up commit for the read path
|
production `verify_wg_peer()`; then land (2) as a follow-up commit for
|
||||||
(`wg get`, `link get`) where the sans-io surface is smallest,
|
the read path (`wg get`, `link get`) where the sans-io surface is
|
||||||
and keep (1) for the privileged mutating ops. Measure before
|
smallest, and keep (1) for the privileged mutating ops. Measure before
|
||||||
converting anything else — there is no perf argument here, only
|
converting anything else — there is no perf argument here, only a "no
|
||||||
a "no foreign event loop in a trio actor" argument, which (1)
|
foreign event loop in a trio actor" argument, which (1) already
|
||||||
already satisfies (a thread is not an event loop).
|
satisfies (a thread is not an event loop).
|
||||||
|
|
||||||
Explicitly **do not** pull in `trio-asyncio` for pyroute2: it
|
Explicitly **do not** pull in `trio-asyncio` for pyroute2 or infect
|
||||||
would be the one place in the runtime where an asyncio loop
|
every wg-using actor merely to service one-shot netlink calls. A
|
||||||
exists for no reason.
|
dedicated `wgman` actor (§5.1) is the one plausible asyncio-hosted
|
||||||
|
shape: it can use tractor's own `.to_asyncio` task linkage while
|
||||||
|
keeping the foreign loop and provisioning authority out of ordinary
|
||||||
|
actor processes.
|
||||||
|
|
||||||
### 4.2 API shape
|
### 4.2 API shape
|
||||||
|
|
||||||
|
|
@ -306,7 +309,8 @@ 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_peer()` becomes a thin composition over one shared key
|
||||||
|
snapshot.
|
||||||
Note the pure-getter rule: no `read_wg_peers(..., create=True)`.
|
Note the pure-getter rule: no `read_wg_peers(..., create=True)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -319,57 +323,268 @@ 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"*.
|
||||||
|
|
||||||
### 5.1 the composition
|
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 candidate default: first-child `wgman`
|
||||||
|
|
||||||
|
For a WG-enabled deployment profile, consider eagerly spawning one
|
||||||
|
private **WireGuard manager** (`wgman`) as the root actor's logical
|
||||||
|
first child. It is a narrow network-control-plane service, not a
|
||||||
|
general worker and not an application-visible transport endpoint.
|
||||||
|
The naive profile gets one manager for the actor tree; advanced
|
||||||
|
deployments may disable it for pre-provisioned networking or place
|
||||||
|
one manager in each capability/bindspace security domain.
|
||||||
|
|
||||||
|
"First child" describes supervision and teardown ordering, not a
|
||||||
|
serial startup barrier. Submit the `wgman` spawn in the same startup
|
||||||
|
wave as ordinary children, start its pyroute2 import, generic-netlink
|
||||||
|
discovery and declared-tunnel reconciliation immediately, and publish
|
||||||
|
a readiness signal separately. Sibling processes can boot in parallel;
|
||||||
|
only their first WG-dependent bind/dial waits for manager readiness.
|
||||||
|
This overlaps setup with actor-tree startup and avoids every sibling
|
||||||
|
paying its own pyroute2/loop/socket initialization latency.
|
||||||
|
|
||||||
|
The initial manager can still call the sync helpers from §4.1. A
|
||||||
|
natural follow-up is to spawn it with `infect_asyncio=True` and keep
|
||||||
|
`AsyncWireGuard` clients alive on asyncio's host loop through
|
||||||
|
`tractor.to_asyncio.run_task()`. Tractor then owns cross-loop task
|
||||||
|
linkage, cancellation and error propagation, while normal siblings
|
||||||
|
remain plain Trio actors. Keep one client per realized namespace or
|
||||||
|
other kernel control domain; do not share a pyroute2 socket across
|
||||||
|
domains merely to reduce object count.
|
||||||
|
|
||||||
|
Keep the authority surface deliberately small:
|
||||||
|
|
||||||
|
- accept structured inspect/verify/ensure/release requests derived
|
||||||
|
from `WGTunnelSpec`, `BindspaceSpec` and explicit `role`; never
|
||||||
|
expose arbitrary pyroute2 calls, shell commands or `setns()` RPC;
|
||||||
|
- let the root/supervisor mediate access initially, or hand siblings
|
||||||
|
a scoped manager capability; do not register a privileged `wgman`
|
||||||
|
endpoint for unrestricted cluster-wide discovery;
|
||||||
|
- never return private keys or namespace FDs to application actors;
|
||||||
|
pass secrets and live capabilities into the manager through the
|
||||||
|
supervisor-owned bootstrap path;
|
||||||
|
- grant only the capabilities required for the manager's assigned
|
||||||
|
domain. Prefer a manager already placed in that user/net namespace
|
||||||
|
over one process holding ambient authority across every namespace;
|
||||||
|
- make ensure/release idempotent and reference-count ownership so one
|
||||||
|
sibling cannot tear down a tunnel still borrowed by another.
|
||||||
|
|
||||||
|
The root owns the manager's lifetime. `wgman` must outlive all
|
||||||
|
siblings borrowing its tunnels and exit before the root drops the
|
||||||
|
underlying namespace capabilities. A manager crash fails closed:
|
||||||
|
dependent operations receive an explicit service error; restart, if
|
||||||
|
enabled, reconciles declared state idempotently before advertising
|
||||||
|
readiness again. Do not silently let siblings fall back to privileged
|
||||||
|
local provisioning, since that defeats both the security boundary and
|
||||||
|
the single warm control-plane benefit.
|
||||||
|
|
||||||
|
Treat eager `wgman` as a measured deployment-profile choice. Compare
|
||||||
|
root startup with no WG declarations, pre-provisioned read-only WG,
|
||||||
|
and runtime-managed tunnels before making it unconditional whenever
|
||||||
|
the `wg` extra is installed. The intended invariant is "one warm
|
||||||
|
manager per simple WG actor tree", not "every tractor program spawns
|
||||||
|
a privileged child".
|
||||||
|
|
||||||
|
### 5.2 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 three bindspace representations with deliberately different roles
|
||||||
|
and 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
|
||||||
|
lifecycle: Literal['attach', 'open']
|
||||||
|
|
||||||
|
|
||||||
|
class BindspaceRef(msgspec.Struct, frozen=True):
|
||||||
|
'''Wire-safe, non-owning ref to the realized resource.'''
|
||||||
|
kind: str
|
||||||
|
key: str|None # mutable name, absent after unlink
|
||||||
|
inode: int # host-local Linux nsfs fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
class Bindspace(ProcessLocal):
|
||||||
|
'''Scoped, non-serializable capability for one live bindspace.'''
|
||||||
|
spec: BindspaceSpec
|
||||||
|
ref: BindspaceRef
|
||||||
|
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]:
|
) -> AsyncGenerator[Bindspace, 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 initial model limits `BindspaceKind` to `netns` while preserving
|
||||||
|
the required role split. `BindspaceSpec` is the requested resource and
|
||||||
|
lifecycle policy. `BindspaceRef` is a serializable, non-owning,
|
||||||
|
host-local record of the resource that was actually opened; it can be
|
||||||
|
compared or logged, but cannot reopen, pin or enter that resource.
|
||||||
|
`Bindspace` is the live capability and uses msgspec's generic struct
|
||||||
|
storage by inheriting the global `tractor.msg.ProcessLocal` marker. Its
|
||||||
|
hidden unsupported sentinel blocks direct and nested default msgspec
|
||||||
|
encoding without a recursive IPC hot-path scan. The live bindspace
|
||||||
|
validates any supplied FD against `BindspaceRef.inode`; explicit FD
|
||||||
|
transfer belongs to the supervisor bootstrap path. An FD avoids
|
||||||
|
name-resolution TOCTOU,
|
||||||
|
survives rename/unlink, and identifies the exact namespace the parent
|
||||||
|
provisioned. Extend the kind/field union only when a second platform
|
||||||
|
resource is implemented.
|
||||||
|
|
||||||
|
`BindspaceSpec.lifecycle` is explicit serialized policy:
|
||||||
|
`'attach'` borrows an existing resource and `'open'` creates/owns one.
|
||||||
|
`open_bindspace()` dispatches that policy by bindspace kind. Never
|
||||||
|
infer it from a listen/dial role: either role may use pre-provisioned
|
||||||
|
or locally owned networking.
|
||||||
|
|
||||||
|
The first lifecycle implementation is deliberately borrow-only:
|
||||||
|
`attach_netns()` opens either `/proc/self/ns/net` when
|
||||||
|
`BindspaceSpec.key = CURRENT_NETNS`, or a named entry beneath
|
||||||
|
`/var/run/netns`. It derives a `BindspaceRef` from the opened FD, yields
|
||||||
|
`ownership='borrowed'`, and closes only that FD on exit. "Attach" does
|
||||||
|
not call `setns()`; it never creates, enters or removes a namespace.
|
||||||
|
Future `open_netns()` creation and owned teardown remain a separate
|
||||||
|
privileged supervisor change.
|
||||||
|
|
||||||
|
`open_netns()` is that owned counterpart: it requires a named spec,
|
||||||
|
creates through pyroute2 in a shielded worker call, attaches the live
|
||||||
|
FD, and yields `ownership='owned'`. FD closure precedes another
|
||||||
|
shielded pyroute2 removal call on every post-creation exit, including
|
||||||
|
cancellation. It still never calls `setns()`; process entry remains a
|
||||||
|
spawn/bootstrap operation.
|
||||||
|
|
||||||
|
`open_bindspace()` is **not** an address factory and does not return a
|
||||||
|
`TunnelledAddress`. At the declaration layer, listener allocation can
|
||||||
|
use the live bindspace to replace an overlay while preserving every
|
||||||
|
tunnel:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with open_bindspace(
|
||||||
|
bindspace_spec,
|
||||||
|
) 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.
|
||||||
|
At the listener boundary, keep the split explicit:
|
||||||
|
`Endpoint.addr` is the peeled concrete address used for transport
|
||||||
|
reflection, while `Endpoint.declared_addr` retains the original
|
||||||
|
wrapper for namespace diagnostics and later bindspace orchestration.
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> AsyncGenerator[Bindspace, None]: ...
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def open_wg_iface(spec: WGTunnelSpec) -> AsyncGenerator[WGTunnelSpec, None]: ...
|
async def open_wg_iface(
|
||||||
|
spec: WGTunnelSpec,
|
||||||
|
config: WGInterfaceConfig,
|
||||||
|
bindspace: Bindspace,
|
||||||
|
role: Literal['listen', 'dial'],
|
||||||
|
) -> AsyncGenerator[WGTunnelSpec, None]: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
and a driver that folds a list of specs into nested contexts
|
`WGInterfaceConfig` and each `WGPeerConfig` are process-local and
|
||||||
(`contextlib.AsyncExitStack` for the N-deep case). The
|
rejected by the global `ProcessLocal` wire guard. The interface config
|
||||||
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
|
owns its private key, local addresses and listen port; each peer owns
|
||||||
it already returns `dict[name, list[Address]]` and the
|
its public key, allowed CIDRs, optional endpoint, preshared key and
|
||||||
`multiaddr_declare_eps.md` sketch anticipates the recursive
|
keepalive. Reprs redact private/preshared keys. `WGTunnelSpec` remains
|
||||||
`dict[str, list[Address]]|dict[...]` return for tunnelled
|
serializable public maddr-derived identity/endpoint data. This split
|
||||||
entries. Extend it to carry the tunnel stack, not to *enter* it.
|
supports multi-peer listeners without overloading the tunnel maddr.
|
||||||
|
|
||||||
### 5.2 `Address.namespace`, at last
|
The initial `open_wg_iface()` lifecycle is owned and Linux-only. It
|
||||||
|
validates role-dependent bearer policy before side effects, creates the
|
||||||
|
iface and addresses through `IPRoute`, configures keys/peers through
|
||||||
|
`WireGuard`, raises the link, and removes it on every post-creation
|
||||||
|
exit. Creation/removal run in shielded Trio worker calls. A listen
|
||||||
|
bearer supplies the local listen port; a dial bearer supplies an
|
||||||
|
omitted endpoint only for the selected maddr peer.
|
||||||
|
|
||||||
- `TunnelledAddress.namespace` → `(kind, id)` e.g.
|
The composition driver folds a list of specs into nested contexts with
|
||||||
`('netns', 'tractor-wg0')`.
|
`contextlib.AsyncExitStack` for the N-deep case. The
|
||||||
- **and** the existing backends should implement it as `None`
|
`parse_endpoints()` API (`_multiaddr.py:189`) is the front door:
|
||||||
explicitly (they currently just don't define it), so the
|
its `ParsedEndpoints` values already contain
|
||||||
Protocol stops lying.
|
`Address|TunnelledAddress` declarations and preserve each tunnel
|
||||||
- consumers to audit: nothing reads `.namespace` today — so
|
stack for the eventual bindspace handler. It carries declarations;
|
||||||
adding it is safe, but the *point* is that
|
it does not *enter* their bindspaces.
|
||||||
`Endpoint`/`Server.pformat()` should start showing it (there's
|
|
||||||
already a `# !TODO, always be ns aware!` +
|
|
||||||
`f'|_netns: {netns}\n'` placeholder sitting in
|
|
||||||
`Endpoint.pformat()`, `_server.py:645`). Fill that in; it's
|
|
||||||
the cheapest possible proof the layer is wired.
|
|
||||||
|
|
||||||
### 5.3 the netns/process reality — read this before designing
|
`open_wg_bindspace()` is the initial driver for one bindspace and an
|
||||||
|
ordered sequence of `(WGTunnelSpec, WGInterfaceConfig)` layers. It
|
||||||
|
opens the bindspace first, enters WG interfaces outermost-first through
|
||||||
|
`AsyncExitStack`, and yields the live `Bindspace` for endpoint
|
||||||
|
allocation. Exit is inside-out, so every interface is removed while the
|
||||||
|
namespace FD remains pinned; only then can an owned namespace be
|
||||||
|
removed. Endpoint/channel lifetimes belong inside the yielded scope.
|
||||||
|
|
||||||
|
The caller supplies `role` to tunnel-resource contexts such as
|
||||||
|
`open_wg_iface()`; do not infer it from maddr shape. Bindspace
|
||||||
|
lifecycle remains the independent explicit policy above. The same
|
||||||
|
composed maddr can name a server source or client destination (§5.4).
|
||||||
|
|
||||||
|
### 5.3 `Address.namespace`, at last
|
||||||
|
|
||||||
|
- an unrealized `TunnelledAddress.namespace` reports its declared name
|
||||||
|
as `(kind, key)`, e.g. `('netns', 'tractor-wg0')`;
|
||||||
|
- `TunnelledAddress.with_bindspace_ref()` returns a frozen declaration
|
||||||
|
annotated with `bindspace.ref`, never the FD-bearing `Bindspace`.
|
||||||
|
Its `.namespace` reports `(kind, inode)` so the
|
||||||
|
realized ref remains stable across rename or unlink;
|
||||||
|
- existing plain backends implement it explicitly as `None`, so the
|
||||||
|
Protocol does not lie and tunnel delegation needs no `getattr()`
|
||||||
|
fallback.
|
||||||
|
- `Endpoint.namespace` reads the retained declaration rather than its
|
||||||
|
peeled transport addr; both `Endpoint.pformat()` and
|
||||||
|
`Server.pformat()` expose that value as the cheapest proof the layer
|
||||||
|
is wired.
|
||||||
|
|
||||||
|
Deferred follow-ups:
|
||||||
|
|
||||||
|
- add native tagged encoding for the complete `TunnelledAddress` graph,
|
||||||
|
including its concrete overlay-address union, tunnel-spec union and
|
||||||
|
optional `BindspaceRef`. Once that codec exists, tests should perform
|
||||||
|
typed roundtrips instead of inspecting an untyped decoded payload.
|
||||||
|
|
||||||
|
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)` reference
|
||||||
|
record. Layer C should move that shape into `BindspaceRef`, avoid a
|
||||||
|
subprocess where netlink/procfs suffices, and hold the namespace FD in
|
||||||
|
`Bindspace` to pin the referenced resource.
|
||||||
|
|
||||||
|
### 5.4 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
|
||||||
**runtime-level config API, not an actor-app-code API.** It is
|
**runtime-level config API, not an actor-app-code API.** It is
|
||||||
|
|
@ -396,44 +611,92 @@ 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
|
- `spawn._netns.enter_netns()` is the first private bootstrap
|
||||||
genuinely scoped).
|
primitive: it checks the inherited FD against the expected inode,
|
||||||
|
calls `setns(fd, CLONE_NEWNET)`, and verifies
|
||||||
|
`/proc/self/ns/net` before returning. It deliberately does not own
|
||||||
|
or close the FD; spawn propagation and status reporting remain the
|
||||||
|
caller's next integration boundary.
|
||||||
|
- only after successful entry does the child drop namespace-entry
|
||||||
|
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 `Bindspace` 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 capability;
|
||||||
|
- 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`
|
||||||
tolerance and `_serve_ipc_eps()`'s per-ep `try/except`
|
tolerance and `_serve_ipc_eps()`'s per-ep `try/except`
|
||||||
encode. Mirror both.
|
encode. Mirror both.
|
||||||
|
|
||||||
### 5.4 tests for layer C
|
### 5.5 tests for layer C
|
||||||
|
|
||||||
- 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
|
||||||
- the `to_thread`-netns-mismatch regression from §5.3, written
|
test body.
|
||||||
|
- the `to_thread`-netns-mismatch regression from §5.4, 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.
|
||||||
|
- `wgman` bootstrap: prove sibling process startup overlaps manager
|
||||||
|
reconciliation while the first WG operation still waits for its
|
||||||
|
readiness signal.
|
||||||
|
- `wgman` authority: reject arbitrary callers/operations and prove an
|
||||||
|
unprivileged sibling cannot receive secrets, FDs or provisioning
|
||||||
|
authority through the manager API.
|
||||||
|
- `wgman` lifetime: prove it outlives tunnel borrowers, fails pending
|
||||||
|
requests explicitly on crash and reconciles before restart-ready.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -448,7 +711,7 @@ machinery covers any iface-layer tunnel `pyroute2` can drive —
|
||||||
`kind: ClassVar[str]`, and dispatch `open_*` by `match` on it.
|
`kind: ClassVar[str]`, and dispatch `open_*` by `match` on it.
|
||||||
Design for it now (union + `match`), implement only `wg` +
|
Design for it now (union + `match`), implement only `wg` +
|
||||||
`netns`. `veth`-pairs-in-netns is the natural second one because
|
`netns`. `veth`-pairs-in-netns is the natural second one because
|
||||||
it makes the §5.4 integration test possible without wg at all —
|
it makes the §5.5 integration test possible without wg at all —
|
||||||
consider doing it *first* for exactly that reason.
|
consider doing it *first* for exactly that reason.
|
||||||
|
|
||||||
## 7. Non-goals
|
## 7. Non-goals
|
||||||
|
|
@ -466,11 +729,17 @@ 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.4; 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 `Bindspace`; borrowed bindspaces 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 every actor | use a worker for one-shots; confine persistent asyncio to an infected `wgman` (§4.1, §5.1) |
|
||||||
|
| eager `wgman` serializes or slows root bootstrap | spawn it in parallel; gate only WG-dependent operations on readiness; measure before making the profile unconditional |
|
||||||
|
| `wgman` becomes a cluster-wide privilege oracle | keep it private/scoped, expose structured verbs only and split managers by capability domain |
|
||||||
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |
|
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |
|
||||||
|
|
||||||
## 9. Follow-up issue seeds
|
## 9. Follow-up issue seeds
|
||||||
|
|
@ -482,6 +751,7 @@ consider doing it *first* for exactly that reason.
|
||||||
- `wg` proto into the multiaddr **spec** (gh #483), then flip
|
- `wg` proto into the multiaddr **spec** (gh #483), then flip
|
||||||
`MsgTransport.maddr` to always return `Multiaddr` (the third
|
`MsgTransport.maddr` to always return `Multiaddr` (the third
|
||||||
#443 bullet)
|
#443 bullet)
|
||||||
- runtime-managed wg key rotation / peer add-remove as a
|
- first-child `wgman` prototype: concurrent bootstrap, scoped sibling
|
||||||
`tractor` service actor — the natural "actor that owns the
|
access, infected-asyncio pyroute2 ownership and restart reconciliation
|
||||||
network" demo
|
- runtime-managed wg key rotation / peer add-remove through `wgman` —
|
||||||
|
the natural "actor that owns the network" demo
|
||||||
|
|
|
||||||
|
|
@ -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 —
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ Most-used names at a glance:
|
||||||
core
|
core
|
||||||
context
|
context
|
||||||
discovery
|
discovery
|
||||||
|
net
|
||||||
errors
|
errors
|
||||||
msg
|
msg
|
||||||
trionics
|
trionics
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ already distributed-system aware.
|
||||||
.. seealso::
|
.. seealso::
|
||||||
|
|
||||||
:doc:`/explain/architecture` for the transport/server
|
:doc:`/explain/architecture` for the transport/server
|
||||||
internals, :doc:`/api/discovery` for how channel addresses
|
internals, :doc:`/api/net` for network declarations,
|
||||||
get registered and found, and :doc:`/api/msg` for the codec
|
:doc:`/api/discovery` for how channel addresses get registered
|
||||||
layer every channel speaks.
|
and found, and :doc:`/api/msg` for the codec layer every channel
|
||||||
|
speaks.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
Network declarations and lifecycles
|
||||||
|
===================================
|
||||||
|
|
||||||
|
``tractor.net`` provides address composition, bindspace declarations,
|
||||||
|
and tunnel configuration. The package is lazy: importing
|
||||||
|
``tractor`` or ``tractor.net`` does not load multiaddr, WireGuard, or
|
||||||
|
pyroute2 implementation modules until a public symbol is used.
|
||||||
|
|
||||||
|
Multiaddr helpers
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
.. currentmodule:: tractor.net
|
||||||
|
|
||||||
|
.. autofunction:: mk_maddr
|
||||||
|
|
||||||
|
.. autofunction:: parse_maddr
|
||||||
|
|
||||||
|
.. autofunction:: parse_endpoints
|
||||||
|
|
||||||
|
Bindspaces
|
||||||
|
----------
|
||||||
|
|
||||||
|
.. autoclass:: BindspaceSpec
|
||||||
|
|
||||||
|
.. autoclass:: BindspaceRef
|
||||||
|
|
||||||
|
.. autoclass:: Bindspace
|
||||||
|
|
||||||
|
.. autofunction:: attach_netns
|
||||||
|
|
||||||
|
.. autofunction:: open_netns
|
||||||
|
|
||||||
|
.. autofunction:: open_bindspace
|
||||||
|
|
||||||
|
Root actor composition
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
A live :class:`Bindspace` can scope the root actor itself. Compose the
|
||||||
|
bindspace manager outside :func:`tractor.open_root_actor` so its network
|
||||||
|
namespace remains pinned for the complete actor runtime::
|
||||||
|
|
||||||
|
async with tractor.net.open_wg_bindspace(
|
||||||
|
bindspace_spec=bindspace_spec,
|
||||||
|
layers=layers,
|
||||||
|
role='listen',
|
||||||
|
) as bindspace:
|
||||||
|
async with tractor.open_root_actor(
|
||||||
|
bindspace=bindspace,
|
||||||
|
enable_transports=['uds'],
|
||||||
|
) as root_actor:
|
||||||
|
...
|
||||||
|
|
||||||
|
Root entry happens before registry probes, IPC listeners, runtime sockets,
|
||||||
|
or actor startup. On every exit, including cancellation or a body error,
|
||||||
|
the calling thread is restored to its original network namespace before
|
||||||
|
``open_root_actor()`` returns. The root context duplicates the live
|
||||||
|
``Bindspace.namespace_fd`` and never consumes or closes the descriptor
|
||||||
|
owned by ``open_wg_bindspace()``. Default child processes inherit the root
|
||||||
|
namespace naturally; passing an explicit alternate child ``bindspace``
|
||||||
|
continues to use that spawn backend's existing behavior. Bound roots reject
|
||||||
|
the persistent ``mp_forkserver`` backend because a server started by an
|
||||||
|
earlier runtime may retain that runtime's network namespace.
|
||||||
|
|
||||||
|
This is the current low-level composition API. A future convenience API
|
||||||
|
may accept a tunnel-bearing multiaddr, realize its WireGuard bindspace
|
||||||
|
internally, and supply that live capability to root startup.
|
||||||
|
|
||||||
|
Tunnels and WireGuard
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
.. autoclass:: TunnelledAddress
|
||||||
|
|
||||||
|
.. autoclass:: WGTunnelSpec
|
||||||
|
|
||||||
|
.. autoclass:: WGInterfaceConfig
|
||||||
|
|
||||||
|
.. autoclass:: WGPeerConfig
|
||||||
|
|
||||||
|
.. autofunction:: parse_wg_maddr
|
||||||
|
|
||||||
|
.. autofunction:: mk_wg_maddr
|
||||||
|
|
||||||
|
.. autofunction:: strip_tunnels
|
||||||
|
|
||||||
|
.. autofunction:: tunnels_of
|
||||||
|
|
||||||
|
.. autofunction:: open_wg_iface
|
||||||
|
|
||||||
|
.. autofunction:: open_wg_bindspace
|
||||||
|
|
||||||
|
.. autofunction:: read_wg_pubkey
|
||||||
|
|
||||||
|
.. autofunction:: read_wg_peers
|
||||||
|
|
||||||
|
.. autofunction:: verify_wg_peer
|
||||||
|
|
@ -264,7 +264,7 @@ terminology is retired: it's *registrar*/*registry* everywhere now
|
||||||
substitute "registrar" and you're up to date.
|
substitute "registrar" and you're up to date.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Multihoming nerds: ``tractor.discovery`` also ships
|
Multihoming nerds: ``tractor.net`` ships
|
||||||
libp2p-style *multiaddr* helpers — ``mk_maddr()`` and
|
libp2p-style *multiaddr* helpers — ``mk_maddr()`` and
|
||||||
``parse_maddr()`` — for describing transport endpoints as
|
``parse_maddr()`` — for describing transport endpoints as
|
||||||
structured strings.
|
structured strings.
|
||||||
|
|
|
||||||
|
|
@ -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,30 @@ 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 --extra wg
|
||||||
```
|
```
|
||||||
|
|
||||||
gets you a `wg`-aware `multiaddr`. That pin goes away once a
|
gets you a `wg`-aware `multiaddr` plus pyroute2's Linux netlink API.
|
||||||
release carries the codec. You also need `multibase`:
|
The multiaddr pin goes away once a release carries the codec.
|
||||||
|
`py-multibase` is a direct project dependency, so no separate install
|
||||||
```bash
|
command is needed.
|
||||||
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 +105,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 +120,34 @@ ping -c1 10.0.11.1 # from B
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -c "
|
python -c "
|
||||||
import base64, multibase
|
from tractor.net 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
|
||||||
|
|
||||||
|
Both scripts explicitly call `await verify_wg_peer(addr.tunnel)`
|
||||||
|
before starting `tractor`. The helper validates the maddr's declared
|
||||||
|
key, reads one `wg0` key snapshot through pyroute2's Linux
|
||||||
|
generic-netlink API, and accepts the key when it is either the
|
||||||
|
interface's own public key or one of its configured peers.
|
||||||
|
|
||||||
|
This establishes key presence only. It does not enforce a
|
||||||
|
host-specific local/peer role and does not verify `Endpoint`,
|
||||||
|
`AllowedIPs`, a recent handshake, or routing.
|
||||||
|
|
||||||
|
Interface inspection commonly requires `CAP_NET_ADMIN` in the user
|
||||||
|
namespace that owns the target network namespace. Run each program in
|
||||||
|
a security context that already has the required inspection authority.
|
||||||
|
The helper never invokes `sudo` or `wg(8)`, escalates privileges, or
|
||||||
|
creates a namespace.
|
||||||
|
|
||||||
|
## 3. run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# host A
|
# host A
|
||||||
|
|
@ -137,6 +157,16 @@ python host_a_srv.py
|
||||||
python host_b_client.py
|
python host_b_client.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run both `tractor` programs as the normal application account in a
|
||||||
|
security context with the inspection authority described above. No
|
||||||
|
`WG_KEY_INSPECTION` export or subprocess preflight is used; tunnel
|
||||||
|
setup remains out-of-band. Do not run the applications as root.
|
||||||
|
|
||||||
|
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 +183,38 @@ 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; async `verify_wg_peer()` is now a separate,
|
||||||
composed step that the caller invokes. A parser that shells
|
explicitly composed step that the caller invokes. Implicit
|
||||||
out is a nasty surprise.
|
kernel inspection from a parser is a nasty surprise.
|
||||||
3. **no `sudo`.** #482 ran `sudo wg show`; a library/example must
|
3. **no `sudo` or subprocess.** #482 ran `sudo wg show`; tractor's
|
||||||
never escalate. `wg show` works unprivileged for read on most
|
helper reads generic netlink through pyroute2 and never attempts
|
||||||
setups; if yours needs root, run the script as root rather
|
privilege escalation or namespace creation. The caller must
|
||||||
than embedding `sudo`.
|
already have the required inspection authority.
|
||||||
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
|
## root composition
|
||||||
|
|
||||||
`WGTunnelledAddr` is deliberately example-local. Promoting it to
|
The `TunnelledAddress`, native maddr parser, bindspace lifecycle, and
|
||||||
`tractor.discovery` as a `TunnelledAddress` whose
|
explicit pyroute2 verification APIs live in `tractor.net`. Keep the
|
||||||
`.proto_key`/`.unwrap()` delegate to `.overlay`, plus
|
owning bindspace context outside the root actor so its namespace FD
|
||||||
`open_bindspace()` `@acm`s that create/tear down the iface +
|
remains live through complete actor teardown:
|
||||||
netns via `pyroute2`, is layers A→C of the plan doc.
|
|
||||||
|
```python
|
||||||
|
async with tractor.net.open_wg_bindspace(
|
||||||
|
bindspace_spec,
|
||||||
|
layers,
|
||||||
|
role='listen',
|
||||||
|
) as bindspace:
|
||||||
|
async with tractor.open_root_actor(bindspace=bindspace):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
The root actor enters before registry or IPC setup and restores the
|
||||||
|
calling thread's original namespace before the outer bindspace context
|
||||||
|
removes owned WireGuard and netns resources.
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@ from __future__ import annotations
|
||||||
|
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
|
from tractor.net import (
|
||||||
from wg_maddr import (
|
TunnelledAddress,
|
||||||
|
mk_maddr,
|
||||||
parse_wg_maddr,
|
parse_wg_maddr,
|
||||||
verify_wg_peer,
|
verify_wg_peer,
|
||||||
WGTunnelledAddr,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# bearer = host A's underlay `(ip, wg ListenPort)`
|
# bearer = host A's underlay `(ip, wg ListenPort)`
|
||||||
|
|
@ -33,27 +33,31 @@ 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), (
|
if not await verify_wg_peer(addr.tunnel):
|
||||||
f'wg pubkey from maddr not active on wg0 !\n'
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,37 +8,42 @@ from __future__ import annotations
|
||||||
|
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
|
from tractor.net import (
|
||||||
from host_a_srv import echo # noqa: F401 (RPC refs it by mod path)
|
TunnelledAddress,
|
||||||
from wg_maddr import (
|
|
||||||
parse_wg_maddr,
|
parse_wg_maddr,
|
||||||
verify_wg_peer,
|
verify_wg_peer,
|
||||||
WGTunnelledAddr,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from host_a_srv import echo # noqa: F401 (RPC refs it by mod path)
|
||||||
|
|
||||||
# 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 = (
|
||||||
'/ip4/192.168.1.50/udp/51820'
|
'/ip4/192.168.1.50/udp/51820'
|
||||||
'/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), (
|
if not await verify_wg_peer(addr.tunnel):
|
||||||
f'wg pubkey from maddr not a peer on wg0 !\n'
|
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(
|
||||||
|
|
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
# tractor: distributed structured concurrency.
|
|
||||||
r'''
|
|
||||||
Parse `wg`-tunnelled multiaddrs into `tractor`-ready addrs.
|
|
||||||
|
|
||||||
The canonical form (per py-multiaddr #108, verified against its
|
|
||||||
upstream merge) nests the *overlay* endpoint **after** the `/wg/`
|
|
||||||
segment:
|
|
||||||
|
|
||||||
/ip4/10.0.0.1/udp/51820/wg/u<key>/ip4/10.0.11.1/tcp/1616
|
|
||||||
\_______ wg bearer ______/\_ key _/\____ tractor ep _____/
|
|
||||||
(underlay, wg
|
|
||||||
`ListenPort`)
|
|
||||||
|
|
||||||
Naming follows `py-multiaddr`'s own encapsulation model, where
|
|
||||||
earlier segments *wrap* later ones (`.encapsulate()` appends), so
|
|
||||||
the two roles are:
|
|
||||||
|
|
||||||
- **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
|
|
||||||
import base64
|
|
||||||
import subprocess
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
import msgspec
|
|
||||||
from multiaddr import Multiaddr
|
|
||||||
from multiaddr.protocols import P_WG
|
|
||||||
|
|
||||||
|
|
||||||
IPProto = Literal['ip4', 'ip6']
|
|
||||||
|
|
||||||
|
|
||||||
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(
|
|
||||||
addr: WGTunnelledAddr,
|
|
||||||
iface: str = 'wg0',
|
|
||||||
) -> bool:
|
|
||||||
'''
|
|
||||||
True iff `addr.peer_pubkey` is a configured peer (or our own
|
|
||||||
pubkey) on `iface`.
|
|
||||||
|
|
||||||
IMPURE + explicit by design: never called from
|
|
||||||
`parse_wg_maddr()`.
|
|
||||||
|
|
||||||
?TODO, per plan-03 layer B, swap this body for `pyroute2`
|
|
||||||
(keeping the signature) — and note `setns(2)` is *per-thread*,
|
|
||||||
so a query issued via `trio.to_thread` lands in the ORIGINAL
|
|
||||||
netns unless `netns=` is passed down.
|
|
||||||
|
|
||||||
'''
|
|
||||||
def _wg(*args: str) -> str:
|
|
||||||
return subprocess.run(
|
|
||||||
['wg', 'show', iface, *args],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
).stdout
|
|
||||||
|
|
||||||
return (
|
|
||||||
addr.peer_pubkey in _wg('peers').split()
|
|
||||||
or
|
|
||||||
addr.peer_pubkey == _wg('public-key').strip()
|
|
||||||
)
|
|
||||||
|
|
@ -0,0 +1,640 @@
|
||||||
|
# Declarative WireGuard actor bootstrap
|
||||||
|
|
||||||
|
This plan starts from `d80dcbe6`, after the explicit WireGuard,
|
||||||
|
bindspace, root-netns and Trio-child bootstrap work in PR #511.
|
||||||
|
It proposes the final declaration-driven layer only. Production and
|
||||||
|
test implementation remain outside this plan commit.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Turn an actor-name endpoint table into explicitly owned network
|
||||||
|
resources that configure one root and its named Trio children without
|
||||||
|
moving declarations, WG secrets or lifecycle policy into actor runtime
|
||||||
|
parameters.
|
||||||
|
|
||||||
|
The completed API should let an application:
|
||||||
|
|
||||||
|
1. parse a flat endpoint table;
|
||||||
|
2. combine each actor's declarations with local realization policy;
|
||||||
|
3. realize every required network resource before root startup;
|
||||||
|
4. pass concrete addresses and live bindspaces through existing root
|
||||||
|
and child APIs; and
|
||||||
|
5. retain every owned resource until the complete actor tree is reaped.
|
||||||
|
|
||||||
|
For attached resources, the coordinator pins the namespace FD only.
|
||||||
|
The external owner must keep each borrowed interface present and
|
||||||
|
unchanged until the endpoint-tree context exits.
|
||||||
|
|
||||||
|
## Existing boundaries
|
||||||
|
|
||||||
|
The implementation composes, rather than replaces, these contracts:
|
||||||
|
|
||||||
|
- `parse_endpoints()` parses a flat
|
||||||
|
`dict[str, list[address declaration]]` and preserves ordered
|
||||||
|
`TunnelledAddress` wrappers. It neither selects an actor nor opens
|
||||||
|
resources (`tractor/discovery/_multiaddr.py:170-227`).
|
||||||
|
- A parsed `WGTunnelSpec` carries public path identity. Local interface
|
||||||
|
selection and `WGInterfaceConfig` remain process-local policy
|
||||||
|
(`tractor/net/_tunnel.py:116-149`,
|
||||||
|
`tractor/net/_tunnel.py:218-368`).
|
||||||
|
- `open_wg_bindspace()` manages one bindspace and an ordered stack of
|
||||||
|
owned WG interfaces (`tractor/net/_tunnel.py:637-687`).
|
||||||
|
- `open_root_actor()` accepts concrete addresses and one already-live
|
||||||
|
`Bindspace`; it enters that namespace before registry or IPC work
|
||||||
|
(`tractor/_root.py:183-311`).
|
||||||
|
- `ActorNursery.start_actor()` accepts concrete child bind tuples and
|
||||||
|
one live `Bindspace`; Trio spawn transports a duplicate FD before
|
||||||
|
child runtime startup (`tractor/runtime/_supervise.py:416-513`).
|
||||||
|
- `SpawnSpec.bind_addrs` remains concrete tuples because msgspec cannot
|
||||||
|
decode the abstract address union
|
||||||
|
(`tractor/msg/types.py:193-220`).
|
||||||
|
|
||||||
|
## Decisions proposed for review
|
||||||
|
|
||||||
|
### Keep declaration and runtime layers separate
|
||||||
|
|
||||||
|
Do not add an endpoint table, WG secrets or lifecycle policy to
|
||||||
|
`open_root_actor()` or `ActorNursery.start_actor()`.
|
||||||
|
|
||||||
|
Add an outer `tractor.net` planner and resource context. The caller
|
||||||
|
continues to pass only:
|
||||||
|
|
||||||
|
- realized declarations and a live bindspace to the root;
|
||||||
|
- concrete unwrapped bind addresses and a live bindspace to Trio
|
||||||
|
children; and
|
||||||
|
- registrar addresses explicitly, because remote registry discovery
|
||||||
|
is separate from local actor binding.
|
||||||
|
|
||||||
|
### Start with a flat, exact-name table
|
||||||
|
|
||||||
|
The first implementation supports exact actor-name keys only.
|
||||||
|
|
||||||
|
- `worker` never matches `worker-2` or a prefixed cluster name.
|
||||||
|
- A missing name means no declarative override; callers may retain
|
||||||
|
existing defaults.
|
||||||
|
- An explicitly empty endpoint list is rejected by planning rather
|
||||||
|
than silently selecting a random listener.
|
||||||
|
- Planning snapshots input lists and never pops or mutates caller
|
||||||
|
configuration.
|
||||||
|
- One table entry describes one actor instance. Reusing its fixed
|
||||||
|
binding for concurrent actors violates the API precondition; no
|
||||||
|
runtime claim registry is added in this phase.
|
||||||
|
|
||||||
|
Recursive tables and instance allocation remain separate API design
|
||||||
|
work. Their matching and ownership semantics are not defined by the
|
||||||
|
original parser.
|
||||||
|
|
||||||
|
### Separate declaration identity from local realization
|
||||||
|
|
||||||
|
An maddr does not choose the local interface or namespace. Pair each
|
||||||
|
declared tunnel with explicit process-local realization policy:
|
||||||
|
|
||||||
|
```python
|
||||||
|
WGInterfaceLifecycle = Literal['attach', 'open']
|
||||||
|
|
||||||
|
|
||||||
|
class EndpointLayerConfig(ProcessLocal):
|
||||||
|
spec: WGTunnelSpec
|
||||||
|
lifecycle: WGInterfaceLifecycle
|
||||||
|
config: WGInterfaceConfig | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EndpointConfig(ProcessLocal):
|
||||||
|
bindspace_spec: BindspaceSpec
|
||||||
|
layers: tuple[EndpointLayerConfig, ...] = ()
|
||||||
|
role: WGRole = 'listen'
|
||||||
|
```
|
||||||
|
|
||||||
|
`EndpointLayerConfig.spec` is the local realization. Planning matches
|
||||||
|
it to the parsed declaration by tunnel depth and maddr-defined fields:
|
||||||
|
|
||||||
|
- `peer_pubkey` and `bearer` must match the declaration;
|
||||||
|
- local `iface` and `netns` may replace parser defaults;
|
||||||
|
- an explicit local `netns` must agree with
|
||||||
|
`BindspaceSpec.key`; and
|
||||||
|
- layer order remains outermost first.
|
||||||
|
|
||||||
|
Planning retains `peer_pubkey`, `bearer` and wrapper order as the public
|
||||||
|
declaration identity, but does not preserve parsed local `iface` or
|
||||||
|
`netns` values in the realized graph. After setup, recursively rebuild
|
||||||
|
every tunnel wrapper from the concrete overlay outward with its matched
|
||||||
|
local spec and the same realized `BindspaceRef`. This canonical graph
|
||||||
|
cannot carry a stale parsed namespace or leave an inner wrapper
|
||||||
|
unannotated. It also avoids calling `with_bindspace_ref()` on an
|
||||||
|
incompatible retained spec.
|
||||||
|
|
||||||
|
For `lifecycle='open'`, `config` is required and the interface is
|
||||||
|
created and removed. For `lifecycle='attach'`, `config` is absent; the
|
||||||
|
coordinator verifies an existing interface but never creates, mutates
|
||||||
|
or deletes it. Attachment is a checked borrowing contract, not an
|
||||||
|
interface lease: the caller must arrange an external owner that keeps
|
||||||
|
the interface alive and configured until context exit.
|
||||||
|
|
||||||
|
Listen declarations identify the local WG public key. After managed
|
||||||
|
creation, or during attachment, read the actual interface key and
|
||||||
|
compare it with the declaration before publishing a binding. Dial
|
||||||
|
declarations identify a remote peer and use explicit peer verification.
|
||||||
|
No key relationship is inferred from private material alone.
|
||||||
|
|
||||||
|
### Require one network stack per actor process
|
||||||
|
|
||||||
|
A process enters one active network namespace for runtime startup.
|
||||||
|
Planning therefore enforces:
|
||||||
|
|
||||||
|
- all tunnel-bearing endpoints for one actor declare one common
|
||||||
|
ordered tunnel stack;
|
||||||
|
- local layers match that stack by declared identity and depth;
|
||||||
|
- plain addresses may coexist inside the same bindspace;
|
||||||
|
- a bindspace-only profile may use an empty layer tuple; and
|
||||||
|
- unknown config keys, missing layers, extra layers, incompatible
|
||||||
|
stacks or namespace mismatches fail before async setup.
|
||||||
|
|
||||||
|
Planning also checks static collisions across actors that resolve into
|
||||||
|
the same bindspace:
|
||||||
|
|
||||||
|
- duplicate fixed TCP/UDS bind addresses;
|
||||||
|
- duplicate managed interface names; and
|
||||||
|
- duplicate ownership claims for one named bindspace.
|
||||||
|
|
||||||
|
This catches configuration collisions but does not police later reuse
|
||||||
|
of one binding by arbitrary runtime calls.
|
||||||
|
|
||||||
|
### Snapshot before the first checkpoint
|
||||||
|
|
||||||
|
Existing process-local WG config structs are mutable. A frozen outer
|
||||||
|
tuple does not freeze nested key, peer or route values.
|
||||||
|
|
||||||
|
Add a frozen process-local marker beside `ProcessLocal` for endpoint
|
||||||
|
records, and prove default msgspec encoding still fails. More
|
||||||
|
importantly, `open_endpoint_tree()` must synchronously copy every
|
||||||
|
mutable nested field into private coordinator-owned values before its
|
||||||
|
first `await`. Setup and cleanup never reread caller-owned plans.
|
||||||
|
|
||||||
|
The proposed public outputs are:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EndpointPlan(FrozenProcessLocal):
|
||||||
|
name: str
|
||||||
|
declared_addrs: tuple[Address | TunnelledAddress, ...]
|
||||||
|
bind_addrs: tuple[Address, ...]
|
||||||
|
config: EndpointConfig | None
|
||||||
|
|
||||||
|
|
||||||
|
class EndpointBinding(FrozenProcessLocal):
|
||||||
|
name: str
|
||||||
|
declared_addrs: tuple[Address | TunnelledAddress, ...]
|
||||||
|
bind_addrs: tuple[Address, ...]
|
||||||
|
bindspace: Bindspace | None
|
||||||
|
```
|
||||||
|
|
||||||
|
`EndpointBinding` does not expose WG secrets or mutable setup config.
|
||||||
|
Its tunnel declarations are recursively rebuilt with canonical local
|
||||||
|
specs and the realized `BindspaceRef` so every layer agrees with the
|
||||||
|
live bindspace and root endpoint diagnostics report its namespace
|
||||||
|
inode. The existing single-wrapper `with_bindspace_ref()` helper is
|
||||||
|
insufficient when parsed local fields differ or wrappers are nested
|
||||||
|
(`tractor/net/_tunnel.py:886-909`,
|
||||||
|
`tractor/net/_tunnel.py:946-976`).
|
||||||
|
|
||||||
|
### Realize the complete table before root startup
|
||||||
|
|
||||||
|
Add lazy public APIs from `tractor/net/_bootstrap.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def plan_endpoints(
|
||||||
|
endpoints: ParsedEndpoints,
|
||||||
|
configs: Mapping[str, EndpointConfig],
|
||||||
|
) -> dict[str, EndpointPlan]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def open_endpoint_tree(
|
||||||
|
plans: Mapping[str, EndpointPlan],
|
||||||
|
) -> AsyncIterator[Mapping[str, EndpointBinding]]:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`open_endpoint_tree()` enters plans in table order through one
|
||||||
|
`AsyncExitStack` and publishes a read-only binding mapping only after
|
||||||
|
all resources and identity checks are ready. It opens no resource
|
||||||
|
lazily from actor-spawn tasks.
|
||||||
|
|
||||||
|
The intended call shape is:
|
||||||
|
|
||||||
|
```python
|
||||||
|
parsed = tractor.net.parse_endpoints(endpoint_table)
|
||||||
|
plans = tractor.net.plan_endpoints(parsed, local_configs)
|
||||||
|
|
||||||
|
async with tractor.net.open_endpoint_tree(plans) as endpoints:
|
||||||
|
root_ep = endpoints['pikerd']
|
||||||
|
async with tractor.open_root_actor(
|
||||||
|
name='pikerd',
|
||||||
|
tpt_bind_addrs=list(root_ep.declared_addrs),
|
||||||
|
bindspace=root_ep.bindspace,
|
||||||
|
registry_addrs=registry_addrs,
|
||||||
|
):
|
||||||
|
async with tractor.open_nursery() as an:
|
||||||
|
child_ep = endpoints['brokerd']
|
||||||
|
portal = await an.start_actor(
|
||||||
|
'brokerd',
|
||||||
|
bind_addrs=[
|
||||||
|
addr.unwrap()
|
||||||
|
for addr in child_ep.bind_addrs
|
||||||
|
],
|
||||||
|
bindspace=child_ep.bindspace,
|
||||||
|
)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
The nesting is contractual: the endpoint tree encloses the root and
|
||||||
|
actor nursery so networking cannot tear down around a live actor.
|
||||||
|
|
||||||
|
## Concurrency and cleanup contract
|
||||||
|
|
||||||
|
`open_endpoint_tree()` has no shared mutable cache, runtime claim map
|
||||||
|
or lazy setup path.
|
||||||
|
|
||||||
|
The setup path is:
|
||||||
|
|
||||||
|
1. validate and privately snapshot every plan synchronously;
|
||||||
|
2. enter actor A's bindspace and layers;
|
||||||
|
3. enter actor B's bindspace and layers;
|
||||||
|
4. verify local listen keys or dial peers;
|
||||||
|
5. recursively construct canonical declarations whose every wrapper
|
||||||
|
carries the live bindspace ref;
|
||||||
|
6. publish a read-only binding mapping; and
|
||||||
|
7. let caller tasks start actors from already-live capabilities.
|
||||||
|
|
||||||
|
Every async-context entry is a checkpoint. A failure or cancellation
|
||||||
|
must close the entered prefix before it escapes. No task can observe a
|
||||||
|
partial binding mapping, and caller mutation after the private snapshot
|
||||||
|
cannot affect setup.
|
||||||
|
|
||||||
|
After yield, root and Trio startup duplicate borrowed namespace FDs
|
||||||
|
rather than consuming coordinator handles. Concurrent child starts
|
||||||
|
read capabilities but do not mutate coordinator state.
|
||||||
|
|
||||||
|
The teardown path is:
|
||||||
|
|
||||||
|
1. child actors stop and are reaped;
|
||||||
|
2. root listeners, runtime and thread restoration complete;
|
||||||
|
3. actor WG layers close inside-out; and
|
||||||
|
4. actor bindspaces release last.
|
||||||
|
|
||||||
|
Plain `AsyncExitStack` does not preserve this plan's error policy by
|
||||||
|
itself. Lower WG and netns managers can currently mask a body or setup
|
||||||
|
error when cleanup fails. Before exporting the coordinator:
|
||||||
|
|
||||||
|
- shield every owned cleanup from cancellation;
|
||||||
|
- continue attempting all remaining cleanup;
|
||||||
|
- preserve the active setup/body/cancellation exception;
|
||||||
|
- attach later cleanup failures as notes; and
|
||||||
|
- make externally removed owned netns teardown idempotent.
|
||||||
|
|
||||||
|
Apply that policy inside `open_netns()`, `open_wg_iface()`, interface
|
||||||
|
creation rollback and the endpoint-tree stack. An outer manager cannot
|
||||||
|
repair a primary exception already replaced by an inner context.
|
||||||
|
|
||||||
|
## Route and privilege policy
|
||||||
|
|
||||||
|
### Explicit overlay routes
|
||||||
|
|
||||||
|
`_sync_create_wg_iface()` adds addresses and peer settings but no
|
||||||
|
explicit routes (`tractor/net/_tunnel.py:470-559`). Connected routes
|
||||||
|
cover only peers in the configured interface subnet.
|
||||||
|
|
||||||
|
Extend `WGInterfaceConfig` with an explicit `routes` tuple. Validate
|
||||||
|
each CIDR and add it to the newly owned interface without replacing an
|
||||||
|
existing host route. Interface deletion remains the owned route cleanup
|
||||||
|
boundary.
|
||||||
|
|
||||||
|
Do not infer host routes from peer `allowed_ips`: those values control
|
||||||
|
WireGuard peer selection and may exceed routes this process should own.
|
||||||
|
|
||||||
|
Tests must pre-create a conflicting route and prove provisioning fails
|
||||||
|
without changing it, while still rolling back only the new interface.
|
||||||
|
|
||||||
|
### Actionable privilege checks
|
||||||
|
|
||||||
|
Managed netns and WG creation are Linux-only and require authority in
|
||||||
|
the owning user namespace. Add diagnostics before the first resource
|
||||||
|
checkpoint:
|
||||||
|
|
||||||
|
- reject managed provisioning on non-Linux platforms;
|
||||||
|
- report a missing `tractor[wg]` dependency before partial setup;
|
||||||
|
- name `CAP_NET_ADMIN` for interface, address and route changes;
|
||||||
|
- name `CAP_SYS_ADMIN` where namespace creation or entry needs it; and
|
||||||
|
- wrap authoritative kernel `EPERM` with the failed operation,
|
||||||
|
namespace and required capability.
|
||||||
|
|
||||||
|
An effective-capability mask is diagnostic only. User namespaces mean
|
||||||
|
the kernel operation remains authoritative.
|
||||||
|
|
||||||
|
Pre-provisioned attachment pins the existing namespace capability and
|
||||||
|
performs a point-in-time interface identity/configuration check. It
|
||||||
|
cannot pin a link against external deletion or reconfiguration, and it
|
||||||
|
owns no interface cleanup. Keeping that link stable through context
|
||||||
|
exit is an explicit external-owner precondition. Managed creation owns
|
||||||
|
all mutation and removal.
|
||||||
|
|
||||||
|
Strictly dropping provisioning capability before actor code conflicts
|
||||||
|
with same-process teardown, which needs that capability later. A helper
|
||||||
|
or persistent `wgman` is required to satisfy both drop-before-user-code
|
||||||
|
and managed cleanup. That architecture remains deferred; this phase
|
||||||
|
documents the limitation rather than claiming capability isolation.
|
||||||
|
|
||||||
|
## Test strategy
|
||||||
|
|
||||||
|
### Pure endpoint planning
|
||||||
|
|
||||||
|
Add `tests/net/test_bootstrap.py` with unit coverage for:
|
||||||
|
|
||||||
|
- exact matching beside similar names;
|
||||||
|
- absent versus explicitly empty declarations;
|
||||||
|
- defensive snapshots and preserved declaration order;
|
||||||
|
- mixed plain and tunnel-bearing endpoints;
|
||||||
|
- declaration-versus-realization field matching;
|
||||||
|
- canonical recursive rebuilding of parsed local fields;
|
||||||
|
- nested layer order;
|
||||||
|
- attach versus open interface policy;
|
||||||
|
- missing, extra and reordered layer configuration;
|
||||||
|
- differing tunnel stacks for one actor;
|
||||||
|
- bindspace/interface/address collisions across actors;
|
||||||
|
- plain bindspace-only profiles; and
|
||||||
|
- unknown local configuration keys.
|
||||||
|
|
||||||
|
No scheduler, monkeypatch or kernel resource is needed at this layer.
|
||||||
|
|
||||||
|
### Lower lifecycle regressions
|
||||||
|
|
||||||
|
Extend bindspace and WG lifecycle tests to prove:
|
||||||
|
|
||||||
|
- setup rollback preserves the setup error when removal also fails;
|
||||||
|
- body failure remains primary when WG/netns cleanup fails;
|
||||||
|
- cancellation cannot interrupt owned cleanup;
|
||||||
|
- all remaining cleanup still runs after one failure;
|
||||||
|
- externally removed named netns cleanup is tolerated;
|
||||||
|
- attached interfaces are verified but never mutated or deleted;
|
||||||
|
- attached-interface lifetime is not represented as coordinator
|
||||||
|
ownership;
|
||||||
|
- managed listen identity matches the configured interface key; and
|
||||||
|
- dial attachment verifies the declared peer.
|
||||||
|
|
||||||
|
Use real Trio scheduling. Replace only pyroute2/kernel adapters where
|
||||||
|
the proof concerns exception ordering rather than kernel behavior.
|
||||||
|
|
||||||
|
### Coordinator lifecycle component tests
|
||||||
|
|
||||||
|
Use real Trio and `AsyncExitStack`, replacing bindspace and WG kernel
|
||||||
|
adapters with traceable async contexts.
|
||||||
|
|
||||||
|
Prove:
|
||||||
|
|
||||||
|
- every resource is ready before publication;
|
||||||
|
- plans enter in table order and exit in reverse order;
|
||||||
|
- each setup failure closes the entered prefix;
|
||||||
|
- body failure and cancellation close every resource;
|
||||||
|
- cleanup failures become secondary notes and do not stop cleanup;
|
||||||
|
- caller mutation during an entry checkpoint cannot alter setup; and
|
||||||
|
- every wrapper in published declarations contains the realized
|
||||||
|
bindspace inode and canonical local spec.
|
||||||
|
|
||||||
|
The doubles retain context scheduling and ownership. They do not prove
|
||||||
|
pyroute2, kernel routing or `setns()`.
|
||||||
|
|
||||||
|
### Real root and Trio composition
|
||||||
|
|
||||||
|
Add Linux integration coverage that:
|
||||||
|
|
||||||
|
- plans distinct exact-name root and child entries;
|
||||||
|
- opens all resources before root startup;
|
||||||
|
- gives the root realized declarations and its live bindspace;
|
||||||
|
- gives the child concrete tuples and its alternate bindspace;
|
||||||
|
- observes the expected namespace in both actor bodies; and
|
||||||
|
- proves actors stop before owned endpoint resources unwind.
|
||||||
|
|
||||||
|
Fake only WG provisioning. Real namespace FDs, Trio process spawn,
|
||||||
|
handshake, supervision and cleanup remain material boundaries.
|
||||||
|
|
||||||
|
### Real WireGuard dataplane
|
||||||
|
|
||||||
|
Add a required, separately capable Linux system test that:
|
||||||
|
|
||||||
|
1. enters disposable user, mount and network namespaces;
|
||||||
|
2. establishes UID/GID mappings and private mount propagation;
|
||||||
|
3. provides an isolated writable `/var/run/netns` strategy for the
|
||||||
|
existing named-netns implementation;
|
||||||
|
4. creates two named netns and a veth underlay through pyroute2;
|
||||||
|
5. opens one real WG endpoint profile in each namespace;
|
||||||
|
6. configures overlay addresses, peer policy and explicit routes;
|
||||||
|
7. starts the root in one namespace and a Trio child in the other;
|
||||||
|
8. completes the parent handshake over the WG overlay;
|
||||||
|
9. discovers the actor with `find_actor()` and performs one RPC; and
|
||||||
|
10. proves actor, socket, interface, route, namespace and FD teardown.
|
||||||
|
|
||||||
|
The veth pair is test-environment underlay, not a production bindspace
|
||||||
|
feature. The test replaces no networking or process boundary.
|
||||||
|
|
||||||
|
Skip only after a precise preflight proves an unavailable kernel
|
||||||
|
feature. Do not catch-and-skip after partial mutation. No `sudo`,
|
||||||
|
subprocess `wg` or sleep-based synchronization is allowed.
|
||||||
|
|
||||||
|
This commit is not considered a dataplane proof until the test passes
|
||||||
|
in an approved environment with WireGuard and the required namespace
|
||||||
|
capabilities. If no such CI runner or recorded local environment is
|
||||||
|
available, stop and report the system-test evidence as blocked rather
|
||||||
|
than merging a universally skipped scaffold.
|
||||||
|
|
||||||
|
## Implementation sequence
|
||||||
|
|
||||||
|
### Commit 1: Resolve endpoint realization plans
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- add a frozen process-local marker in `tractor/msg/_local.py`;
|
||||||
|
- add `tractor/net/_bootstrap.py`;
|
||||||
|
- update lazy exports in `tractor/net/__init__.py`;
|
||||||
|
- add pure tests in `tests/net/test_bootstrap.py`; and
|
||||||
|
- add Prompt-IO provenance for substantive generated code.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- add layer/config/plan models and `plan_endpoints()`;
|
||||||
|
- separate parsed identity from local realization fields;
|
||||||
|
- define canonical recursive declaration reconstruction;
|
||||||
|
- enforce exact-name, one-stack and static-collision rules;
|
||||||
|
- snapshot declarations and expose concrete bind addresses; and
|
||||||
|
- reject ambiguous configuration before async setup.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
|
||||||
|
- Ruff on changed Python;
|
||||||
|
- process-local encoding and immutability tests;
|
||||||
|
- bootstrap, multiaddr and lazy-import tests; and
|
||||||
|
- full test collection.
|
||||||
|
|
||||||
|
### Commit 2: Harden owned and borrowed network lifecycles
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- update netns cleanup in `tractor/net/_bindspace.py`;
|
||||||
|
- update WG rollback/cleanup and attachment in
|
||||||
|
`tractor/net/_tunnel.py`;
|
||||||
|
- extend bindspace and WG lifecycle tests; and
|
||||||
|
- add Prompt-IO provenance.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- preserve primary errors and finish shielded cleanup;
|
||||||
|
- tolerate already-removed owned netns teardown;
|
||||||
|
- add explicit borrowed-interface verification with no mutation;
|
||||||
|
- document and test its external-owner lifetime precondition; and
|
||||||
|
- verify listen keys and dial peers before use.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
|
||||||
|
- Ruff;
|
||||||
|
- setup/body/cancellation/cleanup failure schedules;
|
||||||
|
- attached versus managed ownership tests;
|
||||||
|
- existing bindspace and WG lifecycle suites; and
|
||||||
|
- full test collection.
|
||||||
|
|
||||||
|
### Commit 3: Add routes and authority diagnostics
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- extend WG config/provisioning in `tractor/net/_tunnel.py`;
|
||||||
|
- add a small Linux authority helper under `tractor/net/`;
|
||||||
|
- extend config, provisioning and privilege tests; and
|
||||||
|
- add Prompt-IO provenance.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- validate and own explicit route CIDRs;
|
||||||
|
- reject conflicting routes without replacing host state;
|
||||||
|
- distinguish attached from managed privilege requirements;
|
||||||
|
- fail before partial setup when support is clearly absent; and
|
||||||
|
- retain kernel errors while adding actionable capability context.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
|
||||||
|
- Ruff;
|
||||||
|
- fake-backed pyroute2 route and rollback tests;
|
||||||
|
- deterministic platform/dependency/capability cases;
|
||||||
|
- real unprivileged namespace probe where available; and
|
||||||
|
- full test collection.
|
||||||
|
|
||||||
|
### Commit 4: Open complete endpoint trees
|
||||||
|
|
||||||
|
This is the first commit exporting the resource-owning coordinator;
|
||||||
|
the lower safety, route and privilege contracts land first.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- extend `tractor/net/_bootstrap.py` and lazy exports;
|
||||||
|
- extend `tests/net/test_bootstrap.py`; and
|
||||||
|
- add Prompt-IO provenance.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- add `EndpointBinding` and `open_endpoint_tree()`;
|
||||||
|
- privately snapshot plans before any checkpoint;
|
||||||
|
- realize managed and attached layers eagerly;
|
||||||
|
- verify identities and recursively rebuild canonical declarations
|
||||||
|
with bindspace refs on every wrapper;
|
||||||
|
- publish only after complete setup; and
|
||||||
|
- preserve primary errors through reverse-order cleanup.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
|
||||||
|
- Ruff;
|
||||||
|
- lifecycle and caller-mutation schedules;
|
||||||
|
- lower bindspace/WG suites; and
|
||||||
|
- full test collection.
|
||||||
|
|
||||||
|
### Commit 5: Compose a named actor tree
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- add focused root/Trio endpoint-tree integration coverage;
|
||||||
|
- update `docs/api/net.rst`;
|
||||||
|
- update the two-host WG example; and
|
||||||
|
- add Prompt-IO provenance as required by code/test changes.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- demonstrate root and child argument projection;
|
||||||
|
- preserve root realized declarations for diagnostics;
|
||||||
|
- send concrete child tuples through `SpawnSpec`;
|
||||||
|
- keep endpoint resources outside root and nursery lifetimes; and
|
||||||
|
- leave low-level root, nursery and `SpawnSpec` signatures unchanged.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
|
||||||
|
- Ruff;
|
||||||
|
- real root/Trio netns composition with fake WG provisioning;
|
||||||
|
- docs build; and
|
||||||
|
- the affected root, spawn, IPC and network matrix.
|
||||||
|
|
||||||
|
### Commit 6: Prove the real WG dataplane
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- add a Linux system test and isolated namespace fixture;
|
||||||
|
- add or update a capable CI job after explicit approval;
|
||||||
|
- document prerequisites and precise skip reasons; and
|
||||||
|
- add Prompt-IO provenance.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- provision the disposable veth/WG topology;
|
||||||
|
- prove handshake, discovery and RPC through WG;
|
||||||
|
- exercise routes and live bindspace FD propagation; and
|
||||||
|
- prove complete teardown without privilege escalation.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
|
||||||
|
- Ruff;
|
||||||
|
- a successful run in an approved capable Linux environment;
|
||||||
|
- ordinary CI collection and precise unavailable-feature skips;
|
||||||
|
- docs and package builds; and
|
||||||
|
- the full supported CI matrix.
|
||||||
|
|
||||||
|
## Deferred work
|
||||||
|
|
||||||
|
The following items remain outside this patch set:
|
||||||
|
|
||||||
|
- recursive endpoint tables and hierarchical actor-name matching;
|
||||||
|
- multiple concurrent actors claiming one fixed name/profile;
|
||||||
|
- multiprocessing alternate-bindspace FD transport;
|
||||||
|
- serializing tunnel declarations through `SpawnSpec` for child-side
|
||||||
|
diagnostic retention;
|
||||||
|
- precise child bootstrap exceptions before IPC handshake;
|
||||||
|
- strict privilege drop with a dedicated `wgman` or helper process;
|
||||||
|
- shared-resource caching and dynamic lazy profile realization;
|
||||||
|
- production veth, VRF, VXLAN and other bindspace kinds;
|
||||||
|
- native tagged `TunnelledAddress` graph decoding; and
|
||||||
|
- replacing the temporary py-multiaddr VCS pin after a `/wg/` release.
|
||||||
|
|
||||||
|
## Review gates before implementation
|
||||||
|
|
||||||
|
Human review should confirm:
|
||||||
|
|
||||||
|
1. public names for layer config, endpoint config, plan and binding
|
||||||
|
models plus `plan_endpoints()` and `open_endpoint_tree()`;
|
||||||
|
2. flat exact-name semantics and caller-enforced single-instance use;
|
||||||
|
3. one bindspace and one common declared stack per actor process;
|
||||||
|
4. separate parsed identity and local interface/netns realization;
|
||||||
|
5. explicit attach/open interface ownership, including the external
|
||||||
|
lifetime precondition for attached links;
|
||||||
|
6. eager whole-table realization instead of a concurrent lazy cache;
|
||||||
|
7. explicit local WG config and routes, with no inferred secrets or
|
||||||
|
host routing policy;
|
||||||
|
8. unchanged low-level root, nursery and `SpawnSpec` contracts;
|
||||||
|
9. privilege diagnostics with strict capability drop deferred to a
|
||||||
|
helper architecture; and
|
||||||
|
10. the capable Linux environment required to claim dataplane proof.
|
||||||
|
|
||||||
|
Implementation begins only after this plan is reviewed and explicitly
|
||||||
|
approved.
|
||||||
|
|
@ -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`).
|
||||||
|
|
@ -59,6 +62,12 @@ dependencies = [
|
||||||
"setproctitle>=1.3,<2",
|
"setproctitle>=1.3,<2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
wg = [
|
||||||
|
# read/provision Linux WireGuard state through netlink
|
||||||
|
"pyroute2>=0.9.6,<0.10 ; sys_platform == 'linux'",
|
||||||
|
]
|
||||||
|
|
||||||
# ------ project ------
|
# ------ project ------
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
@ -165,20 +174,8 @@ sync_pause = {requires-python = ">=3.13, <3.14"}
|
||||||
# editable = true
|
# editable = true
|
||||||
|
|
||||||
# ------ tool.uv.sources ------
|
# ------ tool.uv.sources ------
|
||||||
# TODO, distributed (multi-host) extensions
|
# Linux kernel networking is provided by the optional `wg` extra.
|
||||||
# linux kernel networking
|
# Add any temporary `pyroute2` source overrides here.
|
||||||
# '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 ------
|
||||||
|
|
||||||
|
|
@ -194,6 +191,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"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -18,7 +18,7 @@ from tractor.devx import dump_on_hang
|
||||||
from tractor.trionics import collapse_eg
|
from tractor.trionics import collapse_eg
|
||||||
from tractor._testing import tractor_test
|
from tractor._testing import tractor_test
|
||||||
from tractor.discovery._addr import wrap_address
|
from tractor.discovery._addr import wrap_address
|
||||||
from tractor.discovery._multiaddr import mk_maddr
|
from tractor.net import mk_maddr
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ from tractor.discovery import _api
|
||||||
from tractor.discovery._addr import (
|
from tractor.discovery._addr import (
|
||||||
wrap_address,
|
wrap_address,
|
||||||
)
|
)
|
||||||
from tractor.discovery._multiaddr import mk_maddr
|
from tractor.net import mk_maddr
|
||||||
from tractor.ipc import _connect_chan
|
from tractor.ipc import _connect_chan
|
||||||
from tractor._testing.addr import get_rando_addr
|
from tractor._testing.addr import get_rando_addr
|
||||||
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
'''
|
||||||
|
Tunnel annotation peeling at the outbound IPC transport boundary.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
|
||||||
|
from tractor.net 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,)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
'''
|
||||||
|
Tunnel annotation peeling at the inbound IPC transport boundary.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import trio
|
||||||
|
|
||||||
|
from tractor.net import (
|
||||||
|
BindspaceRef,
|
||||||
|
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() -> None:
|
||||||
|
'''
|
||||||
|
`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`. Prove `Endpoint.declared_addr` still retains the
|
||||||
|
original declaration and realized bindspace ref for
|
||||||
|
diagnostics. This retained metadata does not claim the listener
|
||||||
|
process entered that namespace.
|
||||||
|
|
||||||
|
'''
|
||||||
|
overlay = TCPAddress('127.0.0.1', 0)
|
||||||
|
tunnelled = TunnelledAddress(
|
||||||
|
overlay=overlay,
|
||||||
|
tunnel=WGTunnelSpec(
|
||||||
|
peer_pubkey=_PUBKEY,
|
||||||
|
bearer=('192.168.1.50', 51820),
|
||||||
|
netns='actor-net',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ref: BindspaceRef = BindspaceRef(
|
||||||
|
kind='netns',
|
||||||
|
key='actor-net',
|
||||||
|
inode=1234,
|
||||||
|
)
|
||||||
|
declared: TunnelledAddress = tunnelled.with_bindspace_ref(
|
||||||
|
ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with open_ipc_server() as server:
|
||||||
|
eps = await server.listen_on(
|
||||||
|
accept_addrs=[declared],
|
||||||
|
)
|
||||||
|
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 declared
|
||||||
|
assert endpoint.declared_addr is declared
|
||||||
|
namespace: tuple[str, int] = ('netns', 1234)
|
||||||
|
assert endpoint.namespace == namespace
|
||||||
|
endpoint_repr: str = endpoint.pformat()
|
||||||
|
server_repr: str = server.pformat()
|
||||||
|
expected_namespace: str = f'namespace: {namespace!r}'
|
||||||
|
assert expected_namespace in endpoint_repr
|
||||||
|
assert ' |_namespaces:' in server_repr
|
||||||
|
assert 'netns' in server_repr
|
||||||
|
assert '1234' in server_repr
|
||||||
|
assert tunnels_of(declared) == (
|
||||||
|
declared.tunnel,
|
||||||
|
)
|
||||||
|
|
||||||
|
server.cancel()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
'''
|
||||||
|
Process-local struct wire-encoding guards.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tractor.msg import ProcessLocal
|
||||||
|
|
||||||
|
|
||||||
|
class LocalHandle(ProcessLocal):
|
||||||
|
'''
|
||||||
|
Minimal process-local struct used to exercise the global marker.
|
||||||
|
|
||||||
|
'''
|
||||||
|
resource_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'nested',
|
||||||
|
(
|
||||||
|
pytest.param(
|
||||||
|
False,
|
||||||
|
id='direct',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
True,
|
||||||
|
id='nested',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_process_local_rejects_default_encoding(
|
||||||
|
nested: bool,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Process-local values can appear directly or deep in a payload.
|
||||||
|
|
||||||
|
Embed the same marked struct at both depths and prove msgspec's
|
||||||
|
normal traversal reaches the unsupported sentinel without a
|
||||||
|
tractor-specific recursive payload scan.
|
||||||
|
|
||||||
|
'''
|
||||||
|
handle: LocalHandle = LocalHandle(resource_id=1)
|
||||||
|
value: object = (
|
||||||
|
{'nested': [handle]}
|
||||||
|
if nested
|
||||||
|
else handle
|
||||||
|
)
|
||||||
|
|
||||||
|
assert repr(handle) == 'LocalHandle(resource_id=1)'
|
||||||
|
with pytest.raises(
|
||||||
|
TypeError,
|
||||||
|
match='_ProcessLocalToken.*unsupported',
|
||||||
|
):
|
||||||
|
msgspec.msgpack.encode(value)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
'''Network declaration and lifecycle tests.'''
|
||||||
|
|
@ -0,0 +1,572 @@
|
||||||
|
'''
|
||||||
|
Bindspace declaration, reference and live-capability contracts.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import BinaryIO
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
|
||||||
|
from tractor.net import (
|
||||||
|
Bindspace,
|
||||||
|
BindspaceOwnership,
|
||||||
|
BindspaceRef,
|
||||||
|
BindspaceSpec,
|
||||||
|
CURRENT_NETNS,
|
||||||
|
attach_netns,
|
||||||
|
open_bindspace,
|
||||||
|
open_netns,
|
||||||
|
)
|
||||||
|
from tractor.net import _bindspace
|
||||||
|
from tractor.msg import ProcessLocal
|
||||||
|
|
||||||
|
|
||||||
|
def test_bindspace_declarations_roundtrip() -> None:
|
||||||
|
'''
|
||||||
|
Spawn configuration and realized refs must cross actor IPC.
|
||||||
|
|
||||||
|
Encode both frozen structs through msgpack and decode with their
|
||||||
|
concrete types, proving names and stable inode refs survive
|
||||||
|
without carrying any process-local capability state.
|
||||||
|
|
||||||
|
'''
|
||||||
|
values: tuple[
|
||||||
|
BindspaceSpec|BindspaceRef,
|
||||||
|
...,
|
||||||
|
] = (
|
||||||
|
BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
lifecycle='open',
|
||||||
|
),
|
||||||
|
BindspaceRef(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
inode=1234,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
value: BindspaceSpec|BindspaceRef
|
||||||
|
for value in values:
|
||||||
|
encoded: bytes = msgspec.msgpack.encode(value)
|
||||||
|
decoded: BindspaceSpec|BindspaceRef = (
|
||||||
|
msgspec.msgpack.decode(
|
||||||
|
encoded,
|
||||||
|
type=type(value),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert decoded == value
|
||||||
|
|
||||||
|
|
||||||
|
def test_bindspace_pins_local_capability(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A live bindspace pins one exact FD and realized ref.
|
||||||
|
|
||||||
|
Open a stand-in platform FD, record its inode in the realized
|
||||||
|
ref and construct an owned capability. Prove the generic
|
||||||
|
msgspec struct retains that exact local state. Its ability to
|
||||||
|
encode ordinary fields is not authority to transfer the
|
||||||
|
bindspace.
|
||||||
|
|
||||||
|
'''
|
||||||
|
token_path: Path = tmp_path / 'bindspace'
|
||||||
|
token_path.touch()
|
||||||
|
namespace_file: BinaryIO
|
||||||
|
with token_path.open('rb') as namespace_file:
|
||||||
|
namespace_fd: int = namespace_file.fileno()
|
||||||
|
inode: int = token_path.stat().st_ino
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
lifecycle='open',
|
||||||
|
)
|
||||||
|
ref: BindspaceRef = BindspaceRef(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
inode=inode,
|
||||||
|
)
|
||||||
|
bindspace: Bindspace = Bindspace(
|
||||||
|
spec=spec,
|
||||||
|
ref=ref,
|
||||||
|
namespace_fd=namespace_fd,
|
||||||
|
ownership='owned',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert bindspace.spec is spec
|
||||||
|
assert bindspace.ref is ref
|
||||||
|
assert bindspace.namespace_fd == namespace_file.fileno()
|
||||||
|
assert bindspace.ownership == 'owned'
|
||||||
|
assert isinstance(bindspace, msgspec.Struct)
|
||||||
|
assert isinstance(bindspace, ProcessLocal)
|
||||||
|
with pytest.raises(
|
||||||
|
TypeError,
|
||||||
|
match='_ProcessLocalToken.*unsupported',
|
||||||
|
):
|
||||||
|
msgspec.msgpack.encode(bindspace)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bindspace_rejects_mismatched_ref(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A name or inode mismatch would make a bindspace stale authority.
|
||||||
|
|
||||||
|
Construct a requested named spec, then prove both a different
|
||||||
|
realized name and an inode not belonging to the supplied FD are
|
||||||
|
rejected before either can become a live capability.
|
||||||
|
|
||||||
|
'''
|
||||||
|
token_path: Path = tmp_path / 'bindspace'
|
||||||
|
token_path.touch()
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
)
|
||||||
|
# Keep ownership and FD fixed so only the ref changes below.
|
||||||
|
ownership: BindspaceOwnership = 'borrowed'
|
||||||
|
namespace_file: BinaryIO
|
||||||
|
with token_path.open('rb') as namespace_file:
|
||||||
|
namespace_fd: int = namespace_file.fileno()
|
||||||
|
wrong_name: BindspaceRef = BindspaceRef(
|
||||||
|
kind='netns',
|
||||||
|
key='other-wg',
|
||||||
|
inode=token_path.stat().st_ino,
|
||||||
|
)
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match='Spec.key.*Ref.key',
|
||||||
|
):
|
||||||
|
Bindspace(
|
||||||
|
spec=spec,
|
||||||
|
ref=wrong_name,
|
||||||
|
namespace_fd=namespace_fd,
|
||||||
|
ownership=ownership,
|
||||||
|
)
|
||||||
|
|
||||||
|
wrong_inode: BindspaceRef = BindspaceRef(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
inode=token_path.stat().st_ino + 1,
|
||||||
|
)
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match='FD inode.*reference inode',
|
||||||
|
):
|
||||||
|
Bindspace(
|
||||||
|
spec=spec,
|
||||||
|
ref=wrong_inode,
|
||||||
|
namespace_fd=namespace_fd,
|
||||||
|
ownership=ownership,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('model', 'kwargs', 'match'),
|
||||||
|
(
|
||||||
|
pytest.param(
|
||||||
|
BindspaceRef,
|
||||||
|
{
|
||||||
|
'kind': 'netns',
|
||||||
|
'key': None,
|
||||||
|
'inode': None,
|
||||||
|
},
|
||||||
|
'must be a positive `int`',
|
||||||
|
id='ref-requires-inode',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
BindspaceSpec,
|
||||||
|
{'kind': 'vrf'},
|
||||||
|
'Unsupported bindspace kind',
|
||||||
|
id='spec-rejects-kind',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
BindspaceRef,
|
||||||
|
{
|
||||||
|
'kind': 'vrf',
|
||||||
|
'key': 'blue',
|
||||||
|
'inode': 1234,
|
||||||
|
},
|
||||||
|
'Unsupported bindspace kind',
|
||||||
|
id='ref-rejects-kind',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
BindspaceSpec,
|
||||||
|
{
|
||||||
|
'kind': 'netns',
|
||||||
|
'key': '../outside',
|
||||||
|
},
|
||||||
|
'Invalid netns name',
|
||||||
|
id='spec-rejects-path',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
BindspaceSpec,
|
||||||
|
{
|
||||||
|
'kind': 'netns',
|
||||||
|
'key': '',
|
||||||
|
},
|
||||||
|
'BindspaceSpec.key',
|
||||||
|
id='spec-rejects-empty-key',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
BindspaceSpec,
|
||||||
|
{
|
||||||
|
'kind': 'netns',
|
||||||
|
'key': 'tractor-wg0',
|
||||||
|
'lifecycle': 'replace',
|
||||||
|
},
|
||||||
|
'Unsupported bindspace lifecycle',
|
||||||
|
id='spec-rejects-lifecycle',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
BindspaceRef,
|
||||||
|
{
|
||||||
|
'kind': 'netns',
|
||||||
|
'key': '',
|
||||||
|
'inode': 1234,
|
||||||
|
},
|
||||||
|
'BindspaceRef.key',
|
||||||
|
id='ref-rejects-empty-key',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_bindspace_models_reject_invalid_values(
|
||||||
|
model: type[BindspaceSpec]|type[BindspaceRef],
|
||||||
|
kwargs: dict[str, object],
|
||||||
|
match: str,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Direct msgspec construction does not enforce field annotations.
|
||||||
|
|
||||||
|
Parameterize the missing stable inode and future, unimplemented
|
||||||
|
kinds. Prove neither serializable model can carry invalid
|
||||||
|
refs or provisioning instructions into spawn configuration.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with pytest.raises(ValueError, match=match):
|
||||||
|
model(**kwargs) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='Linux netns API',
|
||||||
|
)
|
||||||
|
def test_open_bindspace_attaches_current_netns() -> None:
|
||||||
|
'''
|
||||||
|
The unnamed spec must borrow and pin the caller's current netns.
|
||||||
|
|
||||||
|
Opening `/proc/self/ns/net` could pin the thread-group leader's
|
||||||
|
namespace when this context runs from another thread. Prove the
|
||||||
|
implementation selects `/proc/thread-self/ns/net`, records the
|
||||||
|
calling thread's stable inode and borrowed ownership, then closes
|
||||||
|
the exact descriptor without altering the namespace itself.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> int:
|
||||||
|
'''
|
||||||
|
Borrow the current netns and return its descriptor number.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
)
|
||||||
|
assert spec.key is CURRENT_NETNS
|
||||||
|
assert _bindspace._THREAD_NETNS == Path(
|
||||||
|
'/proc/thread-self/ns/net'
|
||||||
|
)
|
||||||
|
async with open_bindspace(spec) as bindspace:
|
||||||
|
namespace_fd: int|None = bindspace.namespace_fd
|
||||||
|
assert namespace_fd is not None
|
||||||
|
assert bindspace.spec is spec
|
||||||
|
assert bindspace.ref.key is None
|
||||||
|
assert bindspace.ref.inode == os.fstat(
|
||||||
|
namespace_fd
|
||||||
|
).st_ino
|
||||||
|
assert bindspace.ownership == 'borrowed'
|
||||||
|
return namespace_fd
|
||||||
|
|
||||||
|
namespace_fd: int = trio.run(main)
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
os.fstat(namespace_fd)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='Linux netns API',
|
||||||
|
)
|
||||||
|
def test_attach_named_netns_uses_run_directory(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A named spec must resolve only beneath the configured netns dir.
|
||||||
|
|
||||||
|
Replace the run directory with a temporary stand-in, borrow its
|
||||||
|
named inode, and prove the context neither deletes the existing
|
||||||
|
resource nor leaves its descriptor open after exit.
|
||||||
|
|
||||||
|
'''
|
||||||
|
netns_path: Path = tmp_path / 'tractor-wg0'
|
||||||
|
netns_path.touch()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_NETNS_RUN_DIR',
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
'''
|
||||||
|
Borrow the named stand-in and return its descriptor number.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
)
|
||||||
|
async with attach_netns(spec) as bindspace:
|
||||||
|
namespace_fd: int|None = bindspace.namespace_fd
|
||||||
|
assert namespace_fd is not None
|
||||||
|
assert bindspace.ref.key == 'tractor-wg0'
|
||||||
|
assert bindspace.ref.inode == netns_path.stat().st_ino
|
||||||
|
assert bindspace.ownership == 'borrowed'
|
||||||
|
return namespace_fd
|
||||||
|
|
||||||
|
namespace_fd: int = trio.run(main)
|
||||||
|
assert netns_path.exists()
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
os.fstat(namespace_fd)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='Linux netns API',
|
||||||
|
)
|
||||||
|
def test_attach_named_netns_never_creates(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Borrow-only lookup must fail without creating a missing resource.
|
||||||
|
|
||||||
|
Point the run directory at an empty location, request one named
|
||||||
|
netns, and prove the open error propagates while no path appears.
|
||||||
|
|
||||||
|
'''
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_NETNS_RUN_DIR',
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
missing_path: Path = tmp_path / 'missing'
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Attempt to borrow one absent named netns.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='missing',
|
||||||
|
)
|
||||||
|
async with attach_netns(spec):
|
||||||
|
raise AssertionError('Missing netns unexpectedly opened')
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
trio.run(main)
|
||||||
|
assert not missing_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='Linux netns API',
|
||||||
|
)
|
||||||
|
def test_open_netns_owns_lifecycle(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Successful creation must yield ownership and remove on exit.
|
||||||
|
|
||||||
|
Fake pyroute2 creation with a named stand-in file, verify the
|
||||||
|
yielded FD and ref while it exists, then prove FD closure
|
||||||
|
precedes resource removal when the context exits.
|
||||||
|
|
||||||
|
'''
|
||||||
|
events: list[str] = []
|
||||||
|
namespace_fds: list[int] = []
|
||||||
|
netns_path: Path = tmp_path / 'tractor-wg0'
|
||||||
|
|
||||||
|
def create(key: str) -> None:
|
||||||
|
'''
|
||||||
|
Create the named stand-in and record lifecycle order.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert key == 'tractor-wg0'
|
||||||
|
netns_path.touch()
|
||||||
|
events.append('create')
|
||||||
|
|
||||||
|
def remove(key: str) -> None:
|
||||||
|
'''
|
||||||
|
Remove the stand-in after its FD has closed.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert key == 'tractor-wg0'
|
||||||
|
events.append('fd-closed')
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
os.fstat(namespace_fds[0])
|
||||||
|
netns_path.unlink()
|
||||||
|
events.append('remove')
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_NETNS_RUN_DIR',
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_create_netns',
|
||||||
|
create,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_remove_netns',
|
||||||
|
remove,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Open the fake netns and publish its live descriptor number.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
lifecycle='open',
|
||||||
|
)
|
||||||
|
async with open_bindspace(spec) as bindspace:
|
||||||
|
fd: int|None = bindspace.namespace_fd
|
||||||
|
assert fd is not None
|
||||||
|
assert bindspace.ownership == 'owned'
|
||||||
|
assert bindspace.ref.inode == os.fstat(fd).st_ino
|
||||||
|
namespace_fds.append(fd)
|
||||||
|
events.append('yield')
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
assert events == [
|
||||||
|
'create',
|
||||||
|
'yield',
|
||||||
|
'fd-closed',
|
||||||
|
'remove',
|
||||||
|
]
|
||||||
|
assert not netns_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='Linux netns API',
|
||||||
|
)
|
||||||
|
def test_open_netns_shields_cancelled_cleanup(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Cancellation after creation must not leak an owned namespace.
|
||||||
|
|
||||||
|
Cancel the caller inside the yielded context and checkpoint.
|
||||||
|
Prove shielded teardown still removes the stand-in before
|
||||||
|
cancellation leaves the enclosing scope.
|
||||||
|
|
||||||
|
'''
|
||||||
|
netns_path: Path = tmp_path / 'tractor-wg0'
|
||||||
|
removed: list[str] = []
|
||||||
|
|
||||||
|
def create(key: str) -> None:
|
||||||
|
'''
|
||||||
|
Create the named stand-in before cancellation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
netns_path.touch()
|
||||||
|
|
||||||
|
def remove(key: str) -> None:
|
||||||
|
'''
|
||||||
|
Remove the stand-in despite caller cancellation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
netns_path.unlink()
|
||||||
|
removed.append(key)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_NETNS_RUN_DIR',
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_create_netns',
|
||||||
|
create,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_bindspace,
|
||||||
|
'_remove_netns',
|
||||||
|
remove,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Cancel while borrowing the newly owned namespace.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
lifecycle='open',
|
||||||
|
)
|
||||||
|
with trio.CancelScope() as scope:
|
||||||
|
async with open_netns(spec):
|
||||||
|
scope.cancel()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
assert removed == ['tractor-wg0']
|
||||||
|
assert not netns_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='Linux netns API',
|
||||||
|
)
|
||||||
|
def test_open_netns_requires_name() -> None:
|
||||||
|
'''
|
||||||
|
Creation cannot target the caller's current netns.
|
||||||
|
|
||||||
|
Pass `CURRENT_NETNS` and prove validation rejects it before any
|
||||||
|
privileged pyroute2 operation can run.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key=CURRENT_NETNS,
|
||||||
|
lifecycle='open',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Attempt to create the unnamed current namespace.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with open_netns(spec):
|
||||||
|
raise AssertionError(
|
||||||
|
'Current netns unexpectedly created'
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match='requires a named',
|
||||||
|
):
|
||||||
|
trio.run(main)
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'''
|
'''
|
||||||
Multiaddr construction, parsing, and round-trip tests for
|
Multiaddr construction, parsing, and round-trip tests for
|
||||||
`tractor.discovery._multiaddr.mk_maddr()` and
|
`tractor.net.mk_maddr()` and `tractor.net.parse_maddr()`.
|
||||||
`tractor.discovery._multiaddr.parse_maddr()`.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -10,18 +9,39 @@ from types import SimpleNamespace
|
||||||
import pytest
|
import pytest
|
||||||
from multiaddr import Multiaddr
|
from multiaddr import Multiaddr
|
||||||
|
|
||||||
|
from tractor.net import (
|
||||||
|
TunnelledAddress,
|
||||||
|
WGTunnelSpec,
|
||||||
|
mb_pubkey,
|
||||||
|
mk_wg_maddr,
|
||||||
|
mk_maddr,
|
||||||
|
parse_endpoints,
|
||||||
|
parse_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 (
|
||||||
mk_maddr,
|
|
||||||
parse_maddr,
|
|
||||||
parse_endpoints,
|
|
||||||
_tpt_proto_to_maddr,
|
_tpt_proto_to_maddr,
|
||||||
_maddr_to_tpt_proto,
|
_maddr_to_tpt_proto,
|
||||||
)
|
)
|
||||||
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 +187,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 +199,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 +212,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 +231,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 +445,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 +481,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 +505,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 +551,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 +571,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():
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
'''
|
||||||
|
`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.net import (
|
||||||
|
BindspaceRef,
|
||||||
|
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
|
||||||
|
from tractor.ipc._uds import UDSAddress
|
||||||
|
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Plain transport addresses explicitly select no namespace, while
|
||||||
|
a tunnel can select one for the same concrete overlay.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uds_addr: UDSAddress = UDSAddress('/tmp', 'tractor-test.sock')
|
||||||
|
assert overlay.namespace is None
|
||||||
|
assert uds_addr.namespace is None
|
||||||
|
|
||||||
|
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_realized_namespace_uses_stable_ref(
|
||||||
|
overlay: TCPAddress,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Realization must retain a stable ref without mutating the maddr.
|
||||||
|
|
||||||
|
Build an unrealized named declaration, annotate it with the
|
||||||
|
matching realized key and inode, and prove the frozen original is
|
||||||
|
unchanged. The annotated copy must preserve transport delegation
|
||||||
|
and expose the stable inode through `.namespace`. Direct msgspec
|
||||||
|
encoding also proves only serializable ref metadata was retained.
|
||||||
|
|
||||||
|
'''
|
||||||
|
declared: TunnelledAddress = TunnelledAddress(
|
||||||
|
overlay=overlay,
|
||||||
|
tunnel=WGTunnelSpec(
|
||||||
|
peer_pubkey=_PUBKEY,
|
||||||
|
netns='wg-test',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ref: BindspaceRef = BindspaceRef(
|
||||||
|
kind='netns',
|
||||||
|
key='wg-test',
|
||||||
|
inode=1234,
|
||||||
|
)
|
||||||
|
realized: TunnelledAddress = declared.with_bindspace_ref(
|
||||||
|
ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert declared.bindspace_ref is None
|
||||||
|
assert realized.bindspace_ref is ref
|
||||||
|
assert realized.namespace == ('netns', 1234)
|
||||||
|
assert realized.overlay is declared.overlay
|
||||||
|
assert realized.tunnel is declared.tunnel
|
||||||
|
assert realized.unwrap() == declared.unwrap()
|
||||||
|
assert realized.bindspace == declared.bindspace
|
||||||
|
|
||||||
|
declared_payload: dict[str, object] = msgspec.msgpack.decode(
|
||||||
|
msgspec.msgpack.encode(declared)
|
||||||
|
)
|
||||||
|
assert 'bindspace_ref' not in declared_payload
|
||||||
|
|
||||||
|
decoded: dict[str, object] = msgspec.msgpack.decode(
|
||||||
|
msgspec.msgpack.encode(realized)
|
||||||
|
)
|
||||||
|
assert decoded['bindspace_ref'] == {
|
||||||
|
'kind': 'netns',
|
||||||
|
'key': 'wg-test',
|
||||||
|
'inode': 1234,
|
||||||
|
}
|
||||||
|
|
||||||
|
mismatched: BindspaceRef = BindspaceRef(
|
||||||
|
kind='netns',
|
||||||
|
key='other-netns',
|
||||||
|
inode=5678,
|
||||||
|
)
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match='wg-test.*other-netns',
|
||||||
|
):
|
||||||
|
declared.with_bindspace_ref(
|
||||||
|
mismatched,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
'''
|
||||||
|
Process-local WireGuard interface configuration contracts.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tractor.net import (
|
||||||
|
WGInterfaceConfig,
|
||||||
|
WGPeerConfig,
|
||||||
|
)
|
||||||
|
from tractor.msg import ProcessLocal
|
||||||
|
|
||||||
|
|
||||||
|
_PRIVATE_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
||||||
|
_PEER_KEY: str = 'r1LKM1pqhuY9Z6L4y5jQ2fGX67kJSrq5kRV5Jk2ywEo='
|
||||||
|
_PRESHARED_KEY: str = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA='
|
||||||
|
|
||||||
|
|
||||||
|
def test_wg_config_is_process_local_and_redacted() -> None:
|
||||||
|
'''
|
||||||
|
Private WireGuard configuration must neither print nor cross IPC.
|
||||||
|
|
||||||
|
Construct a complete local config and prove its public routing
|
||||||
|
policy remains inspectable while both keys are absent from repr.
|
||||||
|
Verify `ProcessLocal` blocks default msgpack encoding.
|
||||||
|
|
||||||
|
'''
|
||||||
|
peer: WGPeerConfig = WGPeerConfig(
|
||||||
|
public_key=_PEER_KEY,
|
||||||
|
allowed_ips=('10.1.0.0/16', 'fd01::/64'),
|
||||||
|
endpoint=('192.0.2.1', 51820),
|
||||||
|
preshared_key=_PRESHARED_KEY,
|
||||||
|
persistent_keepalive=25,
|
||||||
|
)
|
||||||
|
config: WGInterfaceConfig = WGInterfaceConfig(
|
||||||
|
private_key=_PRIVATE_KEY,
|
||||||
|
addresses=('10.0.0.1/24', 'fd00::1/64'),
|
||||||
|
listen_port=51820,
|
||||||
|
peers=(peer,),
|
||||||
|
)
|
||||||
|
|
||||||
|
config_repr: str = repr(config)
|
||||||
|
assert isinstance(config, ProcessLocal)
|
||||||
|
assert _PRIVATE_KEY not in config_repr
|
||||||
|
assert _PRESHARED_KEY not in config_repr
|
||||||
|
assert config.addresses[0] in config_repr
|
||||||
|
assert peer.allowed_ips[0] in config_repr
|
||||||
|
with pytest.raises(
|
||||||
|
TypeError,
|
||||||
|
match='_ProcessLocalToken.*unsupported',
|
||||||
|
):
|
||||||
|
msgspec.msgpack.encode(config)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('kwargs', 'error'),
|
||||||
|
(
|
||||||
|
pytest.param(
|
||||||
|
{'private_key': 'not-base64'},
|
||||||
|
ValueError,
|
||||||
|
id='private-key',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{
|
||||||
|
'private_key': _PRIVATE_KEY,
|
||||||
|
'addresses': ('not-an-interface',),
|
||||||
|
},
|
||||||
|
ValueError,
|
||||||
|
id='address',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{
|
||||||
|
'private_key': _PRIVATE_KEY,
|
||||||
|
'listen_port': 65536,
|
||||||
|
},
|
||||||
|
ValueError,
|
||||||
|
id='listen-port',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{
|
||||||
|
'private_key': _PRIVATE_KEY,
|
||||||
|
'peers': (
|
||||||
|
WGPeerConfig(public_key=_PEER_KEY),
|
||||||
|
WGPeerConfig(public_key=_PEER_KEY),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
ValueError,
|
||||||
|
id='duplicate-peer',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_wg_config_rejects_invalid_values(
|
||||||
|
kwargs: dict[str, object],
|
||||||
|
error: type[Exception],
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Invalid config must fail before kernel mutation.
|
||||||
|
|
||||||
|
Parameterize every validated input class and prove direct msgspec
|
||||||
|
construction cannot carry malformed configuration into a future
|
||||||
|
pyroute2 interface lifecycle.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with pytest.raises(error):
|
||||||
|
WGInterfaceConfig(**kwargs) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'kwargs',
|
||||||
|
(
|
||||||
|
pytest.param(
|
||||||
|
{'public_key': 'not-base64'},
|
||||||
|
id='public-key',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{
|
||||||
|
'public_key': _PEER_KEY,
|
||||||
|
'preshared_key': 'not-base64',
|
||||||
|
},
|
||||||
|
id='preshared-key',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{
|
||||||
|
'public_key': _PEER_KEY,
|
||||||
|
'allowed_ips': ('not-a-network',),
|
||||||
|
},
|
||||||
|
id='allowed-ip',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{
|
||||||
|
'public_key': _PEER_KEY,
|
||||||
|
'endpoint': ('not-an-ip', 51820),
|
||||||
|
},
|
||||||
|
id='endpoint-host',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{
|
||||||
|
'public_key': _PEER_KEY,
|
||||||
|
'endpoint': ('192.0.2.1', 65536),
|
||||||
|
},
|
||||||
|
id='endpoint-port',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{
|
||||||
|
'public_key': _PEER_KEY,
|
||||||
|
'persistent_keepalive': -1,
|
||||||
|
},
|
||||||
|
id='keepalive',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_wg_peer_config_rejects_invalid_values(
|
||||||
|
kwargs: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Invalid peer policy must fail before kernel mutation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
WGPeerConfig(**kwargs) # type: ignore[arg-type]
|
||||||
|
|
@ -0,0 +1,476 @@
|
||||||
|
'''
|
||||||
|
WireGuard interface policy and owned lifecycle contracts.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager as acm
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from typing import BinaryIO
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
|
||||||
|
import tractor
|
||||||
|
from tractor.net import (
|
||||||
|
Bindspace,
|
||||||
|
BindspaceRef,
|
||||||
|
BindspaceSpec,
|
||||||
|
WGInterfaceConfig,
|
||||||
|
WGPeerConfig,
|
||||||
|
WGTunnelSpec,
|
||||||
|
open_wg_bindspace,
|
||||||
|
open_wg_iface,
|
||||||
|
)
|
||||||
|
from tractor.net import _tunnel
|
||||||
|
|
||||||
|
|
||||||
|
_LOCAL_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
||||||
|
_PEER_KEY: str = 'r1LKM1pqhuY9Z6L4y5jQ2fGX67kJSrq5kRV5Jk2ywEo='
|
||||||
|
|
||||||
|
|
||||||
|
def test_wg_iface_settings_follow_role() -> None:
|
||||||
|
'''
|
||||||
|
Listen and dial roles interpret the tunnel bearer differently.
|
||||||
|
|
||||||
|
Prove listen derives its local port from the bearer while dial
|
||||||
|
applies the bearer as the selected peer's omitted endpoint. Other
|
||||||
|
explicit peers retain their own endpoint and routing policy.
|
||||||
|
|
||||||
|
'''
|
||||||
|
selected: WGPeerConfig = WGPeerConfig(
|
||||||
|
public_key=_PEER_KEY,
|
||||||
|
allowed_ips=('10.1.0.0/16',),
|
||||||
|
)
|
||||||
|
other: WGPeerConfig = WGPeerConfig(
|
||||||
|
public_key=_LOCAL_KEY,
|
||||||
|
endpoint=('198.51.100.2', 51821),
|
||||||
|
)
|
||||||
|
config: WGInterfaceConfig = WGInterfaceConfig(
|
||||||
|
private_key=_LOCAL_KEY,
|
||||||
|
peers=(selected, other),
|
||||||
|
)
|
||||||
|
spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
peer_pubkey=_PEER_KEY,
|
||||||
|
bearer=('192.0.2.1', 51820),
|
||||||
|
)
|
||||||
|
|
||||||
|
listen_port: int|None
|
||||||
|
listen_peers: tuple[dict[str, object], ...]
|
||||||
|
listen_port, listen_peers = _tunnel._wg_iface_settings(
|
||||||
|
spec,
|
||||||
|
config,
|
||||||
|
'listen',
|
||||||
|
)
|
||||||
|
assert listen_port == 51820
|
||||||
|
# A listen bearer configures the local port, not a peer endpoint.
|
||||||
|
assert 'endpoint_addr' not in listen_peers[0]
|
||||||
|
|
||||||
|
dial_port: int|None
|
||||||
|
dial_peers: tuple[dict[str, object], ...]
|
||||||
|
dial_port, dial_peers = _tunnel._wg_iface_settings(
|
||||||
|
spec,
|
||||||
|
config,
|
||||||
|
'dial',
|
||||||
|
)
|
||||||
|
# No local dial listen port was declared; the bearer is remote.
|
||||||
|
assert dial_port is None
|
||||||
|
assert dial_peers[0]['endpoint_addr'] == '192.0.2.1'
|
||||||
|
assert dial_peers[0]['endpoint_port'] == 51820
|
||||||
|
assert dial_peers[0]['allowed_ips'] == ['10.1.0.0/16']
|
||||||
|
assert dial_peers[1]['endpoint_addr'] == '198.51.100.2'
|
||||||
|
assert dial_peers[1]['endpoint_port'] == 51821
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('config', 'role', 'match'),
|
||||||
|
(
|
||||||
|
pytest.param(
|
||||||
|
WGInterfaceConfig(
|
||||||
|
private_key=_LOCAL_KEY,
|
||||||
|
),
|
||||||
|
'dial',
|
||||||
|
'not in.*configured peer keys',
|
||||||
|
id='missing-dial-peer',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
WGInterfaceConfig(
|
||||||
|
private_key=_LOCAL_KEY,
|
||||||
|
listen_port=51821,
|
||||||
|
),
|
||||||
|
'listen',
|
||||||
|
'51821.*51820',
|
||||||
|
id='listen-port-51821-vs-bearer-51820',
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
WGInterfaceConfig(
|
||||||
|
private_key=_LOCAL_KEY,
|
||||||
|
peers=(
|
||||||
|
WGPeerConfig(
|
||||||
|
public_key=_PEER_KEY,
|
||||||
|
endpoint=('198.51.100.1', 51820),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'dial',
|
||||||
|
'198.51.100.1.*192.0.2.1',
|
||||||
|
id='dial-endpoint-conflict',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_wg_iface_settings_reject_conflicts(
|
||||||
|
config: WGInterfaceConfig,
|
||||||
|
role: str,
|
||||||
|
match: str,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Role-dependent conflicts must fail before pyroute2 side effects.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
peer_pubkey=_PEER_KEY,
|
||||||
|
bearer=('192.0.2.1', 51820),
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match=match):
|
||||||
|
_tunnel._wg_iface_settings(
|
||||||
|
spec,
|
||||||
|
config,
|
||||||
|
role, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_wg_iface_shields_cancelled_cleanup(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Cancellation after creation must still remove the owned WG iface.
|
||||||
|
|
||||||
|
Pin a stand-in namespace FD and fake privileged create/remove
|
||||||
|
calls. Cancel inside the yielded context and prove shielded
|
||||||
|
teardown runs before cancellation leaves the enclosing scope.
|
||||||
|
|
||||||
|
'''
|
||||||
|
token_path: Path = tmp_path / 'netns'
|
||||||
|
token_path.touch()
|
||||||
|
events: list[str] = []
|
||||||
|
|
||||||
|
def create(
|
||||||
|
spec: WGTunnelSpec,
|
||||||
|
config: WGInterfaceConfig,
|
||||||
|
bindspace: Bindspace,
|
||||||
|
listen_port: int|None,
|
||||||
|
peers: tuple[dict[str, object], ...],
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Record the validated create request.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert bindspace.namespace_fd is not None
|
||||||
|
assert listen_port is None
|
||||||
|
assert peers[0]['public_key'] == _PEER_KEY
|
||||||
|
events.append('create')
|
||||||
|
|
||||||
|
def remove(
|
||||||
|
spec: WGTunnelSpec,
|
||||||
|
bindspace: Bindspace,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Record shielded removal after cancellation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
events.append('remove')
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_tunnel,
|
||||||
|
'_sync_create_wg_iface',
|
||||||
|
create,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_tunnel,
|
||||||
|
'_sync_remove_wg_iface',
|
||||||
|
remove,
|
||||||
|
)
|
||||||
|
|
||||||
|
namespace_file: BinaryIO
|
||||||
|
with token_path.open('rb') as namespace_file:
|
||||||
|
namespace_fd: int = namespace_file.fileno()
|
||||||
|
bindspace_spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
)
|
||||||
|
bindspace: Bindspace = Bindspace(
|
||||||
|
spec=bindspace_spec,
|
||||||
|
ref=BindspaceRef(
|
||||||
|
kind='netns',
|
||||||
|
key='tractor-wg0',
|
||||||
|
inode=os.fstat(namespace_fd).st_ino,
|
||||||
|
),
|
||||||
|
namespace_fd=namespace_fd,
|
||||||
|
ownership='borrowed',
|
||||||
|
)
|
||||||
|
peer: WGPeerConfig = WGPeerConfig(
|
||||||
|
public_key=_PEER_KEY,
|
||||||
|
)
|
||||||
|
config: WGInterfaceConfig = WGInterfaceConfig(
|
||||||
|
private_key=_LOCAL_KEY,
|
||||||
|
peers=(peer,),
|
||||||
|
)
|
||||||
|
spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
peer_pubkey=_PEER_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Cancel while the fake WG iface is owned.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with trio.CancelScope() as scope:
|
||||||
|
async with open_wg_iface(
|
||||||
|
spec,
|
||||||
|
config,
|
||||||
|
bindspace,
|
||||||
|
'dial',
|
||||||
|
):
|
||||||
|
events.append('yield')
|
||||||
|
scope.cancel()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
assert events == ['create', 'yield', 'remove']
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_wg_bindspace_nests_resource_lifetimes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Nested WG interfaces must exit before their bindspace capability.
|
||||||
|
|
||||||
|
Fake two interface layers over one bindspace. Clear the caller's
|
||||||
|
mutable layer list at bindspace entry, then cancel from inside
|
||||||
|
the yielded application scope and checkpoint. The trace proves
|
||||||
|
stack snapshots its declaration before entry, layers enter
|
||||||
|
outermost-first, cancellation exits them inside-out, and the live
|
||||||
|
bindspace remains available through every interface exit.
|
||||||
|
|
||||||
|
'''
|
||||||
|
events: list[str] = []
|
||||||
|
calls: list[
|
||||||
|
tuple[
|
||||||
|
WGTunnelSpec,
|
||||||
|
WGInterfaceConfig,
|
||||||
|
Bindspace,
|
||||||
|
_tunnel.WGRole,
|
||||||
|
]
|
||||||
|
] = []
|
||||||
|
bindspace_spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
)
|
||||||
|
bindspace: Bindspace = Bindspace(
|
||||||
|
spec=bindspace_spec,
|
||||||
|
ref=BindspaceRef(
|
||||||
|
kind='netns',
|
||||||
|
key=None,
|
||||||
|
inode=1,
|
||||||
|
),
|
||||||
|
namespace_fd=None,
|
||||||
|
ownership='borrowed',
|
||||||
|
)
|
||||||
|
outer_spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
peer_pubkey=_PEER_KEY,
|
||||||
|
iface='wg-outer',
|
||||||
|
)
|
||||||
|
inner_spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
peer_pubkey=_LOCAL_KEY,
|
||||||
|
iface='wg-inner',
|
||||||
|
)
|
||||||
|
outer_config: WGInterfaceConfig = WGInterfaceConfig(
|
||||||
|
private_key=_LOCAL_KEY,
|
||||||
|
)
|
||||||
|
inner_config: WGInterfaceConfig = WGInterfaceConfig(
|
||||||
|
private_key=_PEER_KEY,
|
||||||
|
)
|
||||||
|
layers: list[
|
||||||
|
tuple[WGTunnelSpec, WGInterfaceConfig]
|
||||||
|
] = [
|
||||||
|
(outer_spec, outer_config),
|
||||||
|
(inner_spec, inner_config),
|
||||||
|
]
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def fake_open_bindspace(
|
||||||
|
spec: BindspaceSpec,
|
||||||
|
) -> AsyncIterator[Bindspace]:
|
||||||
|
'''
|
||||||
|
Yield the stand-in bindspace and record its full lifetime.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert spec is bindspace_spec
|
||||||
|
events.append('bindspace-enter')
|
||||||
|
layers.clear()
|
||||||
|
try:
|
||||||
|
yield bindspace
|
||||||
|
finally:
|
||||||
|
events.append('bindspace-exit')
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def fake_open_wg_iface(
|
||||||
|
spec: WGTunnelSpec,
|
||||||
|
config: WGInterfaceConfig,
|
||||||
|
bindspace_arg: Bindspace,
|
||||||
|
role: _tunnel.WGRole,
|
||||||
|
) -> AsyncIterator[WGTunnelSpec]:
|
||||||
|
'''
|
||||||
|
Record one interface's arguments and nested lifetime.
|
||||||
|
|
||||||
|
'''
|
||||||
|
calls.append((spec, config, bindspace_arg, role))
|
||||||
|
events.append(f'{spec.iface}-enter')
|
||||||
|
try:
|
||||||
|
yield spec
|
||||||
|
finally:
|
||||||
|
assert bindspace_arg is bindspace
|
||||||
|
events.append(f'{spec.iface}-exit')
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_tunnel,
|
||||||
|
'open_bindspace',
|
||||||
|
fake_open_bindspace,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_tunnel,
|
||||||
|
'open_wg_iface',
|
||||||
|
fake_open_wg_iface,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Cancel while both interface layers are live.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with trio.CancelScope() as scope:
|
||||||
|
async with open_wg_bindspace(
|
||||||
|
bindspace_spec,
|
||||||
|
layers,
|
||||||
|
'dial',
|
||||||
|
) as opened_bindspace:
|
||||||
|
assert opened_bindspace is bindspace
|
||||||
|
events.append('yield')
|
||||||
|
scope.cancel()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
assert calls == [
|
||||||
|
(outer_spec, outer_config, bindspace, 'dial'),
|
||||||
|
(inner_spec, inner_config, bindspace, 'dial'),
|
||||||
|
]
|
||||||
|
assert events == [
|
||||||
|
'bindspace-enter',
|
||||||
|
'wg-outer-enter',
|
||||||
|
'wg-inner-enter',
|
||||||
|
'yield',
|
||||||
|
'wg-inner-exit',
|
||||||
|
'wg-outer-exit',
|
||||||
|
'bindspace-exit',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
sys.platform != 'linux',
|
||||||
|
reason='network namespaces are Linux-only',
|
||||||
|
)
|
||||||
|
def test_public_wg_bindspace_scopes_root_actor(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tpt_proto: str,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Public network contexts must fully enclose the root runtime.
|
||||||
|
|
||||||
|
Attach the real current netns through `tractor.net`, fake only WG
|
||||||
|
interface provisioning, and open a real root actor with the yielded
|
||||||
|
`Bindspace`. The trace and inode checks prove interface setup wraps
|
||||||
|
actor startup, the runtime occupies the realized bindspace, and root
|
||||||
|
restoration finishes before network-resource teardown.
|
||||||
|
|
||||||
|
'''
|
||||||
|
events: list[str] = []
|
||||||
|
bindspace_spec: BindspaceSpec = BindspaceSpec(
|
||||||
|
kind='netns',
|
||||||
|
lifecycle='attach',
|
||||||
|
)
|
||||||
|
tunnel_spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
peer_pubkey=_PEER_KEY,
|
||||||
|
iface='wg-root',
|
||||||
|
)
|
||||||
|
config: WGInterfaceConfig = WGInterfaceConfig(
|
||||||
|
private_key=_LOCAL_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def fake_open_wg_iface(
|
||||||
|
spec: WGTunnelSpec,
|
||||||
|
iface_config: WGInterfaceConfig,
|
||||||
|
bindspace: Bindspace,
|
||||||
|
role: _tunnel.WGRole,
|
||||||
|
) -> AsyncIterator[WGTunnelSpec]:
|
||||||
|
'''
|
||||||
|
Trace one WG layer around the real root actor lifetime.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert spec is tunnel_spec
|
||||||
|
assert iface_config is config
|
||||||
|
assert role == 'listen'
|
||||||
|
assert bindspace.namespace_fd is not None
|
||||||
|
events.append('wg-enter')
|
||||||
|
try:
|
||||||
|
yield spec
|
||||||
|
finally:
|
||||||
|
events.append('wg-exit')
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_tunnel,
|
||||||
|
'open_wg_iface',
|
||||||
|
fake_open_wg_iface,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Compose the public network and root actor context managers.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with tractor.net.open_wg_bindspace(
|
||||||
|
bindspace_spec=bindspace_spec,
|
||||||
|
layers=((tunnel_spec, config),),
|
||||||
|
role='listen',
|
||||||
|
) as bindspace:
|
||||||
|
events.append('bindspace-open')
|
||||||
|
async with tractor.open_root_actor(
|
||||||
|
bindspace=bindspace,
|
||||||
|
enable_transports=[tpt_proto],
|
||||||
|
):
|
||||||
|
events.append('root-open')
|
||||||
|
assert bindspace.namespace_fd is not None
|
||||||
|
assert os.fstat(
|
||||||
|
bindspace.namespace_fd,
|
||||||
|
).st_ino == bindspace.ref.inode
|
||||||
|
assert Path(
|
||||||
|
'/proc/thread-self/ns/net'
|
||||||
|
).stat().st_ino == bindspace.ref.inode
|
||||||
|
events.append('root-closed')
|
||||||
|
|
||||||
|
events.append('bindspace-closed')
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
assert events == [
|
||||||
|
'wg-enter',
|
||||||
|
'bindspace-open',
|
||||||
|
'root-open',
|
||||||
|
'root-closed',
|
||||||
|
'wg-exit',
|
||||||
|
'bindspace-closed',
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
'''
|
||||||
|
Read-only WireGuard netlink inspection tests.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
NoReturn,
|
||||||
|
)
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
|
||||||
|
from tractor.net import (
|
||||||
|
read_wg_peers,
|
||||||
|
read_wg_pubkey,
|
||||||
|
verify_wg_peer,
|
||||||
|
WGTunnelSpec,
|
||||||
|
)
|
||||||
|
from tractor.net import _tunnel
|
||||||
|
|
||||||
|
pyroute2: Any = pytest.importorskip('pyroute2')
|
||||||
|
|
||||||
|
|
||||||
|
_PUBKEY: str = 'g3x7z0AdV1rM6UQU22CC7IL3/ivn4DzrE7ikDhCZ/Dc='
|
||||||
|
_PEER_1: str = '7PClzcj8o1yAjyPJb0zL2Gt0s2J7yZ6c0JXYqNBGr0E='
|
||||||
|
_PEER_2: str = 'H7bJbl1bpY7VzDlB5wI3KjA7JsiYoMWGDJd8dYgc5iw='
|
||||||
|
_MISSING_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
||||||
|
|
||||||
|
|
||||||
|
class Attrs:
|
||||||
|
'''
|
||||||
|
Minimal pyroute2 netlink-attribute message fake.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
**attrs: Any,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Store attributes for `.get_attr()` lookups.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self._attrs: dict[str, Any] = attrs
|
||||||
|
|
||||||
|
def get_attr(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
) -> Any:
|
||||||
|
'''
|
||||||
|
Return the named fake netlink attribute.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return self._attrs.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_wg_keys_in_worker_thread(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Pyroute2's synchronous `WireGuard` API owns a private asyncio
|
||||||
|
loop. Running it in the Trio thread would either block Trio or
|
||||||
|
introduce that foreign loop into the actor runtime.
|
||||||
|
|
||||||
|
Replace `pyroute2.WireGuard` with a fake which records thread,
|
||||||
|
iface, netns and close state. Return a multipart dump containing
|
||||||
|
duplicate peers, then prove both public helpers execute
|
||||||
|
off-thread, preserve named-netns selection, validate keys,
|
||||||
|
deduplicate peers in kernel order and close every netlink client.
|
||||||
|
|
||||||
|
'''
|
||||||
|
trio_thread: int = threading.get_ident()
|
||||||
|
|
||||||
|
class FakeWireGuard:
|
||||||
|
'''
|
||||||
|
Record each read-only `pyroute2.WireGuard` interaction.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
netns: str|None,
|
||||||
|
flags: int,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Record namespace selection without opening netlink.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self.netns = netns
|
||||||
|
self.flags = flags
|
||||||
|
self.closed = False
|
||||||
|
self.thread_id: int|None = None
|
||||||
|
self.iface: str|None = None
|
||||||
|
instances.append(self)
|
||||||
|
|
||||||
|
def info(
|
||||||
|
self,
|
||||||
|
iface: str,
|
||||||
|
) -> tuple[Attrs, Attrs]:
|
||||||
|
'''
|
||||||
|
Return a multipart WireGuard device dump.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self.thread_id = threading.get_ident()
|
||||||
|
self.iface = iface
|
||||||
|
peer_1: Attrs = Attrs(
|
||||||
|
WGPEER_A_PUBLIC_KEY=_PEER_1.encode(),
|
||||||
|
)
|
||||||
|
peer_2: Attrs = Attrs(
|
||||||
|
WGPEER_A_PUBLIC_KEY=_PEER_2.encode(),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
Attrs(
|
||||||
|
WGDEVICE_A_PUBLIC_KEY=_PUBKEY.encode(),
|
||||||
|
WGDEVICE_A_PEERS=[peer_1],
|
||||||
|
),
|
||||||
|
Attrs(
|
||||||
|
WGDEVICE_A_PUBLIC_KEY=_PUBKEY.encode(),
|
||||||
|
WGDEVICE_A_PEERS=[peer_2, peer_1],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
'''
|
||||||
|
Record netlink-client cleanup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
instances: list[FakeWireGuard] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pyroute2,
|
||||||
|
'WireGuard',
|
||||||
|
FakeWireGuard,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Read both key views from Trio's run thread.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert await read_wg_pubkey(
|
||||||
|
iface='wg-test',
|
||||||
|
netns='actor-net',
|
||||||
|
) == _PUBKEY
|
||||||
|
assert await read_wg_peers(
|
||||||
|
iface='wg-test',
|
||||||
|
netns='actor-net',
|
||||||
|
) == (_PEER_1, _PEER_2)
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
assert len(instances) == 2
|
||||||
|
instance: FakeWireGuard
|
||||||
|
for instance in instances:
|
||||||
|
assert instance.netns == 'actor-net'
|
||||||
|
assert instance.flags == 0
|
||||||
|
assert instance.iface == 'wg-test'
|
||||||
|
assert instance.thread_id != trio_thread
|
||||||
|
assert instance.closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_wg_client_closes_when_read_fails(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A failed netlink read must not leak pyroute2's socket or private
|
||||||
|
event loop. Raise from the fake `.info()` call and prove the same
|
||||||
|
error reaches the Trio caller only after `.close()` runs.
|
||||||
|
|
||||||
|
'''
|
||||||
|
class FakeWireGuard:
|
||||||
|
'''
|
||||||
|
Raise during device inspection and record cleanup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
netns: str|None,
|
||||||
|
flags: int,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Publish this fake instance for the cleanup assertion.
|
||||||
|
|
||||||
|
'''
|
||||||
|
nonlocal instance
|
||||||
|
self.closed = False
|
||||||
|
instance = self
|
||||||
|
|
||||||
|
def info(self, iface: str) -> NoReturn:
|
||||||
|
'''
|
||||||
|
Simulate a failing netlink device read.
|
||||||
|
|
||||||
|
'''
|
||||||
|
raise OSError('netlink read failed')
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
'''
|
||||||
|
Record cleanup after the failed read.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
instance: FakeWireGuard|None = None
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pyroute2,
|
||||||
|
'WireGuard',
|
||||||
|
FakeWireGuard,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
OSError,
|
||||||
|
match='netlink read failed',
|
||||||
|
):
|
||||||
|
trio.run(read_wg_pubkey)
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.closed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('declared_key', 'expected'),
|
||||||
|
(
|
||||||
|
(_PUBKEY, True),
|
||||||
|
(_PEER_2, True),
|
||||||
|
(_MISSING_KEY, False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_verify_wg_peer(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
declared_key: str,
|
||||||
|
expected: bool,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A tunnel declaration can identify either side of one local iface.
|
||||||
|
|
||||||
|
Return one stable key snapshot from the async reader, then prove
|
||||||
|
a local interface key and configured peer both verify while an
|
||||||
|
absent key does not. Also prove the spec selects the iface/netns
|
||||||
|
supplied to the read instead of silently using process defaults.
|
||||||
|
|
||||||
|
'''
|
||||||
|
reads: list[tuple[str, str|None]] = []
|
||||||
|
|
||||||
|
async def read_keys(
|
||||||
|
iface: str,
|
||||||
|
netns: str|None,
|
||||||
|
) -> tuple[str, tuple[str, ...]]:
|
||||||
|
'''
|
||||||
|
Return one deterministic WireGuard key snapshot.
|
||||||
|
|
||||||
|
'''
|
||||||
|
reads.append((iface, netns))
|
||||||
|
return _PUBKEY, (_PEER_1, _PEER_2)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_tunnel,
|
||||||
|
'_read_wg_keys',
|
||||||
|
read_keys,
|
||||||
|
)
|
||||||
|
spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
peer_pubkey=declared_key,
|
||||||
|
iface='wg-test',
|
||||||
|
netns='actor-net',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert trio.run(verify_wg_peer, spec) is expected
|
||||||
|
assert reads == [('wg-test', 'actor-net')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_wg_peer_validates_before_read(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A directly constructed tunnel spec can contain a malformed key.
|
||||||
|
|
||||||
|
Install a reader which would fail if called, pass malformed
|
||||||
|
base64, and prove validation rejects the declaration before any
|
||||||
|
kernel-state inspection occurs.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def unexpected_read(
|
||||||
|
iface: str,
|
||||||
|
netns: str|None,
|
||||||
|
) -> NoReturn:
|
||||||
|
'''
|
||||||
|
Fail if malformed-key validation reaches the read boundary.
|
||||||
|
|
||||||
|
'''
|
||||||
|
raise AssertionError('WireGuard read must not run')
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_tunnel,
|
||||||
|
'_read_wg_keys',
|
||||||
|
unexpected_read,
|
||||||
|
)
|
||||||
|
spec: WGTunnelSpec = WGTunnelSpec(
|
||||||
|
peer_pubkey='not-base64',
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
trio.run(verify_wg_peer, spec)
|
||||||
|
|
@ -12,6 +12,7 @@ from typing import (
|
||||||
get_type_hints,
|
get_type_hints,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
import tractor
|
||||||
from tractor.discovery import (
|
from tractor.discovery import (
|
||||||
_addr,
|
_addr,
|
||||||
_multiaddr,
|
_multiaddr,
|
||||||
|
|
@ -44,8 +45,8 @@ def test_lazy_to_asyncio_package_api():
|
||||||
Before the lazy conversion, package import side effects exposed
|
Before the lazy conversion, package import side effects exposed
|
||||||
`to_asyncio` to `dir()` and wildcard imports. Exercise those APIs
|
`to_asyncio` to `dir()` and wildcard imports. Exercise those APIs
|
||||||
in cold interpreters so this test proves normal `import tractor`
|
in cold interpreters so this test proves normal `import tractor`
|
||||||
leaves `asyncio` unloaded, while discovery and wildcard access
|
leaves `asyncio` unloaded, while introspection and wildcard
|
||||||
still advertise and resolve the public submodule.
|
access still advertise and resolve the public submodule.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
cold = run_cold_import(
|
cold = run_cold_import(
|
||||||
|
|
@ -102,6 +103,12 @@ def test_cold_import_budget():
|
||||||
'bidict',
|
'bidict',
|
||||||
'colorlog',
|
'colorlog',
|
||||||
'multiaddr',
|
'multiaddr',
|
||||||
|
'multibase',
|
||||||
|
'pyroute2',
|
||||||
|
'tractor.discovery._multiaddr',
|
||||||
|
'tractor.net',
|
||||||
|
'tractor.net._bindspace',
|
||||||
|
'tractor.net._tunnel',
|
||||||
'wrapt',
|
'wrapt',
|
||||||
)
|
)
|
||||||
code = (
|
code = (
|
||||||
|
|
@ -136,6 +143,123 @@ def test_cold_import_budget():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lazy_net_package_api():
|
||||||
|
'''
|
||||||
|
Keep the public network package cold until symbol access.
|
||||||
|
|
||||||
|
The old discovery re-exports imported bindspace, tunnel,
|
||||||
|
multiaddr and optional dependencies while initializing a package.
|
||||||
|
Import `tractor.net` in a clean interpreter, inspect its public
|
||||||
|
surface, and prove no implementation or optional dependency was
|
||||||
|
loaded. Then resolve one symbol from each backing module and
|
||||||
|
prove the facade caches each value while preserving boundaries.
|
||||||
|
|
||||||
|
'''
|
||||||
|
modules: tuple[str, ...] = (
|
||||||
|
'tractor.net._bindspace',
|
||||||
|
'tractor.net._tunnel',
|
||||||
|
'tractor.discovery._multiaddr',
|
||||||
|
'multiaddr',
|
||||||
|
'multibase',
|
||||||
|
'pyroute2',
|
||||||
|
)
|
||||||
|
cold: dict[str, object] = run_cold_import(
|
||||||
|
'import json, sys; import tractor.net as net; '
|
||||||
|
f'names = {modules!r}; '
|
||||||
|
'print(json.dumps({'
|
||||||
|
'"public": all(name in dir(net) for name in net.__all__), '
|
||||||
|
'"loaded": [name for name in names if name in sys.modules]'
|
||||||
|
'}))'
|
||||||
|
)
|
||||||
|
assert cold == {
|
||||||
|
'public': True,
|
||||||
|
'loaded': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved: dict[str, object] = run_cold_import(
|
||||||
|
'import json, sys; import tractor.net as net; '
|
||||||
|
'bindspace = net.BindspaceSpec; '
|
||||||
|
'bindspace_cached = net.BindspaceSpec is bindspace; '
|
||||||
|
'maddr = net.mk_maddr; '
|
||||||
|
'maddr_cached = net.mk_maddr is maddr; '
|
||||||
|
'tunnel = net.WGTunnelSpec; '
|
||||||
|
'tunnel_cached = net.WGTunnelSpec is tunnel; '
|
||||||
|
'print(json.dumps({'
|
||||||
|
'"bindspace_cached": bindspace_cached, '
|
||||||
|
'"maddr_cached": maddr_cached, '
|
||||||
|
'"tunnel_cached": tunnel_cached, '
|
||||||
|
'"bindspace_module": bindspace.__module__, '
|
||||||
|
'"maddr_module": maddr.__module__, '
|
||||||
|
'"tunnel_module": tunnel.__module__, '
|
||||||
|
'"multiaddr_loaded": "multiaddr" in sys.modules, '
|
||||||
|
'"pyroute2_loaded": "pyroute2" in sys.modules'
|
||||||
|
'}))'
|
||||||
|
)
|
||||||
|
assert resolved == {
|
||||||
|
'bindspace_cached': True,
|
||||||
|
'maddr_cached': True,
|
||||||
|
'tunnel_cached': True,
|
||||||
|
'bindspace_module': 'tractor.net._bindspace',
|
||||||
|
'maddr_module': 'tractor.discovery._multiaddr',
|
||||||
|
'tunnel_module': 'tractor.net._tunnel',
|
||||||
|
'multiaddr_loaded': False,
|
||||||
|
'pyroute2_loaded': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_net_root_export_and_old_discovery_surface():
|
||||||
|
'''
|
||||||
|
Publish networking only from its approved namespace.
|
||||||
|
|
||||||
|
Before extraction, unshipped network names and implementation
|
||||||
|
modules lived under `tractor.discovery`. Exercise root attribute
|
||||||
|
and wildcard access in clean interpreters, proving `tractor.net`
|
||||||
|
is discoverable and cached without loading implementations. Also
|
||||||
|
prove the old exports are absent and their modules no longer
|
||||||
|
resolve, preventing accidental compatibility aliases.
|
||||||
|
|
||||||
|
'''
|
||||||
|
root: dict[str, object] = run_cold_import(
|
||||||
|
'import json, sys, tractor; '
|
||||||
|
'advertised = "net" in dir(tractor); '
|
||||||
|
'net = tractor.net; '
|
||||||
|
'print(json.dumps({'
|
||||||
|
'"advertised": advertised, '
|
||||||
|
'"cached": tractor.net is net, '
|
||||||
|
'"module": net.__name__, '
|
||||||
|
'"bindspace_loaded": '
|
||||||
|
'"tractor.net._bindspace" in sys.modules, '
|
||||||
|
'"tunnel_loaded": "tractor.net._tunnel" in sys.modules'
|
||||||
|
'}))'
|
||||||
|
)
|
||||||
|
assert root == {
|
||||||
|
'advertised': True,
|
||||||
|
'cached': True,
|
||||||
|
'module': 'tractor.net',
|
||||||
|
'bindspace_loaded': False,
|
||||||
|
'tunnel_loaded': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
old: dict[str, object] = run_cold_import(
|
||||||
|
'import importlib.util, json; '
|
||||||
|
'import tractor.discovery as discovery; '
|
||||||
|
'old_names = ("Bindspace", "TunnelledAddress", '
|
||||||
|
'"mk_maddr", "parse_maddr", "parse_endpoints"); '
|
||||||
|
'old_modules = ("tractor.discovery._bindspace", '
|
||||||
|
'"tractor.discovery._tunnel"); '
|
||||||
|
'print(json.dumps({'
|
||||||
|
'"exports": [name for name in old_names '
|
||||||
|
'if hasattr(discovery, name)], '
|
||||||
|
'"modules": [name for name in old_modules '
|
||||||
|
'if importlib.util.find_spec(name) is not None]'
|
||||||
|
'}))'
|
||||||
|
)
|
||||||
|
assert old == {
|
||||||
|
'exports': [],
|
||||||
|
'modules': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_lazy_annotation_names_resolve():
|
def test_lazy_annotation_names_resolve():
|
||||||
'''
|
'''
|
||||||
Resolve annotations without importing optional dependencies.
|
Resolve annotations without importing optional dependencies.
|
||||||
|
|
@ -157,4 +281,7 @@ def test_lazy_annotation_names_resolve():
|
||||||
assert get_type_hints(_addr.Address.get_random)[
|
assert get_type_hints(_addr.Address.get_random)[
|
||||||
'current_actor'
|
'current_actor'
|
||||||
] is Any
|
] is Any
|
||||||
|
assert get_type_hints(tractor.open_root_actor)[
|
||||||
|
'bindspace'
|
||||||
|
] == Any|None
|
||||||
assert _addr.__annotations__['_address_types'].startswith('dict')
|
assert _addr.__annotations__['_address_types'].startswith('dict')
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,15 +10,25 @@ API design.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
)
|
)
|
||||||
|
from unittest.mock import (
|
||||||
|
AsyncMock,
|
||||||
|
MagicMock,
|
||||||
|
)
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
|
from tractor._exceptions import ActorFailure
|
||||||
from tractor._testing import tractor_test
|
from tractor._testing import tractor_test
|
||||||
|
from tractor.spawn import (
|
||||||
|
_spawn,
|
||||||
|
_trio,
|
||||||
|
)
|
||||||
|
|
||||||
data_to_pass_down = {
|
data_to_pass_down = {
|
||||||
'doggy': 10,
|
'doggy': 10,
|
||||||
|
|
@ -26,6 +36,396 @@ data_to_pass_down = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_peer_handshake_wins_child_boot_race() -> None:
|
||||||
|
'''
|
||||||
|
A connected child must cancel process-death monitoring cleanly.
|
||||||
|
|
||||||
|
Start the death waiter first and hold it at a checkpoint. Let the
|
||||||
|
fake server then return one peer event and channel. The helper must
|
||||||
|
preserve the normal handshake result and cancel the losing process
|
||||||
|
waiter before its nursery exits. The fake explicitly catches and
|
||||||
|
re-raises `trio.Cancelled` to prove cancellation caused its exit.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Control the handshake-first schedule with Trio events.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('handshake-child', 'test')
|
||||||
|
death_started = trio.Event()
|
||||||
|
death_cancelled = trio.Event()
|
||||||
|
peer_event = trio.Event()
|
||||||
|
channel = object()
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Return the peer only after death monitoring is active.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
await death_started.wait()
|
||||||
|
return (peer_event, channel)
|
||||||
|
|
||||||
|
async def wait_for_death() -> int:
|
||||||
|
'''
|
||||||
|
Block until the winning handshake cancels this waiter.
|
||||||
|
|
||||||
|
'''
|
||||||
|
death_started.set()
|
||||||
|
try:
|
||||||
|
await trio.sleep_forever()
|
||||||
|
except trio.Cancelled:
|
||||||
|
death_cancelled.set()
|
||||||
|
raise
|
||||||
|
|
||||||
|
result = await _spawn.wait_for_peer_or_proc_death(
|
||||||
|
ipc_server=SimpleNamespace(
|
||||||
|
wait_for_peer=wait_for_peer,
|
||||||
|
),
|
||||||
|
uid=uid,
|
||||||
|
proc_wait=wait_for_death,
|
||||||
|
proc_repr='handshake-proc',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == (peer_event, channel)
|
||||||
|
assert death_cancelled.is_set()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_child_death_wins_peer_handshake_race() -> None:
|
||||||
|
'''
|
||||||
|
Pre-handshake child death must fail startup instead of hanging.
|
||||||
|
|
||||||
|
Start the peer waiter first and leave it parked like
|
||||||
|
`IPCServer.wait_for_peer()` on an unset event. Return a distinctive
|
||||||
|
process status from the competing waiter, then prove the helper
|
||||||
|
cancels the handshake and raises `ActorFailure` with child identity,
|
||||||
|
status, and process diagnostics.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Control the death-first schedule with Trio events.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('dead-child', 'test')
|
||||||
|
handshake_started = trio.Event()
|
||||||
|
handshake_cancelled = trio.Event()
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Park until process death cancels this handshake waiter.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
handshake_started.set()
|
||||||
|
try:
|
||||||
|
await trio.sleep_forever()
|
||||||
|
except trio.Cancelled:
|
||||||
|
handshake_cancelled.set()
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def wait_for_death() -> int:
|
||||||
|
'''
|
||||||
|
Report child death after handshake monitoring is active.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await handshake_started.wait()
|
||||||
|
return 23
|
||||||
|
|
||||||
|
with pytest.raises(ActorFailure) as exc_info:
|
||||||
|
await _spawn.wait_for_peer_or_proc_death(
|
||||||
|
ipc_server=SimpleNamespace(
|
||||||
|
wait_for_peer=wait_for_peer,
|
||||||
|
),
|
||||||
|
uid=uid,
|
||||||
|
proc_wait=wait_for_death,
|
||||||
|
proc_repr='dead-proc',
|
||||||
|
)
|
||||||
|
|
||||||
|
message: str = str(exc_info.value)
|
||||||
|
assert repr(uid) in message
|
||||||
|
assert 'died during boot' in message
|
||||||
|
assert '(rc=23)' in message
|
||||||
|
assert 'parent-handshake' in message
|
||||||
|
assert 'dead-proc' in message
|
||||||
|
assert handshake_cancelled.is_set()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_child_death_wins_simultaneous_boot_results() -> None:
|
||||||
|
'''
|
||||||
|
Observed process death must outrank a simultaneous handshake.
|
||||||
|
|
||||||
|
Hold both fake waits behind one barrier with cancellation shielding,
|
||||||
|
then release them together so both publish a committed result before
|
||||||
|
sibling cancellation takes effect. Because the child has exited
|
||||||
|
before receiving its `SpawnSpec`, bootstrap must raise `ActorFailure`
|
||||||
|
rather than return its briefly established channel.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Release both boot outcomes from one controlled barrier.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('simultaneous-child', 'test')
|
||||||
|
handshake_ready = trio.Event()
|
||||||
|
death_ready = trio.Event()
|
||||||
|
release = trio.Event()
|
||||||
|
peer_event = trio.Event()
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Publish a handshake despite sibling cancellation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
handshake_ready.set()
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
await release.wait()
|
||||||
|
return (peer_event, object())
|
||||||
|
|
||||||
|
async def wait_for_death() -> int:
|
||||||
|
'''
|
||||||
|
Publish process death despite sibling cancellation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
death_ready.set()
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
await release.wait()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def release_both() -> None:
|
||||||
|
'''
|
||||||
|
Open the barrier only after both waiters are parked.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await handshake_ready.wait()
|
||||||
|
await death_ready.wait()
|
||||||
|
release.set()
|
||||||
|
|
||||||
|
async with trio.open_nursery() as nursery:
|
||||||
|
nursery.start_soon(release_both)
|
||||||
|
with pytest.raises(
|
||||||
|
ActorFailure,
|
||||||
|
match=r'simultaneous-child.*rc=0',
|
||||||
|
):
|
||||||
|
await _spawn.wait_for_peer_or_proc_death(
|
||||||
|
ipc_server=SimpleNamespace(
|
||||||
|
wait_for_peer=wait_for_peer,
|
||||||
|
),
|
||||||
|
uid=uid,
|
||||||
|
proc_wait=wait_for_death,
|
||||||
|
)
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('failing_waiter', ('handshake', 'death'))
|
||||||
|
def test_child_boot_race_preserves_waiter_error(
|
||||||
|
failing_waiter: str,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Waiter failures must retain their original exception identity.
|
||||||
|
|
||||||
|
Park the non-failing sibling and raise one unique error from either
|
||||||
|
the peer or process waiter. The helper's internal nursery must
|
||||||
|
cancel the sibling and re-raise that exact exception instead of
|
||||||
|
wrapping it in an `ExceptionGroup`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Trigger one selected waiter after its sibling starts.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('errored-child', 'test')
|
||||||
|
sibling_started = trio.Event()
|
||||||
|
wait_error = RuntimeError(f'{failing_waiter} failed')
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Raise or park according to the selected peer schedule.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
if failing_waiter == 'handshake':
|
||||||
|
await sibling_started.wait()
|
||||||
|
raise wait_error
|
||||||
|
|
||||||
|
sibling_started.set()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
async def wait_for_death() -> int:
|
||||||
|
'''
|
||||||
|
Raise or park according to the selected process schedule.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if failing_waiter == 'death':
|
||||||
|
await sibling_started.wait()
|
||||||
|
raise wait_error
|
||||||
|
|
||||||
|
sibling_started.set()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as exc_info:
|
||||||
|
await _spawn.wait_for_peer_or_proc_death(
|
||||||
|
ipc_server=SimpleNamespace(
|
||||||
|
wait_for_peer=wait_for_peer,
|
||||||
|
),
|
||||||
|
uid=uid,
|
||||||
|
proc_wait=wait_for_death,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value is wait_error
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trio_proc_cleans_failed_child_peer_event(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Death-first Trio startup must release provisional peer state.
|
||||||
|
|
||||||
|
Return one already-dead fake process while its server handshake
|
||||||
|
parks forever. The fake nursery proves the peer event exists before
|
||||||
|
provisional child publication. After `ActorFailure`, both that
|
||||||
|
exact event and the provisional child record must be gone so repeated
|
||||||
|
failed spawns cannot leak server state.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = ('dead-trio-child', 'test')
|
||||||
|
proc: trio.Process = MagicMock(spec=trio.Process)
|
||||||
|
proc.pid = 1234
|
||||||
|
proc.wait = AsyncMock(return_value=23)
|
||||||
|
proc.poll.return_value = 23
|
||||||
|
proc.__str__.return_value = 'dead-trio-proc'
|
||||||
|
|
||||||
|
class FakeServer:
|
||||||
|
'''
|
||||||
|
Hold the peer registry used during Trio child startup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._peer_connected: dict[
|
||||||
|
tuple[str, str],
|
||||||
|
trio.Event,
|
||||||
|
] = {}
|
||||||
|
|
||||||
|
async def wait_for_peer(
|
||||||
|
self,
|
||||||
|
child_uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, object]:
|
||||||
|
'''
|
||||||
|
Park like a child that never reaches its handshake.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert child_uid == uid
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
server = FakeServer()
|
||||||
|
|
||||||
|
class FakeNursery:
|
||||||
|
'''
|
||||||
|
Track provisional child publication and cleanup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._actor = SimpleNamespace(ipc_server=server)
|
||||||
|
self._children: dict[tuple[str, str], tuple] = {}
|
||||||
|
|
||||||
|
def _register_child(
|
||||||
|
self,
|
||||||
|
subactor: object,
|
||||||
|
proc: object,
|
||||||
|
portal: object|None,
|
||||||
|
) -> tuple[trio.Event, trio.Event, bool]:
|
||||||
|
'''
|
||||||
|
Require peer-event registration before child publication.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert uid in server._peer_connected
|
||||||
|
assert portal is None
|
||||||
|
self._children[uid] = (subactor, proc, portal)
|
||||||
|
return (trio.Event(), trio.Event(), False)
|
||||||
|
|
||||||
|
async def fake_open_process(
|
||||||
|
command: list[str],
|
||||||
|
**kwargs: object,
|
||||||
|
) -> trio.Process:
|
||||||
|
'''
|
||||||
|
Return a process whose death wins the bootstrap race.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert command
|
||||||
|
return proc
|
||||||
|
|
||||||
|
async def fake_wait_for_debugger(**kwargs: object) -> None:
|
||||||
|
'''
|
||||||
|
Keep hard-reap cleanup deterministic and non-interactive.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_trio.trio.lowlevel,
|
||||||
|
'open_process',
|
||||||
|
fake_open_process,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_trio.debug,
|
||||||
|
'maybe_wait_for_debugger',
|
||||||
|
fake_wait_for_debugger,
|
||||||
|
)
|
||||||
|
|
||||||
|
nursery = FakeNursery()
|
||||||
|
subactor = SimpleNamespace(
|
||||||
|
aid=tractor.msg.Aid(
|
||||||
|
name=uid[0],
|
||||||
|
uuid=uid[1],
|
||||||
|
),
|
||||||
|
loglevel=None,
|
||||||
|
pformat=lambda: 'dead-trio-child',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Run the full Trio backend through death-first cleanup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with pytest.raises(
|
||||||
|
ActorFailure,
|
||||||
|
match=r'dead-trio-child.*rc=23',
|
||||||
|
):
|
||||||
|
await _trio.trio_proc(
|
||||||
|
name=uid[0],
|
||||||
|
actor_nursery=nursery,
|
||||||
|
subactor=subactor,
|
||||||
|
errors={},
|
||||||
|
bind_addrs=[],
|
||||||
|
parent_addr=('127.0.0.1', 1616),
|
||||||
|
_runtime_vars={},
|
||||||
|
)
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
assert server._peer_connected == {}
|
||||||
|
assert nursery._children == {}
|
||||||
|
|
||||||
|
|
||||||
async def run_same_func_in_child(
|
async def run_same_func_in_child(
|
||||||
should_be_root: bool,
|
should_be_root: bool,
|
||||||
data: dict,
|
data: dict,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
tractor: structured concurrent ``trio``-"actors".
|
tractor: structured concurrent ``trio``-"actors".
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from types import ModuleType as _ModuleType
|
||||||
|
|
||||||
from ._clustering import (
|
from ._clustering import (
|
||||||
open_actor_cluster as open_actor_cluster,
|
open_actor_cluster as open_actor_cluster,
|
||||||
|
|
@ -82,6 +83,7 @@ __all__: tuple[str, ...] = tuple(
|
||||||
for name in globals()
|
for name in globals()
|
||||||
if not name.startswith('_')
|
if not name.startswith('_')
|
||||||
) + (
|
) + (
|
||||||
|
'net',
|
||||||
'to_asyncio',
|
'to_asyncio',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -92,21 +94,18 @@ def __dir__() -> list[str]:
|
||||||
|
|
||||||
def __getattr__(name: str):
|
def __getattr__(name: str):
|
||||||
'''
|
'''
|
||||||
PEP 562 lazy sub-module loading, presently only for
|
PEP 562 lazy public sub-package loading.
|
||||||
`.to_asyncio` which (transitively) imports `asyncio`
|
|
||||||
itself: a non-trivial multi-ms chunk of the eager
|
|
||||||
`import tractor` cost (gh #470) unneeded by
|
|
||||||
`trio`-only apps.
|
|
||||||
|
|
||||||
Any `tractor.to_asyncio.<attr>` access (or a
|
`tractor.to_asyncio` transitively imports `asyncio`, while
|
||||||
`from tractor import to_asyncio`) still works, the
|
`tractor.net` owns optional network dependencies. Neither is
|
||||||
sub-mod is simply imported on first-access instead
|
needed by most applications merely importing the root package.
|
||||||
of at pkg-import time.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if name == 'to_asyncio':
|
if name in ('net', 'to_asyncio'):
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
return import_module('.to_asyncio', __name__)
|
module: _ModuleType = import_module(f'.{name}', __name__)
|
||||||
|
globals()[name] = module
|
||||||
|
return module
|
||||||
|
|
||||||
raise AttributeError(
|
raise AttributeError(
|
||||||
f'module {__name__!r} has no attribute {name!r}'
|
f'module {__name__!r} has no attribute {name!r}'
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ from ast import literal_eval
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .runtime._runtime import Actor
|
from .runtime._runtime import Actor
|
||||||
from .spawn._entry import _trio_main
|
from .spawn._entry import (
|
||||||
|
_consume_netns_bootstrap,
|
||||||
|
_trio_main,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .discovery._addr import UnwrappedAddress
|
from .discovery._addr import UnwrappedAddress
|
||||||
|
|
@ -46,12 +49,41 @@ def parse_ipaddr(arg):
|
||||||
return arg
|
return arg
|
||||||
|
|
||||||
|
|
||||||
|
def parse_netns_bootstrap(arg: str) -> tuple[int, int]:
|
||||||
|
'''
|
||||||
|
Parse one atomic inherited namespace capability.
|
||||||
|
|
||||||
|
Descriptor and inode validation remains in
|
||||||
|
`_consume_netns_bootstrap()` so every valid descriptor-shaped
|
||||||
|
input reaches its exact child-owned cleanup boundary.
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
value: object = literal_eval(arg)
|
||||||
|
except (ValueError, SyntaxError) as exc:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
'netns bootstrap must be an `(fd, inode)` tuple'
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if (
|
||||||
|
not isinstance(value, tuple)
|
||||||
|
or
|
||||||
|
len(value) != 2
|
||||||
|
):
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
'netns bootstrap must be an `(fd, inode)` tuple'
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _actor_child_main(
|
def _actor_child_main(
|
||||||
uid: tuple[str, str],
|
uid: tuple[str, str],
|
||||||
loglevel: str | None,
|
loglevel: str | None,
|
||||||
parent_addr: UnwrappedAddress | None,
|
parent_addr: UnwrappedAddress | None,
|
||||||
infect_asyncio: bool,
|
infect_asyncio: bool,
|
||||||
spawn_method: SpawnMethodKey = 'trio',
|
spawn_method: SpawnMethodKey = 'trio',
|
||||||
|
netns_bootstrap: tuple[int, int]|None = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
|
|
@ -62,7 +94,13 @@ def _actor_child_main(
|
||||||
invokes this from inside a fresh `concurrent.interpreters`
|
invokes this from inside a fresh `concurrent.interpreters`
|
||||||
sub-interpreter via `Interpreter.call()`.
|
sub-interpreter via `Interpreter.call()`.
|
||||||
|
|
||||||
|
Consume `netns_bootstrap` before Trio patching, actor construction,
|
||||||
|
process-title setup, or actor-runtime entry. The spawn backend must
|
||||||
|
supply an exclusively child-owned FD duplicate.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
_consume_netns_bootstrap(netns_bootstrap)
|
||||||
|
|
||||||
# Apply defensive monkey-patches for upstream `trio`
|
# Apply defensive monkey-patches for upstream `trio`
|
||||||
# bugs we've encountered while running tractor — see
|
# bugs we've encountered while running tractor — see
|
||||||
# `tractor.trionics.patches` for the catalog +
|
# `tractor.trionics.patches` for the catalog +
|
||||||
|
|
@ -113,7 +151,11 @@ def _actor_child_main(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def main(argv: list[str]|None = None) -> None:
|
||||||
|
'''
|
||||||
|
Parse Trio child-bootstrap arguments and enter actor runtime.
|
||||||
|
|
||||||
|
'''
|
||||||
__tracebackhide__: bool = True
|
__tracebackhide__: bool = True
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
|
|
@ -121,7 +163,11 @@ if __name__ == "__main__":
|
||||||
parser.add_argument("--loglevel", type=str)
|
parser.add_argument("--loglevel", type=str)
|
||||||
parser.add_argument("--parent_addr", type=parse_ipaddr)
|
parser.add_argument("--parent_addr", type=parse_ipaddr)
|
||||||
parser.add_argument("--asyncio", action='store_true')
|
parser.add_argument("--asyncio", action='store_true')
|
||||||
args = parser.parse_args()
|
parser.add_argument(
|
||||||
|
'--netns_bootstrap',
|
||||||
|
type=parse_netns_bootstrap,
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
_actor_child_main(
|
_actor_child_main(
|
||||||
uid=args.uid,
|
uid=args.uid,
|
||||||
|
|
@ -129,4 +175,9 @@ if __name__ == "__main__":
|
||||||
parent_addr=args.parent_addr,
|
parent_addr=args.parent_addr,
|
||||||
infect_asyncio=args.asyncio,
|
infect_asyncio=args.asyncio,
|
||||||
spawn_method='trio',
|
spawn_method='trio',
|
||||||
|
netns_bootstrap=args.netns_bootstrap,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@
|
||||||
Root actor runtime ignition(s).
|
Root actor runtime ignition(s).
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import (
|
from contextlib import (
|
||||||
asynccontextmanager as acm,
|
asynccontextmanager as acm,
|
||||||
)
|
)
|
||||||
|
|
@ -31,6 +34,7 @@ import sys
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
|
@ -63,6 +67,11 @@ from ._exceptions import (
|
||||||
RuntimeFailure,
|
RuntimeFailure,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .net._bindspace import Bindspace
|
||||||
|
else:
|
||||||
|
Bindspace = Any
|
||||||
|
|
||||||
|
|
||||||
logger = log.get_logger('tractor')
|
logger = log.get_logger('tractor')
|
||||||
|
|
||||||
|
|
@ -153,9 +162,30 @@ async def maybe_block_bp(
|
||||||
os.environ.pop('PYTHONBREAKPOINT', None)
|
os.environ.pop('PYTHONBREAKPOINT', None)
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def _enter_root_bindspace(
|
||||||
|
bindspace: Bindspace|None,
|
||||||
|
) -> AsyncIterator[None]:
|
||||||
|
'''
|
||||||
|
Adapt synchronous root netns entry to the outer async lifecycle.
|
||||||
|
|
||||||
|
The wrapped context has no checkpoints, so `trio` cancellation
|
||||||
|
cannot interrupt thread-local namespace restoration.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from .spawn._netns import _enter_netns_temporarily
|
||||||
|
|
||||||
|
with _enter_netns_temporarily(bindspace):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def open_root_actor(
|
async def open_root_actor(
|
||||||
*,
|
*,
|
||||||
|
# Low-level realized scope. A future tunnelled-address bootstrap
|
||||||
|
# may open and supply this capability internally.
|
||||||
|
bindspace: Bindspace|None = None,
|
||||||
|
|
||||||
tpt_bind_addrs: list[
|
tpt_bind_addrs: list[
|
||||||
Address # `Address.get_random()` case
|
Address # `Address.get_random()` case
|
||||||
|UnwrappedAddress # registrar case `= uw_reg_addrs`
|
|UnwrappedAddress # registrar case `= uw_reg_addrs`
|
||||||
|
|
@ -219,6 +249,10 @@ async def open_root_actor(
|
||||||
All (disjoint) actor-process-trees-as-programs are created via
|
All (disjoint) actor-process-trees-as-programs are created via
|
||||||
this entrypoint.
|
this entrypoint.
|
||||||
|
|
||||||
|
When `bindspace` is provided, enter its network namespace before
|
||||||
|
any registry or IPC activity and restore the calling thread's
|
||||||
|
original namespace after complete actor teardown.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
# XXX NEVER allow nested actor-trees!
|
# XXX NEVER allow nested actor-trees!
|
||||||
if already_actor := _state.current_actor(
|
if already_actor := _state.current_actor(
|
||||||
|
|
@ -239,10 +273,29 @@ async def open_root_actor(
|
||||||
f'_registry_addrs: {registry_addrs!r}\n'
|
f'_registry_addrs: {registry_addrs!r}\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
effective_start_method: str = (
|
||||||
|
os.environ.get('TRACTOR_SPAWN_METHOD')
|
||||||
|
or start_method
|
||||||
|
or _spawn._spawn_method
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
bindspace is not None
|
||||||
|
and
|
||||||
|
effective_start_method == 'mp_forkserver'
|
||||||
|
):
|
||||||
|
raise NotImplementedError(
|
||||||
|
'Root actor bindspaces are not supported by the '
|
||||||
|
'`mp_forkserver` spawn backend because a persistent '
|
||||||
|
'forkserver may retain its original network namespace!'
|
||||||
|
)
|
||||||
|
|
||||||
# debug.mk_pdb().set_trace()
|
# debug.mk_pdb().set_trace()
|
||||||
async with maybe_block_bp(
|
async with (
|
||||||
|
_enter_root_bindspace(bindspace),
|
||||||
|
maybe_block_bp(
|
||||||
debug_mode=debug_mode,
|
debug_mode=debug_mode,
|
||||||
maybe_enable_greenback=maybe_enable_greenback,
|
maybe_enable_greenback=maybe_enable_greenback,
|
||||||
|
),
|
||||||
):
|
):
|
||||||
if enable_transports is None:
|
if enable_transports is None:
|
||||||
enable_transports: list[str] = _state.current_ipc_protos()
|
enable_transports: list[str] = _state.current_ipc_protos()
|
||||||
|
|
@ -504,7 +557,10 @@ async def open_root_actor(
|
||||||
# XXX INSTEAD, bind random addrs using the same tpt
|
# XXX INSTEAD, bind random addrs using the same tpt
|
||||||
# proto if not already provided.
|
# proto if not already provided.
|
||||||
if not tpt_bind_addrs:
|
if not tpt_bind_addrs:
|
||||||
|
from .net._tunnel import strip_tunnels
|
||||||
|
|
||||||
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 +568,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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,8 @@
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Discovery (protocols) API for automatic addressing
|
Actor discovery and registrar implementation package.
|
||||||
and location management of (service) actors.
|
|
||||||
|
|
||||||
NOTE: this ``__init__`` only eagerly imports the
|
Network declarations and helpers are public from `tractor.net`.
|
||||||
``._multiaddr`` submodule (for public re-exports).
|
|
||||||
Heavier submodules like ``._addr`` and ``._api``
|
|
||||||
are NOT imported here to avoid circular imports;
|
|
||||||
use direct module paths for those.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from ._multiaddr import (
|
|
||||||
parse_endpoints as parse_endpoints,
|
|
||||||
parse_maddr as parse_maddr,
|
|
||||||
mk_maddr as mk_maddr,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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 tractor.net._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,17 @@ 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
|
||||||
|
# `tractor.net._tunnel`.
|
||||||
|
from tractor.net._tunnel import TunnelledAddress
|
||||||
|
return (
|
||||||
|
type(addr) in _address_types.values()
|
||||||
|
or
|
||||||
|
isinstance(addr, TunnelledAddress)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def mk_uuid() -> str:
|
def mk_uuid() -> str:
|
||||||
|
|
@ -223,8 +258,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 +289,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())
|
||||||
|
|
@ -277,7 +334,7 @@ def wrap_address(
|
||||||
# multiaddr-format string, e.g.
|
# multiaddr-format string, e.g.
|
||||||
# '/ip4/127.0.0.1/tcp/1616'
|
# '/ip4/127.0.0.1/tcp/1616'
|
||||||
case str() if addr.startswith('/'):
|
case str() if addr.startswith('/'):
|
||||||
from tractor.discovery._multiaddr import (
|
from tractor.net import (
|
||||||
parse_maddr,
|
parse_maddr,
|
||||||
)
|
)
|
||||||
return parse_maddr(addr)
|
return parse_maddr(addr)
|
||||||
|
|
|
||||||
|
|
@ -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.net._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 tractor.net._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 tractor.net._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 tractor.net._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`
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ from tractor.msg import (
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ._transport import MsgTransport
|
from ._transport import MsgTransport
|
||||||
|
from tractor.net._tunnel import TunnelledAddress
|
||||||
|
else:
|
||||||
|
TunnelledAddress = Any
|
||||||
|
|
||||||
|
|
||||||
log = get_logger()
|
log = get_logger()
|
||||||
|
|
@ -182,16 +185,19 @@ 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):
|
from tractor.net._tunnel import strip_tunnels
|
||||||
addr: Address = wrap_address(addr)
|
|
||||||
|
|
||||||
transport_cls = transport_from_addr(addr)
|
if not is_wrapped_addr(addr):
|
||||||
|
addr = wrap_address(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 +557,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]:
|
||||||
'''
|
'''
|
||||||
|
|
|
||||||
|
|
@ -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 ..net._tunnel import TunnelledAddress
|
||||||
from ..runtime._runtime import Actor
|
from ..runtime._runtime import Actor
|
||||||
from ..runtime._supervise import ActorNursery
|
from ..runtime._supervise import ActorNursery
|
||||||
|
|
||||||
|
|
@ -627,6 +631,7 @@ class Endpoint(Struct):
|
||||||
|
|
||||||
'''
|
'''
|
||||||
addr: Address
|
addr: Address
|
||||||
|
declared_addr: Address|TunnelledAddress
|
||||||
listen_tn: Nursery
|
listen_tn: Nursery
|
||||||
stream_handler_tn: Nursery|None = None
|
stream_handler_tn: Nursery|None = None
|
||||||
|
|
||||||
|
|
@ -641,15 +646,27 @@ class Endpoint(Struct):
|
||||||
MsgTransport, # handle to encoded-msg transport stream
|
MsgTransport, # handle to encoded-msg transport stream
|
||||||
] = {}
|
] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def namespace(self) -> tuple[str, str|int]|None:
|
||||||
|
'''
|
||||||
|
Return the original address declaration's namespace.
|
||||||
|
|
||||||
|
`Endpoint.addr` is peeled to its concrete transport before
|
||||||
|
listener reflection, so `.declared_addr` retains bindspace
|
||||||
|
metadata for diagnostics and later provisioning.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return self.declared_addr.namespace
|
||||||
|
|
||||||
def pformat(
|
def pformat(
|
||||||
self,
|
self,
|
||||||
indent: int = 0,
|
indent: int = 0,
|
||||||
privates: bool = False,
|
privates: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
type_repr: str = type(self).__name__
|
type_repr: str = type(self).__name__
|
||||||
|
namespace: tuple[str, str|int]|None = self.namespace
|
||||||
fmtstr: str = (
|
fmtstr: str = (
|
||||||
# !TODO, always be ns aware!
|
f' |.namespace: {namespace!r}\n'
|
||||||
# f'|_netns: {netns}\n'
|
|
||||||
f' |.addr: {self.addr!r}\n'
|
f' |.addr: {self.addr!r}\n'
|
||||||
f' |_peers: {len(self.peer_tpts)}\n'
|
f' |_peers: {len(self.peer_tpts)}\n'
|
||||||
)
|
)
|
||||||
|
|
@ -927,9 +944,17 @@ class Server(Struct):
|
||||||
ep.addr for ep in eps
|
ep.addr for ep in eps
|
||||||
]
|
]
|
||||||
repr_eps: str = ppfmt(addrs)
|
repr_eps: str = ppfmt(addrs)
|
||||||
|
namespaces: list[
|
||||||
|
tuple[str, str|int]|None
|
||||||
|
] = []
|
||||||
|
ep: Endpoint
|
||||||
|
for ep in eps:
|
||||||
|
namespaces.append(ep.namespace)
|
||||||
|
repr_namespaces: str = ppfmt(namespaces)
|
||||||
|
|
||||||
fmtstr += (
|
fmtstr += (
|
||||||
f' |_endpoints: {repr_eps}\n'
|
f' |_endpoints: {repr_eps}\n'
|
||||||
|
f' |_namespaces: {repr_namespaces}\n'
|
||||||
# ^TODO? how to indent closing ']'..
|
# ^TODO? how to indent closing ']'..
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -965,7 +990,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 +1075,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 +1091,8 @@ async def _serve_ipc_eps(
|
||||||
`.cancel_server()` is called.
|
`.cancel_server()` is called.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
from ..net._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,11 +1101,13 @@ 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,
|
||||||
stream_handler_tn=stream_handler_tn,
|
stream_handler_tn=stream_handler_tn,
|
||||||
|
declared_addr=declared_addr,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
ep_sclang: str = nest_from_op(
|
ep_sclang: str = nest_from_op(
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue