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`))
parent
7da561ecc3
commit
cdd5591428
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
session: tractor-addr-unpacking
|
||||
timestamp: 2026-08-26T02:24:34Z
|
||||
git_ref: 2245f094
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260826T022434Z_2245f094_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
After committing the owned WireGuard interface lifecycle, the human
|
||||
asked to continue with the next established incremental step.
|
||||
|
||||
## Response summary
|
||||
|
||||
Added a WG-specific composition context which owns one declared
|
||||
bindspace and an ordered interface stack. It snapshots caller-owned
|
||||
layer order before checkpointing, enters interfaces outermost-first,
|
||||
and guarantees inside-out interface teardown before bindspace release.
|
||||
Its inbound type shows the `(WGTunnelSpec, WGInterfaceConfig)` pairing
|
||||
directly, and its docstring depicts the stack and unwind order.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `tractor/discovery/_tunnel.py` - bindspace/interface stack driver.
|
||||
- `tractor/discovery/__init__.py` - public layer and driver exports.
|
||||
- `tests/discovery/test_wg_iface_lifecycle.py` - mutable-input,
|
||||
cancellation and nested teardown-order coverage.
|
||||
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - implemented composition
|
||||
and ownership-order contract.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human accepted and committed the preceding reviewed lifecycle
|
||||
boundary, then explicitly requested the next step. That selected the
|
||||
previously established bindspace/WireGuard composition boundary. In
|
||||
staged review, the human requested an explicit interface-stack analogy
|
||||
and a small ASCII diagram explaining entry and exit order. The human
|
||||
then identified `WGInterfaceLayer` as an unnecessary single-use alias
|
||||
which obscured the inbound tuple shape. The agent applied both
|
||||
human-directed edits. The human accepted the fake lifecycle test as a
|
||||
focused control-flow check which avoids pyroute2 overhead, while noting
|
||||
their general preference for end-to-end coverage. No direct manual
|
||||
source edits were observed.
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
model: gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-08-26T02:24:34Z
|
||||
git_ref: 2245f094
|
||||
diff_cmd: git diff HEAD~1..HEAD
|
||||
---
|
||||
|
||||
# Raw output - compose WireGuard bindspace lifecycles
|
||||
|
||||
After committing the owned WireGuard interface lifecycle, the human
|
||||
requested the next incremental change.
|
||||
|
||||
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
|
||||
|
||||
Added `open_wg_bindspace()` to compose one declared bindspace with an
|
||||
ordered sequence of WireGuard interface layers. The context enters the
|
||||
bindspace first, enters interfaces outermost-first through
|
||||
`AsyncExitStack`, yields the live `BindspaceHandle`, and removes
|
||||
interfaces inside-out before releasing or removing the namespace.
|
||||
|
||||
The caller's layer sequence is snapshotted before the first checkpoint,
|
||||
so concurrent mutation cannot alter resource entry or unwind order.
|
||||
Fake lifecycle tests clear a mutable input list during bindspace entry,
|
||||
cancel from the yielded scope, and prove the original stack unwinds in
|
||||
dependency-safe order.
|
||||
|
||||
Ruff passed and focused bindspace/WireGuard coverage passed 39 tests.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue