From 2db2b35a4edfe6ea2baae7e8f6c4a4e9db310746 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 25 Aug 2026 19:33:12 -0400 Subject: [PATCH] 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`)) --- .../20260825T191845Z_5b2a064a_prompt_io.md | 40 +++++++ ...20260825T191845Z_5b2a064a_prompt_io.raw.md | 30 +++++ ai/tpt-backends/03_wg_tunnel_bindspace.md | 18 +-- tests/discovery/test_bindspace.py | 27 ++++- tractor/discovery/__init__.py | 2 + tractor/discovery/_bindspace.py | 108 ++++++++++++++---- 6 files changed, 194 insertions(+), 31 deletions(-) create mode 100644 ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.raw.md diff --git a/ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.md b/ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.md new file mode 100644 index 00000000..98fc2d0e --- /dev/null +++ b/ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.raw.md b/ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.raw.md new file mode 100644 index 00000000..045147cb --- /dev/null +++ b/ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.raw.md @@ -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. diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index 1bc89509..c43d1a7f 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -408,6 +408,7 @@ 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 BindspaceIdentity(msgspec.Struct, frozen=True): @@ -428,8 +429,6 @@ class BindspaceHandle(ProcessLocal): @acm async def open_bindspace( spec: BindspaceSpec, - *, - role: Literal['listen', 'dial'], ) -> AsyncGenerator[BindspaceHandle, None]: ''' Provision/borrow one bindspace and yield its live capability. @@ -451,6 +450,12 @@ 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 @@ -474,7 +479,6 @@ use the handle to replace an overlay while preserving every tunnel: ```python async with open_bindspace( bindspace_spec, - role='listen', ) as bindspace: listen_decl = declared_addr.get_random( bindspace=bindspace, @@ -499,7 +503,6 @@ tunnel/bindspace layer: @acm async def open_netns( spec: BindspaceSpec, - role: Literal['listen', 'dial'], ) -> AsyncGenerator[BindspaceHandle, None]: ... @acm @@ -519,9 +522,10 @@ it already returns `dict[str, list[Address]]|dict[...]` return for tunnelled entries. Extend it to carry the tunnel stack, not to *enter* it. -The caller supplies `role`; do not infer it from maddr shape. The same -composed maddr can name a server source or client destination, and the -required local provisioning/ownership differs (§5.4). +The caller supplies `role` to tunnel-resource contexts such as +`open_wg_iface()`; do not infer it from maddr shape. Bindspace +lifecycle remains the independent explicit policy above. The same +composed maddr can name a server source or client destination (§5.4). ### 5.3 `Address.namespace`, at last diff --git a/tests/discovery/test_bindspace.py b/tests/discovery/test_bindspace.py index 6ec5feb0..5393c6d2 100644 --- a/tests/discovery/test_bindspace.py +++ b/tests/discovery/test_bindspace.py @@ -20,6 +20,7 @@ from tractor.discovery import ( BindspaceSpec, CURRENT_NETNS, attach_netns, + open_bindspace, open_netns, ) from tractor.discovery import _bindspace @@ -39,7 +40,11 @@ def test_bindspace_declarations_roundtrip() -> None: BindspaceSpec|BindspaceIdentity, ..., ] = ( - BindspaceSpec(kind='netns', key='tractor-wg0'), + BindspaceSpec( + kind='netns', + key='tractor-wg0', + lifecycle='open', + ), BindspaceIdentity( kind='netns', key='tractor-wg0', @@ -79,6 +84,7 @@ def test_bindspace_handle_pins_local_capability( spec: BindspaceSpec = BindspaceSpec( kind='netns', key='tractor-wg0', + lifecycle='open', ) identity: BindspaceIdentity = BindspaceIdentity( kind='netns', @@ -207,6 +213,16 @@ def test_bindspace_handle_rejects_mismatched_identity( '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( BindspaceIdentity, { @@ -240,7 +256,7 @@ def test_bindspace_models_reject_invalid_values( sys.platform != 'linux', reason='Linux netns API', ) -def test_attach_current_netns_borrows_and_closes() -> None: +def test_open_bindspace_attaches_current_netns() -> None: ''' The unnamed spec must borrow and pin the caller's current netns. @@ -259,7 +275,7 @@ def test_attach_current_netns_borrows_and_closes() -> None: kind='netns', ) assert spec.key is CURRENT_NETNS - async with attach_netns(spec) as handle: + async with open_bindspace(spec) as handle: namespace_fd: int|None = handle.namespace_fd assert namespace_fd is not None assert handle.spec is spec @@ -426,8 +442,9 @@ def test_open_netns_owns_lifecycle( spec: BindspaceSpec = BindspaceSpec( kind='netns', key='tractor-wg0', + lifecycle='open', ) - async with open_netns(spec) as handle: + async with open_bindspace(spec) as handle: fd: int|None = handle.namespace_fd assert fd is not None assert handle.ownership == 'owned' @@ -503,6 +520,7 @@ def test_open_netns_shields_cancelled_cleanup( spec: BindspaceSpec = BindspaceSpec( kind='netns', key='tractor-wg0', + lifecycle='open', ) with trio.CancelScope() as scope: async with open_netns(spec): @@ -529,6 +547,7 @@ def test_open_netns_requires_name() -> None: spec: BindspaceSpec = BindspaceSpec( kind='netns', key=CURRENT_NETNS, + lifecycle='open', ) async def main() -> None: diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index d44d8a8c..3a98eb1c 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -28,10 +28,12 @@ from ._bindspace import ( BindspaceHandle as BindspaceHandle, BindspaceIdentity as BindspaceIdentity, BindspaceKind as BindspaceKind, + BindspaceLifecycle as BindspaceLifecycle, BindspaceOwnership as BindspaceOwnership, BindspaceSpec as BindspaceSpec, CURRENT_NETNS as CURRENT_NETNS, attach_netns as attach_netns, + open_bindspace as open_bindspace, open_netns as open_netns, ) from ._multiaddr import ( diff --git a/tractor/discovery/_bindspace.py b/tractor/discovery/_bindspace.py index 7afb9b2d..255b0fe3 100644 --- a/tractor/discovery/_bindspace.py +++ b/tractor/discovery/_bindspace.py @@ -41,6 +41,10 @@ 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 @@ -65,6 +69,19 @@ def _validate_bindspace_kind( ) +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, @@ -112,6 +129,7 @@ class BindspaceSpec( ''' kind: BindspaceKind key: str|None = CURRENT_NETNS + lifecycle: BindspaceLifecycle = 'attach' def __post_init__(self) -> None: ''' @@ -119,6 +137,7 @@ class BindspaceSpec( ''' _validate_bindspace_kind(self.kind) + _validate_bindspace_lifecycle(self.lifecycle) _validate_bindspace_key( self.kind, self.key, @@ -207,6 +226,16 @@ class BindspaceHandle( 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 @@ -237,23 +266,14 @@ class BindspaceHandle( @acm -async def attach_netns( +async def _pin_netns( spec: BindspaceSpec, + ownership: BindspaceOwnership, ) -> AsyncIterator[BindspaceHandle]: ''' - 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. + Pin one existing Linux network namespace with explicit ownership. ''' - if sys.platform != 'linux': - raise NotImplementedError( - 'Network namespace bindspaces are Linux-only!' - ) - key: str|None = spec.key namespace_path: Path = ( _SELF_NETNS @@ -275,13 +295,41 @@ async def attach_netns( spec=spec, identity=identity, namespace_fd=namespace_fd, - ownership='borrowed', + ownership=ownership, ) yield handle finally: os.close(namespace_fd) +@acm +async def attach_netns( + spec: BindspaceSpec, +) -> AsyncIterator[BindspaceHandle]: + ''' + 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 handle: + yield handle + + def _create_netns( key: str, ) -> None: @@ -332,6 +380,10 @@ async def open_netns( 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: @@ -349,13 +401,10 @@ async def open_netns( ) created = True - async with attach_netns(spec) as borrowed: - handle: BindspaceHandle = BindspaceHandle( - spec=spec, - identity=borrowed.identity, - namespace_fd=borrowed.namespace_fd, - ownership='owned', - ) + async with _pin_netns( + spec, + ownership='owned', + ) as handle: yield handle finally: if created: @@ -365,3 +414,22 @@ async def open_netns( key, abandon_on_cancel=False, ) + + +@acm +async def open_bindspace( + spec: BindspaceSpec, +) -> AsyncIterator[BindspaceHandle]: + ''' + 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 handle: + yield handle + else: + async with open_netns(spec) as handle: + yield handle