Compare commits

...

35 Commits

Author SHA1 Message Date
Gud Boi d097090f2e Add declarative WG actor bootstrap plan
Build on PR #511's explicit bindspace and WG lifecycle contracts
without moving declaration policy into actor runtime params.

Deats,
- define exact-name planning and whole-table resource realization;
- separate parsed identity from local interface and netns policy;
- specify owned vs borrowed lifecycles, recursive canonical
  declarations and snapshot-before-checkpoint semantics;
- require explicit routes and actionable privilege diagnostics; and
- order the impl through planning, lifecycle, coordinator,
  composition and real WG dataplane patches.

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 18:21:00 -04:00
Gud Boi 93dbbd5a02 Skip Linux netns tests on other platforms
Keep platform-independent spawn coverage active while avoiding Linux
`/proc` and `os.setns()` assumptions on macOS and Windows.

Deats,
- define one reusable Linux netns test mark
- apply it only to tests requiring real Linux namespace APIs
- preserve cross-platform bootstrap and transport unit coverage

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 18:10:53 -04:00
Gud Boi 4152b23d3a Scope `open_root_actor()` to a `Bindspace`
Enter a realized netns before root registry, IPC and runtime
startup, then restore the calling thread before owned bindspace
teardown.

Deats,
- duplicate the namespace FD so caller ownership stays intact
- preserve primary body errors across restore and close failures
- reject persistent `mp_forkserver` roots with stale netns risk
- cover cancellation, real netns entry, UDS RPC and public WG
  composition

Prompt-IO: ai/prompt-io/opencode/20260830T025202Z_b1f6ade8_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 18:10:53 -04:00
Gud Boi 299e044d49 Move network APIs to lazy `tractor.net`
Move bindspace, tunnel and WireGuard lifecycles out of actor
discovery and expose them through one lazy public package.

Deats,
- keep `import tractor` free of multiaddr, pyroute2 and WG impls
- move network-focused tests under `tests/net`
- update IPC, spawn, docs and multihost callers to the new API
- pin `CURRENT_NETNS` through the calling thread's procfs link

Prompt-IO: ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 18:10:53 -04:00
Gud Boi fbab40fa6d Pass `Bindspace` FD to `trio` children
Thread an optional live `Bindspace` through actor spawn APIs and
give each Trio exec child an inherited namespace descriptor before
runtime bootstrap.

Deats,
- duplicate the namespace FD without changing parent ownership
- preserve caller `pass_fds` and process options
- send the `(fd, inode)` pair through the `_child` CLI
- close the parent duplicate on success, failure and cancellation
- reject MP backends until descriptor reduction is implemented
- avoid cleanup before child publication
- exercise real E2E namespace relay through handshake and RPC
- verify parent FD-table cleanup after successful child spawn

Prompt-IO: ai/prompt-io/opencode/20260828T172943Z_2ca8c570_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 18:03:35 -04:00
Gud Boi bf96e0f622 Detect child death before parent handshake
Race the initial peer handshake against process exit during Trio
child bootstrap so a dead child cannot park its spawning task.
Restore and harden the design originally implemented in `3b0724eb`.

Deats,
- register peer events before provisional nursery publication
- raise `ActorFailure` with child status when process death wins
- prioritize observed death over a simultaneous handshake
- preserve waiter exceptions without `ExceptionGroup` wrapping
- log expected losing-waiter cancellation at debug level
- remove exact failed-startup peer events during backend cleanup
- cover controlled schedules and full Trio backend cleanup

Based-on: 3b0724eba8
Prompt-IO: ai/prompt-io/opencode/20260828T045119Z_fb6d81d3_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 18:03:35 -04:00
Gud Boi a0f2891c4b Enter inherited netns during child bootstrap
Consume an optional child-owned `(fd, inode)` capability before
multiprocessing or Trio child bootstrap begins runtime setup.

Deats,
- enter the netns before Trio patching and actor construction
- close the inherited FD before actor runtime startup
- preserve entry errors when descriptor cleanup also fails
- reject malformed FD values without closing unrelated descriptors
- cover multiprocessing and Trio ordering with real stand-in FDs

Prompt-IO: ai/prompt-io/opencode/20260828T012205Z_9ae7cd86_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 18:03:35 -04:00
Gud Boi 4e1c1f7a27 Verify inherited network namespace entry
Add private `enter_netns()` bootstrap validation before actor runtime
integration.

Deats,
- verify the inherited FD against its expected namespace inode
- constrain `setns()` to Linux network namespaces
- verify `/proc/self/ns/net` after the entry syscall
- leave inherited FD ownership to the future spawn caller
- exercise guards with real FDs and unprivileged syscall fakes

Prompt-IO: ai/prompt-io/opencode/20260827T232500Z_d067505a_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 18:03:35 -04:00
Gud Boi 76d4fda335 Plan typed tunnel address decoding
Record native tagged encoding for complete `TunnelledAddress` graphs as
a deferred design follow-up.

Deats,
- cover concrete overlay-address and tunnel-spec unions
- preserve optional `BindspaceRef` metadata through decoding
- replace untyped payload inspection with typed roundtrip tests

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 18:03:35 -04:00
Gud Boi 36658efec7 Retain realized `BindspaceRef` metadata
Add `TunnelledAddress.with_bindspace_ref()` to annotate frozen tunnel
declarations with stable, serializable namespace metadata.

Deats,
- omit absent refs from the existing msgspec shape
- reject declared and realized namespace-name mismatches
- report the declared key before realization and inode afterward
- keep transport peeling free of live bindspace capability state
- expose realized refs through real listener diagnostics

Prompt-IO: ai/prompt-io/opencode/20260826T030534Z_d130431c_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 18:03:35 -04:00
Gud Boi e04081440d Rename bindspace resource models
Replace the unshipped `BindspaceIdentity` and `BindspaceHandle` names
with `BindspaceRef` and `Bindspace` across existing lifecycle APIs.

Deats,
- define refs as wire-safe, host-local and non-owning records
- reserve `Bindspace` for the live FD-backed capability
- rename the capability's realized-resource field to `.ref`
- update lifecycle tests and active design contracts
- omit compatibility aliases for the unshipped model names

Prompt-IO: ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 18:03:35 -04:00
Gud Boi 34e47af8a9 Compose WireGuard bindspace lifecycles
Add `open_wg_bindspace()` to enter one declared bindspace and an
ordered WireGuard interface stack as one async lifetime.

Deats,
- snapshot caller layer ordering before the first checkpoint
- enter interfaces outermost-first through `AsyncExitStack`
- unwind interfaces before releasing the namespace capability
- yield the live `BindspaceHandle` for endpoint allocation
- test mutable input and cancellation ordering with lifecycle fakes

Prompt-IO: ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 18:03:32 -04:00
Gud Boi 71772b228d Own WireGuard interface lifecycles
Add `open_wg_iface()` to create, configure and remove one WireGuard
interface inside a pinned bindspace through pyroute2.

Deats,
- validate listen/dial bearer policy before kernel side effects
- configure local addresses, private key, listen port and peers
- fill an omitted dial endpoint from the selected tunnel bearer
- clean partial synchronous failures before returning to Trio
- shield owned interface creation and teardown from cancellation

Prompt-IO: ai/prompt-io/opencode/20260826T003430Z_6dd39da0_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 17:56:13 -04:00
Gud Boi 736c6f1a3a Model explicit WireGuard peers
Add process-local `WGPeerConfig` entries and make
`WGInterfaceConfig` own a unique peer tuple for listener and dial
provisioning.

Deats,
- carry peer public keys, allowed CIDRs and optional endpoints
- redact per-peer preshared keys while blocking wire encoding
- validate peer routes, endpoint ports and keepalive intervals
- reject duplicate peers before future kernel mutation
- support multi-peer listeners without overloading tunnel identity

Prompt-IO: ai/prompt-io/opencode/20260826T001442Z_dcdf4d82_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 17:56:13 -04:00
Gud Boi 0fbaeb44b1 Separate local WireGuard configuration
Add process-local `WGInterfaceConfig` for key material, interface
addresses and peer-routing policy required by future provisioning.

Deats,
- redact private and preshared keys from representation
- block config from default actor-IPC encoding via `ProcessLocal`
- validate keys, interface CIDRs, allowed CIDRs and bounded integers
- keep public endpoint and peer identity in `WGTunnelSpec`
- move allowed-IP policy out of the serializable tunnel declaration

Prompt-IO: ai/prompt-io/opencode/20260825T234631Z_b973e78c_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 17:56:13 -04:00
Gud Boi 9f9a695d79 Dispatch explicit bindspace lifecycles
Add serialized `BindspaceSpec.lifecycle` policy and dispatch it through
`open_bindspace()` without inferring ownership from transport role.

Deats,
- distinguish borrowed `attach` from owned `open` policy
- validate handle ownership against the declared lifecycle
- share policy-neutral FD pinning between both netns contexts
- reject unsupported lifecycle values before side effects
- exercise both dispatcher branches and owned cancellation cleanup

Prompt-IO: ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 17:56:13 -04:00
Gud Boi 68730b4e22 Open owned network namespaces
Add `open_netns()` to create a named Linux netns through pyroute2,
pin its identity and yield an owned `BindspaceHandle`.

Deats,
- run synchronous creation and removal in Trio worker threads
- shield both privileged side effects from caller cancellation
- reuse `attach_netns()` to pin identity and manage the FD
- close the FD before removing the owned namespace
- fake privileged operations while testing ordering and cancellation

Prompt-IO: ai/prompt-io/opencode/20260825T190529Z_e1007547_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 17:56:13 -04:00
Gud Boi ac39314cfd Attach existing network namespaces
Add `attach_netns()` to pin a current or named Linux netns in a
borrowed `BindspaceHandle` without creating or entering it.

Deats,
- name the current-namespace default `CURRENT_NETNS`
- derive stable identity from the opened FD with `fstat()`
- open descriptors with `O_CLOEXEC` and close them on context exit
- constrain named lookup beneath the standard iproute2 run directory
- report field-specific validation and missing-resource errors

Prompt-IO: ai/prompt-io/opencode/20260825T045557Z_fdccfd7e_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 17:56:13 -04:00
Gud Boi 1305c720b3 Add bindspace capability models
Separate serializable `BindspaceSpec` and `BindspaceIdentity` values
from a process-local `BindspaceHandle` carrying FD and ownership
authority.

Deats,
- add global `ProcessLocal` wire guards for local handle structs
- derive valid kinds and ownership from their `Literal` aliases
- require a positive inode while keeping the mutable name optional
- pin supplied FDs to identity inodes with `fstat()`
- cover round trips, nested encoding and stale capabilities

Caught-during: review remediation
Found-via: `/run-tests` test_bindspace_handle_pins_local_capability

Prompt-IO: ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 17:56:13 -04:00
Gud Boi 3a737f960b Expose tunnel namespaces on IPC endpoints
Make `TCPAddress` and `UDSAddress` explicitly satisfy
`Address.namespace`, then retain each original listener declaration
beside its peeled, resolved transport address.

Deats,
- remove `TunnelledAddress`'s attribute fallback
- add required `Endpoint.declared_addr` metadata
- report declaration namespaces in endpoint/server formatting
- preserve concrete `Endpoint.addr` for transport reflection
- cover plain and tunneled namespace visibility

Prompt-IO: ai/prompt-io/opencode/20260822T032520Z_d35c802b_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 17:56:13 -04:00
Gud Boi 9ec5b1e702 Add explicit `verify_wg_peer()` inspection
Validate a declared tunnel key against one `pyroute2` snapshot
containing the iface's own key and configured peers.

Deats,
- share worker offload across all WireGuard key readers
- forward `WGTunnelSpec.iface` and `.netns` to the read
- reject malformed declarations before netlink I/O
- export the async helper and cover local, peer and absent keys
- replace multihost's `wg show` subprocess probe

Prompt-IO: ai/prompt-io/opencode/20260822T023226Z_59a8ecfd_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 17:56:12 -04:00
Gud Boi 62119cf132 Sketch first-child `wgman` supervisor
Add a candidate Layer-C architecture where a private, eagerly
spawned manager owns pyroute2 and tunnel provisioning for a simple
WG-enabled actor tree.

Deats,
- overlap manager reconciliation with sibling process startup
- contain `AsyncWireGuard` in an infected-asyncio child
- limit requests and capabilities by bindspace security domain
- define readiness, crash, restart and teardown semantics
- retain pre-provisioned and multi-manager escape hatches

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 16:51:39 -04:00
Gud Boi bc2b236619 Read WireGuard iface keys through `pyroute2`
Add async `read_wg_pubkey()` and `read_wg_peers()` helpers which
offload `WireGuard.info()` calls to a `trio` worker thread.

Deats,
- add the Linux-only `wg` extra and pin `pyroute2`
- pass `flags=0` so a read never creates a named netns
- normalize multipart replies, validate keys and de-dup peers
- always close the netlink client, including error paths
- test thread offload, netns forwarding and client cleanup

Prompt-IO: ai/prompt-io/opencode/20260821T233204Z_5d92595f_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-09-01 16:13:41 -04:00
Gud Boi 0d95a9e63c Emit canonical tagged addresses
- Make `TCPAddress.unwrap()` emit `('tcp', host, port)` and
  `UDSAddress.unwrap()` emit `('unix', path)` while retaining the
  compatibility readers from the preceding change.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -3,8 +3,8 @@ model: claude-opus-5
service: claude
session: 7b9c97c4-fff7-4ac4-97fb-35720453308e
timestamp: 2026-08-13T00:11:02Z
git_ref: 27c34aeb
scope: docs+code
git_ref: 27c34aebb615c30d4039fa399f4ce2766ed7ba2c
scope: code
substantive: true
raw_file: 20260813T001102Z_27c34aeb_prompt_io.raw.md
---

View File

@ -2,15 +2,15 @@
model: claude-opus-5
service: claude
timestamp: 2026-08-13T00:11:02Z
git_ref: 27c34aeb
diff_cmd: git diff main..ng_tpts_planning
git_ref: 27c34aebb615c30d4039fa399f4ce2766ed7ba2c
diff_cmd: git diff 3ad7e7e5dc28aa336499725a03744fc10c6c0026..27c34aebb615c30d4039fa399f4ce2766ed7ba2c
---
# Raw output — next-gen tpt-backend implementation plans
## 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
and the other three are written against it so they can be worked
@ -43,7 +43,7 @@ independently:
## 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)`
carrying `bearer: tuple[str, int]`, `peer_pubkey: str`,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

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

View File

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

View File

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

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -0,0 +1,72 @@
---
model: gpt-5.6-sol
service: opencode
session: f3a5eef0-9695-48ab-a336-861187473f8a
timestamp: 2026-08-31T02:23:17Z
git_ref: ng_tpts_planning
diff_cmd: git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD
scope: code
substantive: true
raw_file: 20260831T022317Z_768b5316_prompt_io.raw.md
---
## Prompt
The human asked to continue the retained PR landing sequence and
selected "Repair all blockers" after a read-only review of PR #492.
They then corrected the TIPC assessment:
> oh one thing on the tipc findings, they may have already been
> addressed in the downstream patch branch no?
The repair therefore had to use downstream #493 as implementation
evidence, avoid duplicating solved TIPC work, preserve the dirty
downstream worktree, and repair the remaining shared, QUIC,
WireGuard, lock, and provenance defects without committing,
pushing, or publishing.
## Response summary
Reworked PR #492's plans and executable WireGuard example into a
consistent landing candidate. The shared and TIPC documents now
match current runtime/downstream behavior while naming remaining
risks. The QUIC plan now defines transport ownership across the
whole actor lifecycle. The WireGuard example validates keys and
binds both hosts and the service actor correctly without elevating
Tractor. The dependency lock and historical provenance pointers
are synchronized.
## Files changed
- `ai/tpt-backends/00_shared_backend_contract.md` - current backend
contract, dispatch, capability, and locality rules.
- `ai/tpt-backends/01_tipc_backend.md` - #493 reconciliation and
bounded unresolved work.
- `ai/tpt-backends/02_quic_iroh_backend.md` - lifecycle-safe QUIC
and UniFFI implementation plan.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - corrected address
registry and privilege semantics.
- `ai/tpt-backends/README.md` - refreshed plan summaries.
- `examples/multihost/wg_lan/README.md` - unprivileged two-host
setup and accurate verification limits.
- `examples/multihost/wg_lan/host_a_srv.py` - local key check,
overlay child bind, and stable RPC exposure.
- `examples/multihost/wg_lan/host_b_client.py` - peer key check,
host-B bind, and explicit missing-service failure.
- `examples/multihost/wg_lan/wg_maddr.py` - strict parsing and
asynchronous role-specific key inspection.
- `uv.lock` - exact `py-multiaddr` Git source resolution.
- `ai/prompt-io/claude/20260813T001102Z_27c34aeb_prompt_io.md` -
valid scope and immutable Git reference.
- `ai/prompt-io/claude/20260813T001102Z_27c34aeb_prompt_io.raw.md`
- immutable historical diff pointers.
## Human edits
The human chose the full repair path rather than reducing the PR
to planning documents or publishing the initial review. They also
identified that the initial TIPC review had not accounted for the
downstream implementation branch. That correction materially
changed the work: solved TIPC items were backported into the plan,
remaining defects were separated from implemented behavior, and
the dirty downstream worktree was kept read-only.

View File

@ -0,0 +1,60 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-31T02:23:17Z
git_ref: ng_tpts_planning
diff_cmd: git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD
---
# Raw output - repair PR #492 for landing
## Generated changes
> `git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD -- ai/tpt-backends/`
- Reconciled the shared backend contract with current address,
dispatch, capability, locality, and listener-rebind APIs.
- Updated the TIPC plan from downstream #493 implementation and
tests, preserving unresolved registrar election, collision,
locality, and socket-cleanup work as explicit follow-ups.
- Reworked the QUIC plan around a launch-time transport bootstrap,
one actor-owned endpoint, a transport nursery spanning parent
dial through deregistration, supervised UniFFI cleanup, complete
routable addresses, connection leases, and listener-owned tasks.
- Corrected the WireGuard bindspace plan to distinguish the
build-registered address registry from runtime capability.
> `git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD -- examples/multihost/wg_lan/`
- Hardened WireGuard key conversion and parsing with strict
32-byte validation, lazy protocol lookup, and explicit rejection
of unsupported nested tunnel descriptors.
- Made interface inspection asynchronous, bounded at its
cancellation request, role-specific, and separable from
privileged preflight commands.
- Corrected host-local binds, subactor overlay publication, stable
RPC module exposure, and missing-actor handling in the two-host
example.
- Updated the README so Tractor remains unprivileged and the local
versus remote overlay roles are explicit.
> `git diff 768b531662a88037e3988885102d2ff24a7ce34e..HEAD -- pyproject.toml uv.lock ai/prompt-io/`
- Regenerated `uv.lock` for the exact merged `py-multiaddr` WireGuard
codec revision.
- Replaced mutable historical prompt diff pointers with immutable
refs and normalized the substantive scope without rewriting the
historical raw response.
## Verification output
- `git diff --check`: passed.
- `uv lock --check`: passed.
- Ruff on the WireGuard example directory: passed.
- Python compilation of the WireGuard example directory: passed.
- Tractor imported from the PR worktree's existing environment.
- WireGuard address round-trip, local/peer role checks, malformed
base64 rejection, and nested-tunnel rejection: passed.
- Three independent final re-reviews reported no actionable
findings in the shared/TIPC, QUIC, or WireGuard slices.
- No live TIPC, iroh/UniFFI, or WireGuard network test was run.

View File

@ -47,7 +47,7 @@ Here is a small example from piker,
We should take whatever common API is needed to support this and
distill it into a
```python
tractor.discovery.parse_endpoints(
tractor.net.parse_endpoints(
) -> dict[
str,
list[Address]

View File

@ -33,19 +33,35 @@ doc in the same PR.
## 1. The backend duck-type (empirical, from `_tcp.py`/`_uds.py`)
A transport backend is **one module** under `tractor/ipc/`
exposing exactly four things. There is no ABC to subclass and no
plugin entrypoint; wiring is by explicit table registration
(§2) plus one piece of reflection (§1.3).
A transport backend is **one module** under `tractor/ipc/`.
There is no ABC to subclass and no plugin entrypoint; wiring is by
explicit table registration (§2) plus one piece of reflection
(§1.3).
Keep two contracts distinct:
- `tractor.discovery._addr.Address` is a static `Protocol`. It
declares address-wrapper members including `namespace`,
`open_listener()` and `close_listener()`.
- the runtime's empirical contract is what `_tcp.py`, `_uds.py`
and `_server.py` actually call. The current address classes do
not implement every declared `Address` member: listener
lifecycle is module-level, `def_bindspace` is used despite not
being declared by the `Protocol`, and `namespace` remains
aspirational.
Until those surfaces are deliberately reconciled, implement the
empirical module contract below and update the static `Protocol`
only when the runtime really consumes the new member. Do not claim
that structural conformance alone defines a backend.
### 1.1 `class <Proto>Address(msgspec.Struct, frozen=True)`
Structurally conforms to the `Address` `Protocol` in
`tractor/discovery/_addr.py:82`. Required surface:
The runtime-consumed address-wrapper surface is:
| member | kind | notes |
| --- | --- | --- |
| `proto_key` | `ClassVar[str]` | the wire/registry key, e.g. `'tcp'`, `'uds'` |
| `proto_key` | `ClassVar[str]` | internal transport key, e.g. `'tcp'`, `'uds'` |
| `unwrapped_type` | `ClassVar[type]` | the primitive tuple shape |
| `def_bindspace` | `ClassVar` | default bindspace value |
| `is_valid` | `@property -> bool` | "is this a *dialable/bindable* addr" |
@ -81,22 +97,32 @@ Hard constraints learned from the existing two:
paper over it at best.
**The fix, and the recommended prerequisite for all three
backends: make the unwrapped form carry an explicit
proto-key, using the `multiaddr` protocol name as the
canonical spelling** — `('tcp', host, port)`,
`('unix', path)`, `('udp', ...)`, `('tipc', stype, inst,
scope)`. Then `wrap_address()` collapses from an
order-sensitive `match` to `_address_types[addr[0]]`, and the
whole collision class stops existing. Note this *also* aligns
the on-wire form with `mk_maddr()`/`parse_maddr()`, so the two
representations stop being independent inventions.
backends: make the unwrapped form carry an explicit internal
proto-key** — `('tcp', host, port)`,
`('uds', filedir, filename)`, `('tipc', stype, inst, scope)`.
The tag must be a `TransportProtocolKey`/registry key. In
particular it is **`'uds'`, not the external multiaddr spelling
`'unix'`**. If a wire or display format uses a different name,
name that translation explicitly; today `_multiaddr.py` maps
internal `uds` to external `/unix/`. Then `wrap_address()` can
dispatch through `_address_types[addr[0]]` without an
order-sensitive shape match.
Two consequences to plan for:
- it's a **wire-format change** (`SpawnSpec`,
`_root_mailbox`, `_registry_addrs`) plus every test fixture
and downstream config (`piker`'s `[network]` table). It
wants its **own migration commit, landed before any new
backend**, not smuggled into one.
- it's a **wire-format change**. Widen and keep synchronized
`discovery._addr.UnwrappedAddress` and the duplicate wire
alias in `msg.types`; change `SpawnSpec.reg_addrs` and
`.bind_addrs`, not only `_root_mailbox` and
`_registry_addrs`. Audit the related `RuntimeVars`
`_root_mailbox`/`_root_addrs` annotations, `Actor.reg_addrs`
and accept-address annotations, channel/spawn signatures,
fixtures, and downstream config (`piker`'s `[network]`
table). `msgspec` rejects a union containing multiple
array-like tuple shapes, so #493 used
`tuple[str|int, ...]` as the truthful transitional wire type;
the complete proto-key migration can restore per-proto
validation. This wants its **own migration commit, landed
before any new backend**, not smuggled into one.
- it's the moment to **stop handing raw unwrapped tuples to
users at all.** The long-term shape is: `Address` subtypes
are the public currency and `UnwrappedAddress` becomes an
@ -104,15 +130,21 @@ Hard constraints learned from the existing two:
`ipaddress` uses (you pass `IPv4Address`, not a 4-tuple).
Public API should accept `Address|maddr-str` and treat bare
tuples as legacy-tolerated input, ideally deprecated.
- **`.get_random()` must be collision-free without a live
runtime.** See the `UDSAddress.get_random()` uuid-token
- **`.get_random()` must not deterministically alias without a
live runtime.** See the `UDSAddress.get_random()` uuid-token
comment (`_uds.py:207-220`): with no `current_actor()` the
sockname degenerates to a pure fn of `(prefix, pid)` and two
calls in one proc alias. Mix in a `uuid4().hex[:8]` token.
- **`.bindspace` semantics**: "the address' bindable space" —
ip/host for `tcp`, the socket-file *directory* for `uds`. For
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
"the if-available OS-specific network namespace key" and is
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
return value is a valid `from_addr()` input. This is fine for
TIPC (§3 of plan 01) and **is the main integration hazard for
iroh** (§3 of plan 02) — plans that break it must say so
explicitly and propose the upstream `_server.py` patch.
return value is a valid `from_addr()` input. That is false for
TIPC, whose listener sockname is an undialable port ID, and for
non-socket iroh. Both plans must use the explicit backend rebind
policy added at this integration point rather than pretending a
sockname is always an address replacement.
### 1.4 `class Msgpack<Proto>Stream(MsgpackTransport)`
@ -224,20 +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`
`TransportProtocolKey = Literal['tcp', 'uds', ...]` — add the
key. This `Literal` is the canonical set; `_testing/pytest.py`
drives `--tpt-proto` validation off `_addr._address_types`,
and the spawn-backend fixture already models the
"drive-the-set-from-the-Literal" pattern
(`pytest.py:870-880`) — do the same rather than hardcoding.
2. `tractor/discovery/_addr.py:173` `_address_types: bidict`
`{'<key>': <Proto>Address}`. Note it is a **`bidict`**, so
the mapping must stay 1:1.
internal key. This `Literal` is the **declared protocol-key
set**, not proof that a backend is usable on this host.
2. `tractor/discovery/_addr.py` `_address_protos` and
`_address_types: dict[str, Type[Address]]` — register
`'<key>': <Proto>Address`. `_address_types` is a plain
**`dict`, not a `bidict`**, and represents the backends this
build registers for import and dispatch. UDS is conditional on
`HAS_UDS`, while TIPC can remain registered on a host where its
kernel support is unavailable. An importable backend with a
runtime capability requirement therefore needs a separate
availability check. Never conflate this dispatch registry with
either host usability or the declared `TransportProtocolKey`
universe.
3. `tractor/discovery/_addr.py:181` `_default_lo_addrs`
`'<key>': <Proto>Address.get_root().unwrap()`.
⚠️ this dict is built at **import time**, so
@ -250,7 +289,7 @@ Adding a backend touches these and only these:
add a case iff your `unwrapped_type` isn't already uniquely
matched. **Preferably do the proto-key migration in §1.1
first**, after which this step becomes a one-line
`_address_types` entry instead of an order-sensitive `case`.
`_address_types` lookup instead of an order-sensitive `case`.
5. `tractor/ipc/_types.py``Address` union alias,
`_msg_transports` list, `_key_to_transport[('msgpack', key)]`,
`_addr_to_transport[<Proto>Address]`.
@ -262,9 +301,17 @@ Adding a backend touches these and only these:
`parse_maddr()`.
8. `tractor/ipc/__init__.py` — re-export if the backend has a
public surface.
9. `tractor/_testing/addr.py::get_rando_addr()` — per-proto
9. `tractor/discovery/_api.py::_is_local_addr()` and
`prefer_addr()` — define and test the backend's locality and
selection tier. The current order is UDS, local TCP, then
remote. A new backend must not silently fall into `remote` by
accident: for example TIPC node scope is local, cluster scope
is not known-local, and an observed address with unknown scope
must not be promoted. Preserve the last-registered tie-break
unless intentionally changing policy.
10. `tractor/_testing/addr.py::get_rando_addr()` — per-proto
branch so the whole suite can run under `--tpt-proto <key>`.
10. `pyproject.toml` — new deps go in an **optional extra**, never
11. `pyproject.toml` — new deps go in an **optional extra**, never
in `[project].dependencies`. See §5.
## 3. Where the `trio.SocketListener` assumption is load-bearing
@ -339,7 +386,10 @@ dep-free, or make that table lazy.
`_state._def_tpt_proto` + `_runtime_vars['_enable_tpts']`
(`pytest.py:807-835`). Adding the key to `_address_types` is
what makes `--tpt-proto <key>` legal (`pytest.py:795-800`
asserts the lookup).
asserts the lookup). Thus CLI acceptance follows the
build-registered `_address_types`, while type-level declarations
follow `TransportProtocolKey` and host usability follows each
backend's capability probe; test all three layers separately.
- The **acceptance bar** for every backend is: the *entire*
existing suite passes under `--tpt-proto <key>`, unmodified.
That is the whole point of the abstraction. Backend-specific
@ -353,8 +403,11 @@ dep-free, or make that table lazy.
`OSError(97, 'Address family not supported by protocol')`
because the `tipc` module isn't loaded. Put the predicate in
the backend module (so apps can use it too), not in the test.
- New pytest marks must be registered in `pyproject.toml`, per
the project's fix-warnings-at-source rule (gh #469).
- New pytest marks must be registered in
`_testing/pytest.py::pytest_configure()` with
`config.addinivalue_line()`, alongside the existing custom
marks. The repo has no `pyproject.toml` marker table. This is
still part of the fix-warnings-at-source rule (gh #469).
## 7. Code style (non-negotiable, matches the repo)

View File

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

View File

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

View File

@ -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,
`_addr.py:262`) but `parse_maddr()` only knows
`/ip4|ip6/<h>/tcp/<p>` and `/unix/<p>`; a `.../wg/u<key>`
maddr raises `ValueError('Unsupported multiaddr protocol
combo')`.
`_addr.py:262`). `parse_maddr()` and `mk_maddr()` support plain
TCP/UDS addresses plus nested, canonical bearer-first `/wg/`
stacks represented locally as `TunnelledAddress` wrappers.
- there is no `wg` proto in the multiaddr *spec* yet, but
multiformats/py-multiaddr#108 (key form `u<base64url>`) is
**merged** as of 2026-07-28 (`f86519da`) — and unreleased, the
latest `0.2.0` predating it. Spec registration is still tracked
by multiformats/py-multiaddr#107 and gh #483.
- so **today's deployable story is declarative**: run `wg-quick`
out-of-band, parse the maddr, strip to the overlay
`(host, port)`, verify the pubkey against the live tunnel,
hand the overlay addr to `registry_addrs=`/`tpt_bind_addrs=`.
#482 already contains working example code for exactly this.
- **today's deployable story remains declarative**: run `wg-quick`
out-of-band, parse the maddr, strip its wrapper to the overlay
`(host, port)`, explicitly verify the declared pubkey against the
local interface key or configured peers with async
`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
(`_addr.py:94-101`, "the if-available OS-specific network
namespace key") and **no backend implements it**. This plan is
its first consumer.
namespace key"). `TunnelledAddress` implements it from its spec;
no concrete transport backend implements it yet.
## 2. Three layers, three PRs
| 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 |
| **B. `pyroute2` read/verify** | replace the `subprocess.run(['sudo','wg','show'])` shelling 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 |
| **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 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*, 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.
**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
tunnel: WGTunnelSpec # proto-specific, frozen
```
with `.proto_key` **delegating to `overlay.proto_key`** so every
existing table lookup (`_addr_to_transport`,
`enable_transports` guard at `_root.py:391`,
`transport_from_addr()`) keeps working untouched, and
`.unwrap()` delegating to `overlay.unwrap()` so **nothing new
crosses the wire**. `.namespace` and `.bindspace` come from
the tunnel spec. The wrapper is stripped (`→ .overlay`) at the
moment of bind/connect.
- ⚠️ `is_wrapped_addr()` (`_addr.py:194`) tests
`type(addr) in _address_types.values()` — a `bidict` of
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.
with `.proto_key`, `.bindspace`, and `.unwrap()` delegating to
the overlay so transport guards retain their existing meaning
and **nothing new crosses the wire**. `.namespace` derives from
the tunnel spec. Exact-type dispatch through
`_addr_to_transport`/`transport_from_addr()` still requires the
wrapper to be stripped (`→ .overlay`) at bind/connect time.
- ⚠️ `is_wrapped_addr()` explicitly recognizes
`TunnelledAddress` even though the wrapper is deliberately not
in `_address_types`: it has no `MsgTransport` of its own and
therefore gets no build-registered proto-key entry.
- the reflection in `Endpoint.start_listener()`
(`inspect.getmodule(self.addr)`) would resolve to the
*wrapper's* module, not the transport's. **So the wrapper
must be unwrapped before it reaches `Endpoint`** — i.e. by
the bindspace `@acm` (layer C) or by `parse_maddr()`
(layer A). State this loudly in the docstring; it's the #1
way to get this wrong.
the bindspace `@acm` (layer C) or explicitly via `.overlay`
or `strip_tunnels()` at each bind/dial boundary (layer A).
State this loudly in the docstring; it's the #1 way to get
this wrong.
- (b) add fields to each existing `Address` type. Rejected:
duplicates tunnel logic per-backend and pollutes `.unwrap()`.
@ -104,10 +102,10 @@ class WGTunnelSpec(
frozen=True,
):
peer_pubkey: str # std-base64 `wg(8)` form
bearer: tuple[str, int]|None = None
iface: str = 'wg0'
netns: str|None = None
# layer-C-only fields, unset in layer A
maybe_endpoint: tuple[str, int]|None = None
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
\_______ 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
@ -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
semantically inverted: it puts the overlay addr where the bearer
belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares
no overlay endpoint at all. `parse_wg_maddr()` in
`examples/multihost/wg_lan/` now rejects it with an actionable
error.
no overlay endpoint at all. `tractor.net.parse_wg_maddr()`
now rejects it with an actionable error.
Observed protocol-name lists, for writing the `match`:
| 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
third is an `Endpoint`:
| part | bound by | in the runtime? |
| part | socket owner / provisioner | runtime role |
| --- | --- | --- |
| bearer | kernel, via `wg-quick`/`pyroute2` | no |
| `/wg/u<key>` | nothing — it's an identity | no, verified out-of-band |
| overlay | `tractor`'s `IPCServer` | **yes**, as `.overlay` |
| 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 | parsed and explicitly verified |
| overlay | `tractor`'s `IPCServer` | application `MsgTransport`, as `.overlay` |
This owner-split is the real axis of the design, *not* whether
the maddr stack is "composed" (it is).
@ -168,7 +165,7 @@ Observed protocol-name lists, for writing the `match`:
| 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)` |
| per-seg maddrs | `ma.split()` |
| 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.
Always call it on a peeled sub-maddr, never the whole stack.
- `parse_maddr()` gains a case on
`[('ip4'|'ip6'), 'udp', 'wg', ('ip4'|'ip6'), <overlay-l4>]`
peel w/ the API above, decode the multibase key to std-base64,
and return `TunnelledAddress(overlay=..., tunnel=WGTunnelSpec(
...))` w/ the bearer recorded in the spec.
- keep the existing 2-proto cases byte-identical; add the new
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.
- keep the existing 2-proto cases byte-identical; add
`case _ if 'wg' in proto_names:` after them.
- that case delegates to `parse_wg_maddr()`, which repeatedly
peels the last `/wg/`, decodes its key to std-base64, records
its bearer in `WGTunnelSpec`, and wraps the overlay in one
`TunnelledAddress` per segment.
- `mk_maddr()` inverse for `TunnelledAddress` is just
`.encapsulate()` composition; don't rebuild `str`s by hand.
- **pending an upstream release**: py-multiaddr#108 is merged, so
`Multiaddr('/…/wg/u…')` parses — but off a `[tool.uv.sources]`
`rev` pin, since no release carries the codec. Gate the tests
on `_have_wg_maddr_proto()`, implemented as
`Multiaddr('/…/wg/u…')` parses off a PEP 621 direct-revision pin,
since no release carries the codec. Gate parser entry on
`_wg_proto_code()`, implemented as
`protocols.protocol_with_name('wg')` under
`except ProtocolNotFoundError`. Do **not** probe by parsing 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
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
*pure functions* + one impure probe, cleanly separated:
The parser/key-codec helpers and async production verifier live in
`tractor/net/_tunnel.py`; parsing remains pure while verification
is an explicit, impure caller step:
```python
def parse_wg_maddr(maddr: str) -> TunnelledAddress: ... # pure
def wg8_pubkey(multibase_key: str) -> str: ... # pure
def verify_wg_peer(spec: WGTunnelSpec) -> bool: ... # impure probe
def parse_wg_maddr(maddr: str|Multiaddr) -> TunnelledAddress: ...
def mb_pubkey(wg8_key: str) -> str: ...
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>
peers`), but it must be a *single* function so layer B swaps
only its body. Never call it implicitly from
Layer A's example-local `verify_wg_key()` used role-specific
`wg show <if> public-key|peers` queries. Layer B replaces it with
`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
side-effect-free; verification is the *caller's* explicit step
(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()`
regression (a tunnelled maddr `str``TunnelledAddress`; 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.
**Recommended split**: ship (1) first so layer B is a small,
reviewable, behaviour-preserving swap of `verify_wg_peer()`'s
body; then land (2) as a follow-up commit for the read path
(`wg get`, `link get`) where the sans-io surface is smallest,
and keep (1) for the privileged mutating ops. Measure before
converting anything else — there is no perf argument here, only
a "no foreign event loop in a trio actor" argument, which (1)
already satisfies (a thread is not an event loop).
reviewable replacement of the example-local verification probe with
production `verify_wg_peer()`; then land (2) as a follow-up commit for
the read path (`wg get`, `link get`) where the sans-io surface is
smallest, and keep (1) for the privileged mutating ops. Measure before
converting anything else — there is no perf argument here, only a "no
foreign event loop in a trio actor" argument, which (1) already
satisfies (a thread is not an event loop).
Explicitly **do not** pull in `trio-asyncio` for pyroute2: it
would be the one place in the runtime where an asyncio loop
exists for no reason.
Explicitly **do not** pull in `trio-asyncio` for pyroute2 or infect
every wg-using actor merely to service one-shot netlink calls. A
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
@ -306,7 +309,8 @@ async def read_wg_peers(
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)`.
---
@ -319,57 +323,268 @@ data-structure which can easily be passed to nested `@acm`s
which consecutively setup nested net bindspaces for binding the
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
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
async def open_bindspace(
addr: TunnelledAddress,
) -> AsyncGenerator[Address, None]:
spec: BindspaceSpec,
) -> AsyncGenerator[Bindspace, None]:
'''
Enter the net-bindspace implied by `addr`'s tunnel stack,
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`.
Provision/borrow one bindspace and yield its live capability.
'''
```
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
@acm
async def open_netns(name: str) -> AsyncGenerator[None, None]: ...
async def open_netns(
spec: BindspaceSpec,
) -> AsyncGenerator[Bindspace, None]: ...
@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
(`contextlib.AsyncExitStack` for the N-deep case). The
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
it already returns `dict[name, list[Address]]` and the
`multiaddr_declare_eps.md` sketch anticipates the recursive
`dict[str, list[Address]]|dict[...]` return for tunnelled
entries. Extend it to carry the tunnel stack, not to *enter* it.
`WGInterfaceConfig` and each `WGPeerConfig` are process-local and
rejected by the global `ProcessLocal` wire guard. The interface config
owns its private key, local addresses and listen port; each peer owns
its public key, allowed CIDRs, optional endpoint, preshared key and
keepalive. Reprs redact private/preshared keys. `WGTunnelSpec` remains
serializable public maddr-derived identity/endpoint data. This split
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.
`('netns', 'tractor-wg0')`.
- **and** the existing backends should implement it as `None`
explicitly (they currently just don't define it), so the
Protocol stops lying.
- consumers to audit: nothing reads `.namespace` today — so
adding it is safe, but the *point* is that
`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.
The composition driver folds a list of specs into nested contexts with
`contextlib.AsyncExitStack` for the N-deep case. The
`parse_endpoints()` API (`_multiaddr.py:189`) is the front door:
its `ParsedEndpoints` values already contain
`Address|TunnelledAddress` declarations and preserve each tunnel
stack for the eventual bindspace handler. It carries declarations;
it does not *enter* their bindspaces.
### 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
**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*
in practice. Therefore: **netns membership belongs to the
actor process, decided before the runtime binds**, not to a
mid-life `@acm`. Design:
- the root/parent decides the netns for a subactor and passes
it in the spawn spec (there's already
mid-life actor API. Design:
- the root/parent decides the `BindspaceSpec`, provisions or
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
`_runtime.py:1595-1615` — the netns rides alongside).
- the child, in `_runtime.async_main()` **before**
`IPCServer.listen_on()`, enters it.
- the mid-life `@acm` form is then only for the *root* /
single-actor case, and for iface creation (which is
genuinely scoped).
- the child spawn/bootstrap trampoline calls `setns()` **before**
`_runtime.async_main()`, `IPCServer.listen_on()`, parent-channel
connection, or creation of any worker thread/socket.
- `spawn._netns.enter_netns()` is the first private bootstrap
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
`RuntimeError` if `open_netns()` is entered after any
listener exists.
- privileges: iface/netns creation needs `CAP_NET_ADMIN`.
Never `sudo` from inside the runtime. Two supported modes:
`RuntimeError` if namespace entry is attempted after bootstrap.
- capabilities: iface/route/WG configuration needs `CAP_NET_ADMIN`;
creating or entering a Linux network namespace normally requires
`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,
and what #482 documents), (ii) runtime-managed when the
process already holds the cap. Detect with a cheap
`os.geteuid()==0 or CAP_NET_ADMIN in /proc/self/status`
probe and *fail loudly with an actionable message* otherwise.
and what #482 documents), (ii) runtime-managed when the supervising
process/helper holds the required caps. Probe exact required caps 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
already gone must not strand the rest of the teardown — the
exact lesson `_uds.close_listener()`'s `FileNotFoundError`
tolerance and `_serve_ipc_eps()`'s per-ep `try/except`
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
enter/exit ordering (outermost-last-out) via a trace list.
- integration, gated on `CAP_NET_ADMIN` (skip otherwise, and in
CI run it in a `--cap-add NET_ADMIN` container job): create two
netns + a wg pair entirely in-process, boot a `tractor` root in
one and a subactor in the other, `find_actor()` across the
tunnel. This is a *fantastic* test to have and is fully
self-contained — no second host, no `sudo` in the test body.
- the `to_thread`-netns-mismatch regression from §5.3, written
- integration, gated on `CAP_NET_ADMIN` plus `CAP_SYS_ADMIN` in the
owning user namespace (or a tested userns/helper equivalent; skip
otherwise). In CI, grant both capabilities explicitly. Create two
netns + a wg pair entirely in-process, boot a `tractor` root in one
and a subactor in the other, then `find_actor()` across the tunnel.
This is fully self-contained — no second host and no `sudo` in the
test body.
- the `to_thread`-netns-mismatch regression from §5.4, written
**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.
Design for it now (union + `match`), implement only `wg` +
`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.
## 7. Non-goals
@ -466,11 +729,17 @@ consider doing it *first* for exactly that reason.
| risk | mitigation |
| --- | --- |
| `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first |
| py-multiaddr#108 merged but unreleased | `[tool.uv.sources]` `rev` pin + `_have_wg_maddr_proto()` gate; layer A's overlay-addr path works regardless |
| `TunnelledAddress` leaks into `Endpoint` and breaks `inspect.getmodule()` | unwrap at parse/bindspace boundary; assert `not isinstance(ep.addr, TunnelledAddress)` in `Endpoint.__post_init__` |
| `to_thread` worker runs in the wrong netns | §5.4; pass `netns=` to pyroute2 or pin a worker; test-first |
| namespace name is renamed/replaced between provision and spawn | pass an open namespace FD; verify `(key, inode)` after child entry |
| 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 |
| 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()` |
## 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
`MsgTransport.maddr` to always return `Multiaddr` (the third
#443 bullet)
- runtime-managed wg key rotation / peer add-remove as a
`tractor` service actor — the natural "actor that owns the
network" demo
- first-child `wgman` prototype: concurrent bootstrap, scoped sibling
access, infected-asyncio pyroute2 ownership and restart reconciliation
- runtime-managed wg key rotation / peer add-remove through `wgman`
the natural "actor that owns the network" demo

View File

@ -7,9 +7,9 @@ different model/provider) without design or lib-selection drift.
**Read [`00_shared_backend_contract.md`](./00_shared_backend_contract.md)
first** — it is the normative description of what a `tractor`
transport backend *is* as of `main@83b34884` (the backend
duck-type, the 10-item registration checklist, the test-harness
plumbing, the code-style rules). The three plans assume it and
document only their own deltas.
duck-type, registration and address-selection wiring, the
test-harness plumbing, the code-style rules). The three plans
assume it and document only their own deltas.
| plan | issue | dep | size | lands |
| --- | --- | --- | --- | --- |
@ -23,10 +23,12 @@ Headline conclusions:
`trio.SocketListener` are address-family agnostic (only
`SOCK_STREAM` + a trio socket), and CPython ships `AF_TIPC` +
23 `TIPC_*` constants. So the backend is ~one module of
contract boilerplate, zero new deps, and it buys
*kernel-native* service discovery: `bind()` publishes,
`connect()`-by-name resolves — no registrar in the loop.
(`modprobe tipc` is required; hard-gate everything.)
contract boilerplate, zero new deps, and it buys kernel-native
service primitives: `bind()` publishes, known-address
`connect()` resolves, and topology events report publication
changes. Actor-name lookup, registrar state and split-brain-safe
election remain separate work. (`modprobe tipc` is required;
hard-gate everything.)
- **QUIC's cost is entirely in two adapters**, not in QUIC. The
`iroh` python bindings are `uniffi`-generated asyncio, but the
asyncio dependency is confined to *one* future-poll callback —

View File

@ -53,6 +53,7 @@ Most-used names at a glance:
core
context
discovery
net
errors
msg
trionics

View File

@ -84,6 +84,7 @@ already distributed-system aware.
.. seealso::
:doc:`/explain/architecture` for the transport/server
internals, :doc:`/api/discovery` for how channel addresses
get registered and found, and :doc:`/api/msg` for the codec
layer every channel speaks.
internals, :doc:`/api/net` for network declarations,
:doc:`/api/discovery` for how channel addresses get registered
and found, and :doc:`/api/msg` for the codec layer every channel
speaks.

95
docs/api/net.rst 100644
View File

@ -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

View File

@ -264,7 +264,7 @@ terminology is retired: it's *registrar*/*registry* everywhere now
substitute "registrar" and you're up to date.
.. note::
Multihoming nerds: ``tractor.discovery`` also ships
Multihoming nerds: ``tractor.net`` ships
libp2p-style *multiaddr* helpers — ``mk_maddr()`` and
``parse_maddr()`` — for describing transport endpoints as
structured strings.

View File

@ -21,16 +21,16 @@ Supersedes the example set in gh
/ip4/192.168.1.50/udp/51820/wg/u<A_pub>/ip4/10.0.11.1/tcp/1616
\____ wg bearer ___________/\__ key __/\____ tractor ep _____/
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:
| part | who binds it | in the runtime? |
| part | socket owner / provisioner | runtime role |
| --- | --- | --- |
| `/ip4/../udp/51820` bearer | kernel via `wg-quick`/`pyroute2` | no |
| `/wg/u<key>` | nothing — it's an identity | no, verified out-of-band |
| `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | **yes**, as `.overlay` |
| `/ip4/../udp/51820` bearer | kernel-owned; `wg-quick` now, tractor bindspace later | control-plane metadata |
| `/wg/u<key>` | nothing — it's an identity | parsed, verified explicitly |
| `/ip4/../tcp/1616` overlay | `tractor`'s `IPCServer` | application `MsgTransport` |
Verified against py-multiaddr
[#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
release yet — the latest `0.2.0` (2026-03-17) predates it and has
no `wg` codec. So `pyproject.toml` carries a temporary
`[tool.uv.sources]` `rev` pin at the merge commit, and a plain
no `wg` codec. So `pyproject.toml` temporarily pins the merge commit
in its PEP 621 dependency metadata, and a plain
```bash
uv sync
uv sync --extra wg
```
gets you a `wg`-aware `multiaddr`. That pin goes away once a
release carries the codec. You also need `multibase`:
```bash
uv pip install multibase
```
gets you a `wg`-aware `multiaddr` plus pyroute2's Linux netlink API.
The multiaddr pin goes away once a release carries the codec.
`py-multibase` is a direct project dependency, so no separate install
command is needed.
Without the codec `parse_wg_maddr()` raises immediately with an
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
tunnel API (`.decapsulate_code()`, `.split()`, `.join()`,
`.encapsulate()`, `.value_for_protocol()`) rather than any
bespoke segment slicing — see its README "En/decapsulate" and
"Tunneling" sections. gh #429 was about *dropping* our NIH
parser, and that applies to peeling a tunnel stack just as much
as to decoding one proto.
parser, and that applies to peeling nested tunnel stacks just as
much as to decoding one proto.
## 0. tunnel setup (out-of-band, both hosts)
@ -106,8 +105,11 @@ AllowedIPs = 10.0.11.1/32
PersistentKeepalive = 25
```
Note how `ListenPort` and `Endpoint` are exactly the maddr's
bearer segment, and `[Interface] Address` is its overlay host.
This example configures host A's `ListenPort` and host B's
`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
sudo wg-quick up wg0 # both hosts
@ -118,16 +120,34 @@ ping -c1 10.0.11.1 # from B
```bash
python -c "
import base64, multibase
from tractor.net import mb_pubkey
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
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
# host A
@ -137,6 +157,16 @@ python host_a_srv.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
`portal.run()` refs the fn by module path — standard `tractor`
RPC semantics.
@ -153,24 +183,38 @@ Four corrections, all from
all. `parse_wg_maddr()` now rejects it with an actionable
error.
2. **parsing is pure.** #482's helper had the key-check adjacent
to the parse; `verify_wg_peer()` is now a separate, explicitly
composed step that the caller invokes. A parser that shells
out is a nasty surprise.
3. **no `sudo`.** #482 ran `sudo wg show`; a library/example must
never escalate. `wg show` works unprivileged for read on most
setups; if yours needs root, run the script as root rather
than embedding `sudo`.
to the parse; async `verify_wg_peer()` is now a separate,
explicitly composed step that the caller invokes. Implicit
kernel inspection from a parser is a nasty surprise.
3. **no `sudo` or subprocess.** #482 ran `sudo wg show`; tractor's
helper reads generic netlink through pyroute2 and never attempts
privilege escalation or namespace creation. The caller must
already have the required inspection authority.
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`
registered in `_address_types` — that table is a `bidict`
(1:1 proto-key↔type) and `_addr_to_transport` wants a
`MsgTransport` per addr-type, which `wg` doesn't have.
registered in `_address_types` — that registry maps available
transport keys to concrete address types, and
`_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
`tractor.discovery` as a `TunnelledAddress` whose
`.proto_key`/`.unwrap()` delegate to `.overlay`, plus
`open_bindspace()` `@acm`s that create/tear down the iface +
netns via `pyroute2`, is layers A→C of the plan doc.
The `TunnelledAddress`, native maddr parser, bindspace lifecycle, and
explicit pyroute2 verification APIs live in `tractor.net`. Keep the
owning bindspace context outside the root actor so its namespace FD
remains live through complete actor teardown:
```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.

View File

@ -10,11 +10,11 @@ from __future__ import annotations
import tractor
import trio
from wg_maddr import (
from tractor.net import (
TunnelledAddress,
mk_maddr,
parse_wg_maddr,
verify_wg_peer,
WGTunnelledAddr,
)
# bearer = host A's underlay `(ip, wg ListenPort)`
@ -33,27 +33,31 @@ async def echo(msg: str) -> str:
async def main():
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
assert verify_wg_peer(addr), (
f'wg pubkey from maddr not active on wg0 !\n'
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
if not await verify_wg_peer(addr.tunnel):
raise RuntimeError(
f'Maddr key is not wg0 local public key!\n'
f'maddr: {WG_MADDR}\n'
f'key: {addr.peer_pubkey}\n'
f'key: {addr.tunnel.peer_pubkey}\n'
)
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'
)
async with tractor.open_nursery(
# 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],
enable_transports=[addr.overlay_proto],
enable_transports=[addr.overlay.proto_key],
) as an:
overlay_host, _ = addr.unwrap()
await an.start_actor(
'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()

View File

@ -8,37 +8,42 @@ from __future__ import annotations
import tractor
import trio
from host_a_srv import echo # noqa: F401 (RPC refs it by mod path)
from wg_maddr import (
from tractor.net import (
TunnelledAddress,
parse_wg_maddr,
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
WG_MADDR: str = (
'/ip4/192.168.1.50/udp/51820'
'/wg/u<A_pub_b64url>'
'/ip4/10.0.11.1/tcp/1616'
)
LOCAL_OVERLAY_BIND: tuple[str, int] = ('10.0.11.2', 0)
async def main():
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
assert verify_wg_peer(addr), (
f'wg pubkey from maddr not a peer on wg0 !\n'
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
if not await verify_wg_peer(addr.tunnel):
raise RuntimeError(
f'Maddr key is not a configured wg0 peer!\n'
f'maddr: {WG_MADDR}\n'
f'key: {addr.tunnel.peer_pubkey}\n'
)
async with (
tractor.open_root_actor(
name='wg_client',
tpt_bind_addrs=[LOCAL_OVERLAY_BIND],
registry_addrs=[addr.overlay],
enable_transports=[addr.overlay_proto],
enable_transports=[addr.overlay.proto_key],
),
tractor.find_actor(
'echo_srv',
registry_addrs=[addr.overlay],
raise_on_none=True,
) as portal,
):
res: str = await portal.run(

View File

@ -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()
)

View File

@ -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.

View File

@ -48,7 +48,10 @@ dependencies = [
# typed IPC msging
"msgspec>=0.20.0",
"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",
# per-actor `argv[0]` proc-title for OS-level diag tools
# (`ps`, `top`, `psutil`-backed tooling like `acli.pytree`).
@ -59,6 +62,12 @@ dependencies = [
"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 ------
[dependency-groups]
@ -165,20 +174,8 @@ sync_pause = {requires-python = ">=3.13, <3.14"}
# editable = true
# ------ tool.uv.sources ------
# TODO, distributed (multi-host) extensions
# linux kernel networking
# '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' }
# Linux kernel networking is provided by the optional `wg` extra.
# Add any temporary `pyroute2` source overrides here.
# ------ tool.uv.sources ------
@ -194,6 +191,9 @@ python-preference = 'system'
# ------ tool.uv ------
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.sdist]
include = ["tractor"]

View File

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

View File

@ -18,7 +18,7 @@ from tractor.devx import dump_on_hang
from tractor.trionics import collapse_eg
from tractor._testing import tractor_test
from tractor.discovery._addr import wrap_address
from tractor.discovery._multiaddr import mk_maddr
from tractor.net import mk_maddr
import trio

View File

@ -24,7 +24,7 @@ from tractor.discovery import _api
from tractor.discovery._addr import (
wrap_address,
)
from tractor.discovery._multiaddr import mk_maddr
from tractor.net import mk_maddr
from tractor.ipc import _connect_chan
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:
w = wrap_address(uw_addr)
if w.proto_key == 'tcp':
_host, port = uw_addr
_, _host, port = uw_addr
assert port > 0
trio.run(_main)
@ -443,7 +443,7 @@ def test_tpt_bind_addrs_as_maddr_str(
for uw_addr in actor.accept_addrs:
w = wrap_address(uw_addr)
if w.proto_key == 'tcp':
_host, port = uw_addr
_, _host, port = uw_addr
assert port > 0
trio.run(_main)
@ -475,7 +475,7 @@ def test_registrar_merge_binds_union(
# actually differ (always true for TCP, may
# collide for UDS).
expect_disjoint: bool = (
tuple(reg_addr) != rando.unwrap()
reg_wrapped.unwrap() != rando.unwrap()
)
async def _main():

View File

@ -0,0 +1,91 @@
'''
Tunnel annotation peeling at the outbound IPC transport boundary.
'''
from __future__ import annotations
import pytest
import trio
from tractor.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,)

View File

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

View File

@ -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)

View File

@ -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)

View File

@ -0,0 +1 @@
'''Network declaration and lifecycle tests.'''

View File

@ -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)

View File

@ -1,7 +1,6 @@
'''
Multiaddr construction, parsing, and round-trip tests for
`tractor.discovery._multiaddr.mk_maddr()` and
`tractor.discovery._multiaddr.parse_maddr()`.
`tractor.net.mk_maddr()` and `tractor.net.parse_maddr()`.
'''
from pathlib import Path
@ -10,18 +9,39 @@ from types import SimpleNamespace
import pytest
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._uds import UDSAddress
from tractor.discovery._multiaddr import (
mk_maddr,
parse_maddr,
parse_endpoints,
_tpt_proto_to_maddr,
_maddr_to_tpt_proto,
)
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():
'''
`_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')
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():
@ -179,7 +199,7 @@ def test_parse_maddr_tcp_ipv6():
result = parse_maddr('/ip6/::1/tcp/5678')
assert isinstance(result, TCPAddress)
assert result.unwrap() == ('::1', 5678)
assert result.unwrap() == ('tcp', '::1', 5678)
def test_parse_maddr_uds():
@ -192,9 +212,10 @@ def test_parse_maddr_uds():
result = parse_maddr('/unix/tmp/tractor_test/test.sock')
assert isinstance(result, UDSAddress)
filedir, filename = result.unwrap()
assert filename == 'test.sock'
assert str(filedir) == '/tmp/tractor_test'
assert result.unwrap() == (
'unix',
'/tmp/tractor_test/test.sock',
)
def test_parse_maddr_unsupported():
@ -210,6 +231,181 @@ def test_parse_maddr_unsupported():
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(
'addr',
[
@ -249,7 +445,22 @@ def test_wrap_address_maddr_str():
result = wrap_address('/ip4/127.0.0.1/tcp/9999')
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 ------
@ -270,11 +481,11 @@ def test_parse_endpoints_tcp_only():
reg_addr = result['registry'][0]
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]
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():
@ -294,12 +505,37 @@ def test_parse_endpoints_mixed_tpts():
assert len(addrs) == 2
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)
filedir, filename = addrs[1].unwrap()
assert filename == 'broker.sock'
assert str(filedir) == '/tmp/tractor'
assert addrs[1].unwrap() == (
'unix',
'/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():
@ -315,7 +551,7 @@ def test_parse_endpoints_unwrapped_tuples():
addr = result['ems'][0]
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():
@ -335,10 +571,10 @@ def test_parse_endpoints_mixed_str_and_tuple():
assert len(addrs) == 2
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 addrs[1].unwrap() == ('127.0.0.1', 8888)
assert addrs[1].unwrap() == ('tcp', '127.0.0.1', 8888)
def test_parse_endpoints_unsupported_proto():

View File

@ -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)

View File

@ -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]

View File

@ -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',
]

View File

@ -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)

View File

@ -12,6 +12,7 @@ from typing import (
get_type_hints,
)
import tractor
from tractor.discovery import (
_addr,
_multiaddr,
@ -44,8 +45,8 @@ def test_lazy_to_asyncio_package_api():
Before the lazy conversion, package import side effects exposed
`to_asyncio` to `dir()` and wildcard imports. Exercise those APIs
in cold interpreters so this test proves normal `import tractor`
leaves `asyncio` unloaded, while discovery and wildcard access
still advertise and resolve the public submodule.
leaves `asyncio` unloaded, while introspection and wildcard
access still advertise and resolve the public submodule.
'''
cold = run_cold_import(
@ -102,6 +103,12 @@ def test_cold_import_budget():
'bidict',
'colorlog',
'multiaddr',
'multibase',
'pyroute2',
'tractor.discovery._multiaddr',
'tractor.net',
'tractor.net._bindspace',
'tractor.net._tunnel',
'wrapt',
)
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():
'''
Resolve annotations without importing optional dependencies.
@ -157,4 +281,7 @@ def test_lazy_annotation_names_resolve():
assert get_type_hints(_addr.Address.get_random)[
'current_actor'
] is Any
assert get_type_hints(tractor.open_root_actor)[
'bindspace'
] == Any|None
assert _addr.__annotations__['_address_types'].startswith('dict')

View File

@ -8,6 +8,7 @@ import trio
import tractor
from tractor._testing import tractor_test
from tractor.discovery._addr import wrap_address
def test_no_runtime():
@ -48,7 +49,7 @@ async def test_self_is_registered_localportal(reg_addr):
with trio.fail_after(0.2):
sockaddr = await portal.run_from_ns(
'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):

File diff suppressed because it is too large Load Diff

View File

@ -10,15 +10,25 @@ API design.
"""
from functools import partial
from types import SimpleNamespace
from typing import (
Any,
)
from unittest.mock import (
AsyncMock,
MagicMock,
)
import pytest
import trio
import tractor
from tractor._exceptions import ActorFailure
from tractor._testing import tractor_test
from tractor.spawn import (
_spawn,
_trio,
)
data_to_pass_down = {
'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(
should_be_root: bool,
data: dict,

View File

@ -18,6 +18,7 @@
tractor: structured concurrent ``trio``-"actors".
"""
from types import ModuleType as _ModuleType
from ._clustering import (
open_actor_cluster as open_actor_cluster,
@ -82,6 +83,7 @@ __all__: tuple[str, ...] = tuple(
for name in globals()
if not name.startswith('_')
) + (
'net',
'to_asyncio',
)
@ -92,21 +94,18 @@ def __dir__() -> list[str]:
def __getattr__(name: str):
'''
PEP 562 lazy sub-module loading, presently only for
`.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.
PEP 562 lazy public sub-package loading.
Any `tractor.to_asyncio.<attr>` access (or a
`from tractor import to_asyncio`) still works, the
sub-mod is simply imported on first-access instead
of at pkg-import time.
`tractor.to_asyncio` transitively imports `asyncio`, while
`tractor.net` owns optional network dependencies. Neither is
needed by most applications merely importing the root package.
'''
if name == 'to_asyncio':
if name in ('net', 'to_asyncio'):
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(
f'module {__name__!r} has no attribute {name!r}'

View File

@ -26,7 +26,10 @@ from ast import literal_eval
from typing import TYPE_CHECKING
from .runtime._runtime import Actor
from .spawn._entry import _trio_main
from .spawn._entry import (
_consume_netns_bootstrap,
_trio_main,
)
if TYPE_CHECKING:
from .discovery._addr import UnwrappedAddress
@ -46,12 +49,41 @@ def parse_ipaddr(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(
uid: tuple[str, str],
loglevel: str | None,
parent_addr: UnwrappedAddress | None,
infect_asyncio: bool,
spawn_method: SpawnMethodKey = 'trio',
netns_bootstrap: tuple[int, int]|None = None,
) -> None:
'''
@ -62,7 +94,13 @@ def _actor_child_main(
invokes this from inside a fresh `concurrent.interpreters`
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`
# bugs we've encountered while running tractor — see
# `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
parser = argparse.ArgumentParser()
@ -121,7 +163,11 @@ if __name__ == "__main__":
parser.add_argument("--loglevel", type=str)
parser.add_argument("--parent_addr", type=parse_ipaddr)
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(
uid=args.uid,
@ -129,4 +175,9 @@ if __name__ == "__main__":
parent_addr=args.parent_addr,
infect_asyncio=args.asyncio,
spawn_method='trio',
netns_bootstrap=args.netns_bootstrap,
)
if __name__ == "__main__":
main()

View File

@ -18,6 +18,9 @@
Root actor runtime ignition(s).
'''
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import (
asynccontextmanager as acm,
)
@ -31,6 +34,7 @@ import sys
from typing import (
Any,
Callable,
TYPE_CHECKING,
)
import warnings
@ -63,6 +67,11 @@ from ._exceptions import (
RuntimeFailure,
)
if TYPE_CHECKING:
from .net._bindspace import Bindspace
else:
Bindspace = Any
logger = log.get_logger('tractor')
@ -153,9 +162,30 @@ async def maybe_block_bp(
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
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[
Address # `Address.get_random()` case
|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
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!
if already_actor := _state.current_actor(
@ -239,10 +273,29 @@ async def open_root_actor(
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()
async with maybe_block_bp(
async with (
_enter_root_bindspace(bindspace),
maybe_block_bp(
debug_mode=debug_mode,
maybe_enable_greenback=maybe_enable_greenback,
),
):
if enable_transports is None:
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
# proto if not already provided.
if not tpt_bind_addrs:
from .net._tunnel import strip_tunnels
for addr in ponged_addrs:
bindable_addr: Address = strip_tunnels(addr)
tpt_bind_addrs.append(
# XXX, these are `Address` NOT `UnwrappedAddress`.
#
@ -512,8 +568,8 @@ async def open_root_actor(
# protos we allocate port=0 such that the system
# allocates a random value at bind time; this
# happens in the `.ipc.*` stack's backend.
addr.get_random(
bindspace=addr.bindspace,
bindable_addr.get_random(
bindspace=bindable_addr.bindspace,
)
)

View File

@ -15,18 +15,8 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
Discovery (protocols) API for automatic addressing
and location management of (service) actors.
Actor discovery and registrar implementation package.
NOTE: this ``__init__`` only eagerly imports the
``._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.
Network declarations and helpers are public from `tractor.net`.
'''
from ._multiaddr import (
parse_endpoints as parse_endpoints,
parse_maddr as parse_maddr,
mk_maddr as mk_maddr,
)

View File

@ -19,7 +19,9 @@ from typing import (
Any,
Protocol,
ClassVar,
Literal,
Type,
TypeAlias,
TYPE_CHECKING,
)
@ -40,9 +42,13 @@ from ..ipc._uds import (
if TYPE_CHECKING:
# ONLY type-annots, the eager import costs ~4.5ms
# of `import tractor` wall-time (gh #470).
from tractor.net._tunnel import (
TunnelledAddress,
)
from ..runtime._runtime import Actor
else:
Actor = Any
TunnelledAddress = Any
log = get_logger()
@ -69,25 +75,44 @@ log = get_logger()
# seems like the right name as per,
# https://www.geeksforgeeks.org/introduction-to-address-descriptor/
#
UnwrappedAddress = (
# tcp/udp/uds
tuple[
str, # host/domain(tcp), filesys-dir(uds)
int|str, # port/path(uds)
TaggedTCPAddress: TypeAlias = tuple[
Literal['tcp'],
str,
int,
]
# ?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"
TaggedUnixAddress: TypeAlias = tuple[
Literal['unix'],
str,
]
TaggedUDSAlias: TypeAlias = tuple[
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`?
class Address(Protocol):
proto_key: ClassVar[str]
unwrapped_type: ClassVar[UnwrappedAddress]
unwrapped_type: ClassVar[type]
# TODO, i feel like an `.is_bound()` is a better thing to
# support?
@ -99,7 +124,7 @@ class Address(Protocol):
# TODO, maybe `.netns` is a better name?
@property
def namespace(self) -> tuple[str, int]|None:
def namespace(self) -> tuple[str, str|int]|None:
'''
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:
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:
@ -223,8 +258,16 @@ def mk_uuid() -> str:
def wrap_address(
addr: UnwrappedAddress|str,
) -> Address:
addr: (
TaggedAddress
|TaggedUDSAlias
|LegacyUnwrappedAddress
|list[str|int]
|str
|Address
|TunnelledAddress
),
) -> Address|TunnelledAddress:
'''
Wrap an `UnwrappedAddress` as an `Address`-type based
on matching builtin python data-structures which we adhoc
@ -246,6 +289,20 @@ def wrap_address(
# import pdbp; pdbp.set_trace()
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
case (
(str(), int())
@ -277,7 +334,7 @@ def wrap_address(
# multiaddr-format string, e.g.
# '/ip4/127.0.0.1/tcp/1616'
case str() if addr.startswith('/'):
from tractor.discovery._multiaddr import (
from tractor.net import (
parse_maddr,
)
return parse_maddr(addr)

View File

@ -38,9 +38,13 @@ if TYPE_CHECKING:
# `import tractor` path (gh #470).
from multiaddr import Multiaddr
from tractor.discovery._addr import Address
from tractor.net._tunnel import (
TunnelledAddress,
)
else:
Multiaddr = Any
Address = Any
TunnelledAddress = Any
# map from tractor-internal `proto_key` identifiers
# to the standard multiaddr protocol name strings.
@ -57,7 +61,7 @@ _maddr_to_tpt_proto: dict[str, str] = {
def mk_maddr(
addr: 'Address',
addr: 'Address|TunnelledAddress',
) -> Multiaddr:
'''
Construct a `Multiaddr` from a tractor `Address` instance,
@ -67,6 +71,13 @@ def mk_maddr(
'''
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
maddr_proto: str|None = _tpt_proto_to_maddr.get(proto_key)
if maddr_proto is None:
@ -76,7 +87,7 @@ def mk_maddr(
match proto_key:
case 'tcp':
host, port = addr.unwrap()
_, host, port = addr.unwrap()
ip = ipaddress.ip_address(host)
net_proto: str = (
'ip4' if ip.version == 4
@ -87,13 +98,12 @@ def mk_maddr(
)
case 'uds':
filedir, filename = addr.unwrap()
filepath = Path(filedir) / filename
_, sockpath = addr.unwrap()
# NOTE, strip any leading `/` to avoid
# double-slash `/unix//run/..` which the
# multiaddr parser rejects as "empty
# protocol path".
fpath_str: str = str(filepath).lstrip('/')
fpath_str: str = sockpath.lstrip('/')
return Multiaddr(
f'/{maddr_proto}/{fpath_str}'
)
@ -101,7 +111,7 @@ def mk_maddr(
def parse_maddr(
maddr_str: str,
) -> 'Address':
) -> 'Address|TunnelledAddress':
'''
Parse a multiaddr string into a tractor `Address`.
@ -113,7 +123,16 @@ def parse_maddr(
from tractor.ipc._tcp import TCPAddress
from tractor.ipc._uds import UDSAddress
try:
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] = [
p.name for p in maddr.protocols()
]
@ -136,6 +155,10 @@ def parse_maddr(
filename=sockpath.name,
)
case _ if 'wg' in proto_names:
from tractor.net._tunnel import parse_wg_maddr
return parse_wg_maddr(maddr)
case _:
raise ValueError(
f'Unsupported multiaddr protocol combo: '
@ -154,11 +177,11 @@ EndpointsTable = dict[
list[str|tuple], # maddr strs or UnwrappedAddress
]
# output table: actor/service name -> list of wrapped
# `Address` instances ready for transport binding.
# output table: actor/service name -> list of wrapped address
# declarations ready for bindspace handling.
ParsedEndpoints = dict[
str, # actor/service name
list['Address'],
list['Address|TunnelledAddress'],
]
@ -167,7 +190,7 @@ def parse_endpoints(
) -> ParsedEndpoints:
'''
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
a list of addresses in any format accepted by
@ -179,6 +202,8 @@ def parse_endpoints(
``/uds/`` proto_key)
- raw unwrapped tuples: ``('127.0.0.1', 1616)``
- 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
value list contains the corresponding `Address`

View File

@ -59,6 +59,9 @@ from tractor.msg import (
if TYPE_CHECKING:
from ._transport import MsgTransport
from tractor.net._tunnel import TunnelledAddress
else:
TunnelledAddress = Any
log = get_logger()
@ -182,16 +185,19 @@ class Channel:
@classmethod
async def from_addr(
cls,
addr: UnwrappedAddress,
addr: UnwrappedAddress|Address|TunnelledAddress,
**kwargs
) -> Channel:
if not is_wrapped_addr(addr):
addr: Address = wrap_address(addr)
from tractor.net._tunnel import strip_tunnels
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(
addr,
transport_addr,
**kwargs,
)
# XXX, for UDS *no!* since we recv the peer-pid and build out
@ -551,7 +557,7 @@ class Channel:
@acm
async def _connect_chan(
addr: UnwrappedAddress,
addr: UnwrappedAddress|Address|TunnelledAddress,
close_timeout: float|None = None,
) -> typing.AsyncGenerator[Channel, None]:
'''

View File

@ -59,12 +59,16 @@ from ..msg import (
from ..trionics import maybe_open_nursery
from ..runtime import _state
from .. import log
from ..discovery._addr import Address
from ..discovery._addr import (
Address,
UnwrappedAddress,
)
from ._chan import Channel
from ._transport import MsgTransport
if TYPE_CHECKING:
from ..net._tunnel import TunnelledAddress
from ..runtime._runtime import Actor
from ..runtime._supervise import ActorNursery
@ -627,6 +631,7 @@ class Endpoint(Struct):
'''
addr: Address
declared_addr: Address|TunnelledAddress
listen_tn: Nursery
stream_handler_tn: Nursery|None = None
@ -641,15 +646,27 @@ class Endpoint(Struct):
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(
self,
indent: int = 0,
privates: bool = False,
) -> str:
type_repr: str = type(self).__name__
namespace: tuple[str, str|int]|None = self.namespace
fmtstr: str = (
# !TODO, always be ns aware!
# f'|_netns: {netns}\n'
f' |.namespace: {namespace!r}\n'
f' |.addr: {self.addr!r}\n'
f' |_peers: {len(self.peer_tpts)}\n'
)
@ -927,9 +944,17 @@ class Server(Struct):
ep.addr for ep in eps
]
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 += (
f' |_endpoints: {repr_eps}\n'
f' |_namespaces: {repr_namespaces}\n'
# ^TODO? how to indent closing ']'..
)
@ -965,7 +990,9 @@ class Server(Struct):
async def listen_on(
self,
*,
accept_addrs: list[tuple[str, int|str]]|None = None,
accept_addrs: list[
UnwrappedAddress|Address|TunnelledAddress
]|None = None,
stream_handler_nursery: Nursery|None = None,
) -> list[Endpoint]:
'''
@ -1048,7 +1075,7 @@ async def _serve_ipc_eps(
*,
server: IPCServer,
stream_handler_tn: Nursery,
listen_addrs: list[tuple[str, int|str]],
listen_addrs: list[Address|TunnelledAddress],
task_status: TaskStatus[
Nursery,
@ -1064,6 +1091,8 @@ async def _serve_ipc_eps(
`.cancel_server()` is called.
'''
from ..net._tunnel import strip_tunnels
try:
listen_tn: Nursery
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.
# ?TODO, maybe just pass `list(eps.values()` tho?
listeners: list[trio.abc.Listener] = []
for addr in listen_addrs:
for declared_addr in listen_addrs:
addr: Address = strip_tunnels(declared_addr)
ep = Endpoint(
addr=addr,
listen_tn=listen_tn,
stream_handler_tn=stream_handler_tn,
declared_addr=declared_addr,
)
try:
ep_sclang: str = nest_from_op(

Some files were not shown because too many files have changed in this diff Show More