From cdd5591428e7be6f43a1705cce6dbe36a7c0fcd3 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 25 Aug 2026 22:53:51 -0400 Subject: [PATCH] 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`)) --- .../20260826T022434Z_2245f094_prompt_io.md | 47 ++++++ ...20260826T022434Z_2245f094_prompt_io.raw.md | 28 ++++ ai/tpt-backends/03_wg_tunnel_bindspace.md | 12 +- tests/discovery/test_wg_iface_lifecycle.py | 139 ++++++++++++++++++ tractor/discovery/__init__.py | 1 + tractor/discovery/_tunnel.py | 71 ++++++++- 6 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.raw.md diff --git a/ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.md b/ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.md new file mode 100644 index 00000000..3f5962fe --- /dev/null +++ b/ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.raw.md b/ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.raw.md new file mode 100644 index 00000000..34a25f27 --- /dev/null +++ b/ai/prompt-io/opencode/20260826T022434Z_2245f094_prompt_io.raw.md @@ -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. diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index 25f598f0..99cac323 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -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 diff --git a/tests/discovery/test_wg_iface_lifecycle.py b/tests/discovery/test_wg_iface_lifecycle.py index 07a39960..1c14be1c 100644 --- a/tests/discovery/test_wg_iface_lifecycle.py +++ b/tests/discovery/test_wg_iface_lifecycle.py @@ -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', + ] diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index b33ecd7a..f454f4a4 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -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, diff --git a/tractor/discovery/_tunnel.py b/tractor/discovery/_tunnel.py index d48e5750..78f31358 100644 --- a/tractor/discovery/_tunnel.py +++ b/tractor/discovery/_tunnel.py @@ -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,