Compare commits

...

30 Commits

Author SHA1 Message Date
Gud Boi d80dcbe60f 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-08-31 18:11:13 -04:00
Gud Boi 793ba9f94c 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-08-31 16:55:58 -04:00
Gud Boi 3e5a699df6 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-08-29 23:56:21 -04:00
Gud Boi b1f6ade840 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-08-29 22:03:11 -04:00
Gud Boi 2fb6143604 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-08-29 22:03:11 -04:00
Gud Boi 6d5b145787 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-08-29 22:03:11 -04:00
Gud Boi 503a3ed766 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-08-29 22:03:11 -04:00
Gud Boi d0261787fd 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-08-29 22:03:11 -04:00
Gud Boi d1ffb436d0 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-08-29 22:03:11 -04:00
Gud Boi 244e080efe 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-08-29 22:03:11 -04:00
Gud Boi 6dd149df3d 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-08-29 22:03:11 -04:00
Gud Boi f7bf068d09 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-08-29 22:03:11 -04:00
Gud Boi 1fd857ce21 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-08-29 22:03:11 -04:00
Gud Boi 1b0b085667 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-08-29 22:03:11 -04:00
Gud Boi 790a52ca7a 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-08-29 22:03:11 -04:00
Gud Boi 7abbb2b5f0 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-08-29 22:03:11 -04:00
Gud Boi 40e96b2991 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-08-29 22:03:11 -04:00
Gud Boi b9dca728d9 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-08-29 22:03:11 -04:00
Gud Boi be3e78197d 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-08-29 22:03:11 -04:00
Gud Boi ece3827bdf 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-08-29 22:03:11 -04:00
Gud Boi 35e496c166 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-08-29 22:03:11 -04:00
Gud Boi 0a0aa64305 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-08-29 22:03:11 -04:00
Gud Boi 51f4e31ce0 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-08-29 22:03:03 -04:00
Gud Boi 0a741282af 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-08-29 22:03:03 -04:00
Gud Boi 419ce6a631 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-08-29 22:03:03 -04:00
Gud Boi 176e0c3e61 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-08-29 22:03:03 -04:00
Gud Boi 6b8e9ad298 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-08-29 22:03:03 -04:00
Gud Boi bcf8f87ea5 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-08-29 22:03:03 -04:00
Gud Boi c7451bb2cb 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-08-29 22:03:03 -04:00
Gud Boi fa8067f79f 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-08-29 22:03:03 -04:00
108 changed files with 9942 additions and 611 deletions

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

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

@ -112,7 +112,13 @@ Hard constraints learned from the existing two:
- **`.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

View File

@ -22,24 +22,23 @@ onto `trio` as the library's sans-io layer allows.
## 1. What exists today (verified, per #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
- **today's deployable story remains declarative**: run `wg-quick`
out-of-band, parse the maddr, strip its wrapper 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.
- `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
@ -123,7 +122,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
@ -150,11 +149,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).
@ -197,9 +196,9 @@ Observed protocol-name lists, for writing the `match`:
- `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 +206,21 @@ 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 codecs + explicit verification
Port #482 §2's helpers into `tractor/discovery/_tunnel.py` as
*pure functions* + one impure probe, cleanly separated:
Port #482 §2's pure helpers into
`tractor/net/_tunnel.py`, keeping the impure probe cleanly
separated until layer B:
```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
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_peer()` may shell out (`wg show
<if> peers`), but layer B replaces that probe with one explicit async
function backed by pyroute2. 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).
---
@ -289,9 +289,12 @@ 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,269 @@ 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
`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.
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.
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:153`) is the front door:
it already returns `dict[name, list[Address]]` and the
it already returns
`dict[name, list[Address|TunnelledAddress]]` 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.
### 5.2 `Address.namespace`, at last
`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.
- `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 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 the netns/process reality — read this before designing
### 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,33 +612,65 @@ 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/netns creation/config needs `CAP_NET_ADMIN`;
entering an existing Linux 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.
@ -432,8 +680,23 @@ server bound in the old namespace.
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
- 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

@ -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,23 +41,21 @@ 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 dependency.
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()`,
@ -118,9 +116,9 @@ ping -c1 10.0.11.1 # from B
```bash
python -c "
import base64, multibase
key = open('wg_pub.key').read().strip()
print(multibase.encode('base64url', base64.b64decode(key)).decode())
from tractor.net import mb_pubkey
key = open('wg_pub.key').read().strip()
print(mb_pubkey(key))
"
```
@ -153,24 +151,36 @@ 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.
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.
## 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,27 @@ async def echo(msg: str) -> str:
async def main():
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
assert verify_wg_peer(addr), (
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
assert await verify_wg_peer(addr.tunnel), (
f'wg pubkey from maddr not active on wg0 !\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:
await an.start_actor(
'echo_srv',
enable_modules=[__name__],
)
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,14 +8,13 @@ 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'
@ -25,8 +24,8 @@ WG_MADDR: str = (
async def main():
addr: WGTunnelledAddr = parse_wg_maddr(WG_MADDR)
assert verify_wg_peer(addr), (
addr: TunnelledAddress = parse_wg_maddr(WG_MADDR)
assert await verify_wg_peer(addr.tunnel), (
f'wg pubkey from maddr not a peer on wg0 !\n'
f'maddr: {WG_MADDR}\n'
)
@ -34,7 +33,7 @@ async def main():
tractor.open_root_actor(
name='wg_client',
registry_addrs=[addr.overlay],
enable_transports=[addr.overlay_proto],
enable_transports=[addr.overlay.proto_key],
),
tractor.find_actor(
'echo_srv',

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

@ -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(
debug_mode=debug_mode,
maybe_enable_greenback=maybe_enable_greenback,
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)
]
# ?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"
TaggedTCPAddress: TypeAlias = tuple[
Literal['tcp'],
str,
int,
]
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
maddr = Multiaddr(maddr_str)
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(

View File

@ -37,7 +37,6 @@ from trio import (
from tractor.msg import MsgCodec
from tractor.log import get_logger
from tractor.discovery._multiaddr import mk_maddr
from tractor.ipc._transport import (
MsgTransport,
MsgpackTransport,
@ -47,8 +46,10 @@ if TYPE_CHECKING:
# ONLY type-annots, the eager import costs
# `import tractor` wall-time (gh #470).
from multiaddr import Multiaddr
from tractor.discovery._addr import TaggedTCPAddress
else:
Multiaddr = Any
TaggedTCPAddress = Any
log = get_logger()
@ -70,7 +71,7 @@ class TCPAddress(
) from valerr
proto_key: ClassVar[str] = 'tcp'
unwrapped_type: ClassVar[type] = tuple[str, int]
unwrapped_type: ClassVar[type] = tuple
def_bindspace: ClassVar[str] = '127.0.0.1'
# ?TODO, actually validate ipv4/6 with stdlib's `ipaddress`
@ -105,6 +106,14 @@ class TCPAddress(
def bindspace(self) -> str:
return self._host
@property
def namespace(self) -> None:
'''
Report that plain TCP uses the process's current namespace.
'''
return None
@property
def domain(self) -> str:
return self._host
@ -112,19 +121,35 @@ class TCPAddress(
@classmethod
def from_addr(
cls,
addr: tuple[str, int]
addr: tuple|list,
) -> TCPAddress:
match addr:
case (str(), int()):
return TCPAddress(addr[0], addr[1])
case (
('tcp', str() as host, int() as port)
|
['tcp', str() as host, int() as port]
|
(str() as host, int() as port)
|
[str() as host, int() as port]
|
(
str() as host,
int() as port,
int(),
int(),
)
):
return TCPAddress(host, port)
case _:
raise ValueError(
f'Invalid unwrapped address for {cls}\n'
f'{addr}\n'
)
def unwrap(self) -> tuple[str, int]:
def unwrap(self) -> TaggedTCPAddress:
return (
self.proto_key,
self._host,
self._port,
)
@ -209,6 +234,8 @@ class MsgpackTCPStream(MsgpackTransport):
@property
def maddr(self) -> Multiaddr:
from tractor.net import mk_maddr
return mk_maddr(self.raddr)
def connected(self) -> bool:
@ -223,7 +250,8 @@ class MsgpackTCPStream(MsgpackTransport):
**kwargs
) -> MsgpackTCPStream:
stream = await trio.open_tcp_stream(
*destaddr.unwrap(),
destaddr._host,
destaddr._port,
**kwargs
)
return MsgpackTCPStream(

View File

@ -63,7 +63,6 @@ from trio._highlevel_open_unix_stream import (
from tractor.msg import MsgCodec
from tractor.log import get_logger
from tractor.discovery._multiaddr import mk_maddr
from tractor.ipc._transport import (
MsgpackTransport,
)
@ -77,10 +76,12 @@ if TYPE_CHECKING:
# ONLY type-annots, the eager import costs
# `import tractor` wall-time (gh #470).
from multiaddr import Multiaddr
from tractor.discovery._addr import TaggedUnixAddress
from tractor.runtime._runtime import Actor
else:
Multiaddr = Any
Actor = Any
TaggedUnixAddress = Any
# Platform-specific credential passing constants
@ -147,7 +148,7 @@ class UDSAddress(
# -[ ] need to check what other mult-transport frameworks do
# like zmq, nng, uri-spec et al!
proto_key: ClassVar[str] = 'uds'
unwrapped_type: ClassVar[type] = tuple[str, int]
unwrapped_type: ClassVar[type] = tuple
def_bindspace: ClassVar[Path] = get_rt_dir()
@property
@ -163,9 +164,17 @@ class UDSAddress(
self.def_bindspace
)
@property
def namespace(self) -> None:
'''
Report that plain UDS uses the process's current namespace.
'''
return None
@property
def sockpath(self) -> Path:
return self.bindspace / self.filename
return Path(self.bindspace) / self.filename
@property
def is_valid(self) -> bool:
@ -179,16 +188,26 @@ class UDSAddress(
def from_addr(
cls,
addr: (
tuple[Path|str, Path|str]|Path|str
tuple|list|Path|str
),
) -> UDSAddress:
match addr:
case tuple()|list():
filedir = Path(addr[0])
filename = Path(addr[1])
case (
(('unix' | 'uds'), str()|Path() as sockpath)
|
[('unix' | 'uds'), str()|Path() as sockpath]
):
path = Path(sockpath)
return UDSAddress(*unwrap_sockpath(path))
case (
(str()|Path() as filedir, str()|Path() as filename)
|
[str()|Path() as filedir, str()|Path() as filename]
):
return UDSAddress(
filedir=filedir,
filename=filename,
filedir=Path(filedir),
filename=Path(filename),
# maybe_pid=pid,
)
# NOTE, in case we ever decide to just `.unwrap()`
@ -203,12 +222,10 @@ class UDSAddress(
f'{addr!r}\n'
)
def unwrap(self) -> tuple[str, int]:
# XXX NOTE, since this gets passed DIRECTLY to
# `.ipc._uds.open_unix_socket_w_passcred()`
def unwrap(self) -> TaggedUnixAddress:
return (
str(self.filedir),
str(self.filename),
'unix',
str(self.sockpath),
)
@classmethod
@ -593,6 +610,8 @@ class MsgpackUDSStream(MsgpackTransport):
@property
def maddr(self) -> Multiaddr|str:
from tractor.net import mk_maddr
if not self.raddr:
return '<unknown-peer>'

View File

@ -27,6 +27,9 @@ from .ptr import (
from .pretty_struct import (
Struct as Struct,
)
from ._local import (
ProcessLocal as ProcessLocal,
)
from ._codec import (
_def_msgspec_codec as _def_msgspec_codec,
_ctxvar_MsgCodec as _ctxvar_MsgCodec,

View File

@ -0,0 +1,54 @@
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation, either version 3 of
# the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public
# License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
'''
Markers for process-local values which must not cross actor IPC.
'''
from __future__ import annotations
import msgspec
class _ProcessLocalToken:
'''
Unsupported msgspec value embedded in every `ProcessLocal`.
'''
__slots__ = ()
_PROCESS_LOCAL_TOKEN: _ProcessLocalToken = _ProcessLocalToken()
class ProcessLocal(
msgspec.Struct,
kw_only=True,
repr_omit_defaults=True,
):
'''
Generic struct marker which rejects default msgspec encoding.
The hidden sentinel remains part of the encoded field set, so
msgspec encounters `_ProcessLocalToken` and raises `TypeError`
even when this value is nested inside another supported payload.
A custom encode hook may explicitly override that safeguard.
Keyword-only fields let subclasses add required fields after the
marker's default sentinel.
'''
_process_local: _ProcessLocalToken = _PROCESS_LOCAL_TOKEN

View File

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

View File

@ -0,0 +1,75 @@
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
'''
Network declarations, bindspaces and tunnels.
Public symbols are imported and cached on first access so importing
this package does not load optional network dependencies.
'''
from importlib import import_module
_SYMBOL_MODULES: dict[str, str] = {
'Bindspace': '._bindspace',
'BindspaceKind': '._bindspace',
'BindspaceLifecycle': '._bindspace',
'BindspaceOwnership': '._bindspace',
'BindspaceRef': '._bindspace',
'BindspaceSpec': '._bindspace',
'CURRENT_NETNS': '._bindspace',
'attach_netns': '._bindspace',
'open_bindspace': '._bindspace',
'open_netns': '._bindspace',
'TunnelledAddress': '._tunnel',
'TunnelSpec': '._tunnel',
'WGTunnelSpec': '._tunnel',
'WGInterfaceConfig': '._tunnel',
'WGPeerConfig': '._tunnel',
'WGRole': '._tunnel',
'mb_pubkey': '._tunnel',
'mk_wg_maddr': '._tunnel',
'open_wg_bindspace': '._tunnel',
'open_wg_iface': '._tunnel',
'parse_wg_maddr': '._tunnel',
'read_wg_peers': '._tunnel',
'read_wg_pubkey': '._tunnel',
'strip_tunnels': '._tunnel',
'tunnels_of': '._tunnel',
'verify_wg_peer': '._tunnel',
'wg8_pubkey': '._tunnel',
'mk_maddr': '..discovery._multiaddr',
'parse_maddr': '..discovery._multiaddr',
'parse_endpoints': '..discovery._multiaddr',
}
__all__: tuple[str, ...] = tuple(_SYMBOL_MODULES)
def __dir__() -> list[str]:
'''
Advertise the complete lazy public API.
'''
return sorted(set(globals()) | set(__all__))
def __getattr__(name: str) -> object:
'''
Import and cache one public network symbol on first access.
'''
try:
module_name: str = _SYMBOL_MODULES[name]
except KeyError:
raise AttributeError(
f'module {__name__!r} has no attribute {name!r}'
) from None
value: object = getattr(
import_module(module_name, __name__),
name,
)
globals()[name] = value
return value

View File

@ -0,0 +1,436 @@
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation, either version 3 of
# the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public
# License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
'''
Serializable bindspace declarations and live capabilities.
'''
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 (
Final,
get_args,
Literal,
TypeAlias,
)
import msgspec
import trio
from ..msg._local import ProcessLocal
BindspaceKind: TypeAlias = Literal[
'netns',
]
BindspaceLifecycle: TypeAlias = Literal[
'attach', # borrow one existing platform resource
'open', # create, own and remove one platform resource
]
BindspaceOwnership: TypeAlias = Literal[
'owned', # manager tears down the resource after final release
'borrowed', # manager leaves the pre-existing resource intact
]
_NETNS_RUN_DIR: Path = Path('/var/run/netns')
_THREAD_NETNS: Path = Path('/proc/thread-self/ns/net')
CURRENT_NETNS: Final[None] = None
def _validate_bindspace_kind(
kind: BindspaceKind,
) -> None:
'''
Reject platform-resource kinds without an implementation.
'''
if kind not in get_args(BindspaceKind):
raise ValueError(
f'Unsupported bindspace kind: {kind!r}'
)
def _validate_bindspace_lifecycle(
lifecycle: BindspaceLifecycle,
) -> None:
'''
Reject lifecycle policies without an implementation.
'''
if lifecycle not in get_args(BindspaceLifecycle):
raise ValueError(
f'Unsupported bindspace lifecycle: {lifecycle!r}'
)
def _validate_bindspace_key(
kind: BindspaceKind,
key: str|None,
field: str,
) -> None:
'''
Reject empty or path-like platform-resource names.
`None` is valid. Spell it `CURRENT_NETNS` for
`BindspaceSpec.key`; `BindspaceRef.key = None` records an
unnamed realized netns.
'''
if key == '':
raise ValueError(
f'`{field}` must be a non-empty name or `None` '
f'(`CURRENT_NETNS` for `BindspaceSpec.key`)!'
)
if (
kind == 'netns'
and
key is not None
and
(
Path(key).name != key
or
key in ('.', '..')
)
):
raise ValueError(
f'Invalid netns name: {key!r}'
)
class BindspaceSpec(
msgspec.Struct,
frozen=True,
):
'''
Serializable declaration of one requested bindspace.
For a netns spec, `.key = CURRENT_NETNS` selects the calling
thread's current namespace without a named-path lookup.
'''
kind: BindspaceKind
key: str|None = CURRENT_NETNS
lifecycle: BindspaceLifecycle = 'attach'
def __post_init__(self) -> None:
'''
Reject an empty platform-resource key.
'''
_validate_bindspace_kind(self.kind)
_validate_bindspace_lifecycle(self.lifecycle)
_validate_bindspace_key(
self.kind,
self.key,
'BindspaceSpec.key',
)
class BindspaceRef(
msgspec.Struct,
frozen=True,
):
'''
Serializable, non-owning ref to one realized bindspace.
`.key` is an optional mutable namespace locator. `.inode` is a
host-local kernel fingerprint which remains stable while the
resource exists or a live `Bindspace` pins it. This ref grants no
authority and cannot reopen the resource by itself.
'''
kind: BindspaceKind
key: str|None
inode: int
def __post_init__(self) -> None:
'''
Require a host-local resource inode and an optional locator.
'''
_validate_bindspace_kind(self.kind)
_validate_bindspace_key(
self.kind,
self.key,
'BindspaceRef.key',
)
if (
type(self.inode) is not int
or
self.inode <= 0
):
raise ValueError(
'`BindspaceRef.inode` must be a positive `int`!'
)
class Bindspace(
ProcessLocal,
):
'''
Process-local capability for one live realized bindspace.
`ProcessLocal` provides compact typed storage plus a default
wire-encoding guard. `Bindspace` construction and explicit FD
transfer belong to the supervisor's spawn/bootstrap path.
'''
spec: BindspaceSpec
ref: BindspaceRef
namespace_fd: int|None
ownership: BindspaceOwnership
def __post_init__(self) -> None:
'''
Validate and retain one scoped bindspace capability.
'''
spec: BindspaceSpec = self.spec
ref: BindspaceRef = self.ref
namespace_fd: int|None = self.namespace_fd
ownership: BindspaceOwnership = self.ownership
if spec.kind != ref.kind:
raise ValueError(
'`BindspaceSpec.kind` does not match '
'`BindspaceRef.kind`!'
)
if (
spec.key is not None
and
spec.key != ref.key
):
raise ValueError(
'`BindspaceSpec.key` does not match '
'`BindspaceRef.key`!'
)
if ownership not in get_args(BindspaceOwnership):
raise ValueError(
f'Invalid bindspace ownership: {ownership!r}'
)
expected_ownership: BindspaceOwnership = (
'borrowed'
if spec.lifecycle == 'attach'
else 'owned'
)
if ownership != expected_ownership:
raise ValueError(
f'`BindspaceSpec.lifecycle={spec.lifecycle!r}` '
f'requires ownership={expected_ownership!r}!'
)
if namespace_fd is not None:
if (
type(namespace_fd) is not int
or
namespace_fd < 0
):
raise ValueError(
'`namespace_fd` must be non-negative or `None`!'
)
fd_inode: int = os.fstat(namespace_fd).st_ino
if ref.inode != fd_inode:
raise ValueError(
f'Namespace FD inode {fd_inode} does not match '
f'reference inode {ref.inode}!'
)
def __repr__(self) -> str:
'''
Render the capability ref without dereferencing its FD.
'''
return (
f'{type(self).__name__}('
f'ref={self.ref!r}, '
f'ownership={self.ownership!r}, '
f'namespace_fd={self.namespace_fd!r})'
)
@acm
async def _pin_netns(
spec: BindspaceSpec,
ownership: BindspaceOwnership,
) -> AsyncIterator[Bindspace]:
'''
Pin one existing Linux network namespace with explicit ownership.
'''
key: str|None = spec.key
namespace_path: Path = (
_THREAD_NETNS
if key is CURRENT_NETNS
else _NETNS_RUN_DIR / key
)
namespace_fd: int = os.open(
namespace_path,
os.O_RDONLY | os.O_CLOEXEC,
)
try:
inode: int = os.fstat(namespace_fd).st_ino
ref: BindspaceRef = BindspaceRef(
kind='netns',
key=key,
inode=inode,
)
bindspace: Bindspace = Bindspace(
spec=spec,
ref=ref,
namespace_fd=namespace_fd,
ownership=ownership,
)
yield bindspace
finally:
os.close(namespace_fd)
@acm
async def attach_netns(
spec: BindspaceSpec,
) -> AsyncIterator[Bindspace]:
'''
Borrow and pin one existing Linux network namespace.
`BindspaceSpec.key = CURRENT_NETNS` selects the calling process's
current netns. A named key resolves beneath the standard iproute2
netns run directory. "Attach" pins an existing namespace FD; this
context never calls `setns()` or creates/removes a namespace.
'''
if sys.platform != 'linux':
raise NotImplementedError(
'Network namespace bindspaces are Linux-only!'
)
if spec.lifecycle != 'attach':
raise ValueError(
'`attach_netns()` requires lifecycle=`attach`!'
)
async with _pin_netns(
spec,
ownership='borrowed',
) as bindspace:
yield bindspace
def _create_netns(
key: str,
) -> None:
'''
Create one named netns through pyroute2's synchronous API.
'''
try:
from pyroute2 import netns
except ImportError as exc:
raise RuntimeError(
'Netns creation requires the `tractor[wg]` extra.'
) from exc
netns.create(key)
def _remove_netns(
key: str,
) -> None:
'''
Remove one named netns through pyroute2's synchronous API.
'''
try:
from pyroute2 import netns
except ImportError as exc:
raise RuntimeError(
'Netns removal requires the `tractor[wg]` extra.'
) from exc
netns.remove(key)
@acm
async def open_netns(
spec: BindspaceSpec,
) -> AsyncIterator[Bindspace]:
'''
Create, pin and own one named Linux network namespace.
Creation and removal are shielded synchronous pyroute2 calls in a
worker thread. This context never enters the namespace.
Spawn-time bootstrap remains responsible for eventual `setns()`.
'''
if sys.platform != 'linux':
raise NotImplementedError(
'Network namespace bindspaces are Linux-only!'
)
if spec.lifecycle != 'open':
raise ValueError(
'`open_netns()` requires lifecycle=`open`!'
)
key: str|None = spec.key
if key is CURRENT_NETNS:
raise ValueError(
'`open_netns()` requires a named `BindspaceSpec.key`!'
)
created: bool = False
try:
with trio.CancelScope(shield=True):
await trio.to_thread.run_sync(
_create_netns,
key,
abandon_on_cancel=False,
)
created = True
async with _pin_netns(
spec,
ownership='owned',
) as bindspace:
yield bindspace
finally:
if created:
with trio.CancelScope(shield=True):
await trio.to_thread.run_sync(
_remove_netns,
key,
abandon_on_cancel=False,
)
@acm
async def open_bindspace(
spec: BindspaceSpec,
) -> AsyncIterator[Bindspace]:
'''
Dispatch one declared bindspace lifecycle.
Lifecycle is explicit serialized policy. It is never inferred
from whether the eventual transport role is listen or dial.
'''
if spec.lifecycle == 'attach':
async with attach_netns(spec) as bindspace:
yield bindspace
else:
async with open_netns(spec) as bindspace:
yield bindspace

File diff suppressed because it is too large Load Diff

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