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`))wkt/wg_pyroute2_read
parent
7abbb2b5f0
commit
790a52ca7a
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue