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`))
Gud Boi 2026-08-25 22:53:51 -04:00
parent 7da561ecc3
commit cdd5591428
6 changed files with 292 additions and 6 deletions

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

@ -530,8 +530,8 @@ 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.
and a driver that folds a list of specs into nested contexts
(`contextlib.AsyncExitStack` for the N-deep case). The
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|TunnelledAddress]]` and the
@ -539,6 +539,14 @@ it already returns
`dict[str, list[Address]]|dict[...]` return for tunnelled
entries. Extend it to carry the tunnel stack, not to *enter* it.
`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 `BindspaceHandle` for endpoint
allocation. Exit is inside-out, so every interface is removed while the
namespace FD remains pinned; only then can an owned namespace be
removed. Endpoint/channel lifetimes belong inside the yielded scope.
The caller supplies `role` to tunnel-resource contexts such as
`open_wg_iface()`; do not infer it from maddr shape. Bindspace
lifecycle remains the independent explicit policy above. The same

View File

@ -4,6 +4,8 @@ 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
from typing import BinaryIO
@ -18,6 +20,7 @@ from tractor.discovery import (
WGInterfaceConfig,
WGPeerConfig,
WGTunnelSpec,
open_wg_bindspace,
open_wg_iface,
)
from tractor.discovery import _tunnel
@ -237,3 +240,139 @@ def test_open_wg_iface_shields_cancelled_cleanup(
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 the
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,
BindspaceHandle,
_tunnel.WGRole,
]
] = []
bindspace_spec: BindspaceSpec = BindspaceSpec(
kind='netns',
)
bindspace: BindspaceHandle = BindspaceHandle(
spec=bindspace_spec,
identity=BindspaceIdentity(
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[BindspaceHandle]:
'''
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,
handle: BindspaceHandle,
role: _tunnel.WGRole,
) -> AsyncIterator[WGTunnelSpec]:
'''
Record one interface's arguments and nested lifetime.
'''
calls.append((spec, config, handle, role))
events.append(f'{spec.iface}-enter')
try:
yield spec
finally:
assert handle 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 handle:
assert handle 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',
]

View File

@ -50,6 +50,7 @@ from ._tunnel import (
WGRole as WGRole,
mb_pubkey as mb_pubkey,
mk_wg_maddr as mk_wg_maddr,
open_wg_bindspace as open_wg_bindspace,
open_wg_iface as open_wg_iface,
parse_wg_maddr as parse_wg_maddr,
read_wg_peers as read_wg_peers,

View File

@ -67,9 +67,16 @@ Unwrap at the parse or bindspace boundary; see `.overlay` and
'''
from __future__ import annotations
from collections.abc import AsyncIterator
from collections.abc import (
AsyncIterator,
Sequence,
)
import base64
from contextlib import asynccontextmanager as acm
from contextlib import (
AsyncExitStack,
asynccontextmanager as acm,
)
import ipaddress
import sys
from typing import (
@ -85,6 +92,11 @@ import multibase
import trio
from ..msg._local import ProcessLocal
from ._bindspace import (
BindspaceHandle,
BindspaceSpec,
open_bindspace,
)
if TYPE_CHECKING:
from multiaddr import Multiaddr
@ -93,10 +105,8 @@ if TYPE_CHECKING:
Address,
UnwrappedAddress,
)
from ._bindspace import BindspaceHandle
else:
Address = Any
BindspaceHandle = Any
Multiaddr = Any
UnwrappedAddress = Any
@ -622,6 +632,59 @@ async def open_wg_iface(
)
@acm
async def open_wg_bindspace(
bindspace_spec: BindspaceSpec,
layers: Sequence[tuple[WGTunnelSpec, WGInterfaceConfig]],
role: WGRole,
) -> AsyncIterator[BindspaceHandle]:
'''
Open one bindspace and its ordered WireGuard interface stack.
`layers` is an interface stack declared outermost first:
application scope
|
layers[-1] <- last entered, first exited
|
...
|
layers[0] <- first entered, last exited
|
bindspace
`AsyncExitStack` builds it bottom-up in declaration order and
unwinds it top-down before the bindspace closes.
'''
layer_stack: tuple[
tuple[WGTunnelSpec, WGInterfaceConfig],
...,
] = tuple(layers)
async with AsyncExitStack() as stack:
bindspace: BindspaceHandle = await (
stack.enter_async_context(
open_bindspace(bindspace_spec)
)
)
layer: tuple[WGTunnelSpec, WGInterfaceConfig]
for layer in layer_stack:
tunnel_spec: WGTunnelSpec
config: WGInterfaceConfig
tunnel_spec, config = layer
await stack.enter_async_context(
open_wg_iface(
tunnel_spec,
config,
bindspace,
role,
)
)
yield bindspace
def _sync_read_wg_keys(
iface: str,
netns: str|None,