diff --git a/ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.md b/ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.md new file mode 100644 index 00000000..a09b808c --- /dev/null +++ b/ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.md @@ -0,0 +1,43 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-addr-unpacking +timestamp: 2026-08-27T21:11:15Z +git_ref: d130431c +scope: code +substantive: true +raw_file: 20260827T211115Z_d130431c_prompt_io.raw.md +--- + +## Prompt + +During review, the human requested concrete names for the existing +bindspace abstractions and asked to isolate that rename before realized +reference retention. + +## Response summary + +Renamed the serializable resource record to `BindspaceRef`, the live +FD-backed capability to `Bindspace`, and the capability's record field +to `.ref`. Updated existing lifecycle APIs, tests and active design +contracts without compatibility aliases. + +## Files changed + +- `tractor/discovery/_bindspace.py` - renamed models and `.ref` field. +- `tractor/discovery/_tunnel.py` - existing lifecycle annotations. +- `tractor/discovery/__init__.py` - renamed public model exports. +- `tests/discovery/test_bindspace.py` - renamed model contracts. +- `tests/discovery/test_wg_iface_lifecycle.py` - live bindspace usage. +- `ai/tpt-backends/00_shared_backend_contract.md` - shared terminology. +- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - request, ref and live + capability terminology. + +## Human edits + +The human identified the earlier identity/handle terminology as too +abstract for an IPC-passed non-owning reference and live resource. They +selected `BindspaceRef`, `Bindspace`, and `.ref`, then requested this +rename as a prerequisite commit separate from the realized-ref feature. +The agent applied those human-directed names; no direct manual source +edits were observed. diff --git a/ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.raw.md b/ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.raw.md new file mode 100644 index 00000000..1a0bc4bd --- /dev/null +++ b/ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.raw.md @@ -0,0 +1,26 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-27T21:11:15Z +git_ref: d130431c +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - simplify bindspace model names + +While reviewing realized bindspace metadata, the human requested more +concrete names for the existing bindspace model family and asked that +the rename remain separate from the subsequent feature. + +> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py tests/discovery/test_wg_iface_lifecycle.py ai/tpt-backends/00_shared_backend_contract.md ai/tpt-backends/03_wg_tunnel_bindspace.md` + +Renamed `BindspaceIdentity` to `BindspaceRef` for the serializable, +non-owning, host-local resource record. Renamed `BindspaceHandle` to +`Bindspace` for the live process-local FD-backed capability, and renamed +its `.identity` field to `.ref`. + +No compatibility aliases were added because the models are unshipped. +Active design contracts now distinguish the serialized request, the +wire-safe non-owning ref, and the live capability. Ruff passed, 518 +tests collected, and 53 focused bindspace/WireGuard/listener tests +passed across the final two-commit tree. diff --git a/ai/tpt-backends/00_shared_backend_contract.md b/ai/tpt-backends/00_shared_backend_contract.md index a7e0f22c..739cc0a0 100644 --- a/ai/tpt-backends/00_shared_backend_contract.md +++ b/ai/tpt-backends/00_shared_backend_contract.md @@ -115,9 +115,10 @@ Hard constraints learned from the existing two: *ALPN + relay/discovery realm* (plan 02). Do not overload this transport-level bind selector with process namespace lifecycle. Plan 03 augments an maddr/address declaration with a serializable - `BindspaceSpec` and a scoped, non-serializable `BindspaceHandle`; - the latter owns namespace identity/FD/lifetime and is consumed at - spawn bootstrap before a concrete address reaches transport bind. + `BindspaceSpec` request and host-local `BindspaceRef`. A scoped, + non-serializable `Bindspace` carries that ref and owns the FD/lifetime + used during spawn bootstrap before a concrete address reaches + transport bind. `Address.namespace` is already spec'd in the Protocol as "the if-available OS-specific network namespace key" and is currently unimplemented by both backends — plan 03 is the diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index 99cac323..be064a4b 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -378,7 +378,7 @@ Keep the authority surface deliberately small: The root owns the manager's lifetime. `wgman` must outlive all siblings borrowing its tunnels and exit before the root drops the -underlying namespace/capability handles. A manager crash fails closed: +underlying namespace capabilities. A manager crash fails closed: dependent operations receive an explicit service error; restart, if enabled, reconciles declared state idempotently before advertising readiness again. Do not silently let siblings fall back to privileged @@ -400,8 +400,8 @@ select the local instance of that network stack. A netns, VRF, interface, user namespace, or equivalent platform resource is orthogonal augmentation carried alongside/below the maddr. -Keep two bindspace representations with deliberately different -lifetimes: +Keep three bindspace representations with deliberately different roles +and lifetimes: ```python class BindspaceSpec(msgspec.Struct, frozen=True): @@ -411,17 +411,17 @@ class BindspaceSpec(msgspec.Struct, frozen=True): lifecycle: Literal['attach', 'open'] -class BindspaceIdentity(msgspec.Struct, frozen=True): - '''Stable identity of the realized platform resource.''' +class BindspaceRef(msgspec.Struct, frozen=True): + '''Wire-safe, non-owning ref to the realized resource.''' kind: str key: str|None # mutable name, absent after unlink - inode: int # stable Linux namespace identity + inode: int # host-local Linux nsfs fingerprint -class BindspaceHandle(ProcessLocal): +class Bindspace(ProcessLocal): '''Scoped, non-serializable capability for one live bindspace.''' spec: BindspaceSpec - identity: BindspaceIdentity + ref: BindspaceRef namespace_fd: int|None ownership: Literal['owned', 'borrowed'] @@ -429,7 +429,7 @@ class BindspaceHandle(ProcessLocal): @acm async def open_bindspace( spec: BindspaceSpec, -) -> AsyncGenerator[BindspaceHandle, None]: +) -> AsyncGenerator[Bindspace, None]: ''' Provision/borrow one bindspace and yield its live capability. @@ -437,15 +437,17 @@ async def open_bindspace( ``` The initial model limits `BindspaceKind` to `netns` while preserving -the required lifetime split. `BindspaceSpec` and -`BindspaceIdentity` are frozen msgspec structs which cross -config/spawn serialization. `BindspaceHandle` also uses msgspec's -generic struct storage by inheriting the global -`tractor.msg.ProcessLocal` marker. Its hidden unsupported sentinel -blocks direct and nested default msgspec encoding without a recursive -IPC hot-path scan. The handle validates any supplied FD against -`BindspaceIdentity.inode`; explicit FD transfer belongs to the -supervisor bootstrap path. An FD avoids name-resolution TOCTOU, +the required role split. `BindspaceSpec` is the requested resource and +lifecycle policy. `BindspaceRef` is a serializable, non-owning, +host-local record of the resource that was actually opened; it can be +compared or logged, but cannot reopen, pin or enter that resource. +`Bindspace` is the live capability and uses msgspec's generic struct +storage by inheriting the global `tractor.msg.ProcessLocal` marker. Its +hidden unsupported sentinel blocks direct and nested default msgspec +encoding without a recursive IPC hot-path scan. The live bindspace +validates any supplied FD against `BindspaceRef.inode`; explicit FD +transfer belongs to the supervisor bootstrap path. An FD avoids +name-resolution TOCTOU, survives rename/unlink, and identifies the exact namespace the parent provisioned. Extend the kind/field union only when a second platform resource is implemented. @@ -459,7 +461,7 @@ 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 -`/var/run/netns`. It derives identity from the opened FD, yields +`/var/run/netns`. It derives a `BindspaceRef` from the opened FD, yields `ownership='borrowed'`, and closes only that FD on exit. "Attach" does not call `setns()`; it never creates, enters or removes a namespace. Future `open_netns()` creation and owned teardown remain a separate @@ -474,7 +476,8 @@ spawn/bootstrap operation. `open_bindspace()` is **not** an address factory and does not return a `TunnelledAddress`. At the declaration layer, listener allocation can -use the handle to replace an overlay while preserving every tunnel: +use the live bindspace to replace an overlay while preserving every +tunnel: ```python async with open_bindspace( @@ -503,13 +506,13 @@ tunnel/bindspace layer: @acm async def open_netns( spec: BindspaceSpec, -) -> AsyncGenerator[BindspaceHandle, None]: ... +) -> AsyncGenerator[Bindspace, None]: ... @acm async def open_wg_iface( spec: WGTunnelSpec, config: WGInterfaceConfig, - bindspace: BindspaceHandle, + bindspace: Bindspace, role: Literal['listen', 'dial'], ) -> AsyncGenerator[WGTunnelSpec, None]: ... ``` @@ -542,7 +545,7 @@ 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 +`AsyncExitStack`, and yields the live `Bindspace` 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. @@ -566,10 +569,10 @@ composed maddr can name a server source or client destination (§5.4). Use `github/ns_aware@e4688cad` as prototype evidence, not code to cherry-pick unchanged. Its `/proc//ns/` inode reader and -`ip netns identify` probe establish the useful `(key, inode)` identity -pair. Layer C should move that shape into `BindspaceIdentity`, avoid a +`ip netns identify` probe establish the useful `(key, inode)` reference +record. Layer C should move that shape into `BindspaceRef`, avoid a subprocess where netlink/procfs suffices, and hold the namespace FD in -`BindspaceHandle` to pin the identity. +`Bindspace` to pin the referenced resource. ### 5.4 the netns/process reality — read this before designing @@ -612,7 +615,7 @@ server bound in the old namespace. - a root/single-actor process follows the same ordering: enter during root bootstrap, never after actor runtime startup. - iface/route/WG provisioning is genuinely scoped and remains under - the parent/supervisor's `BindspaceHandle` context. + the parent/supervisor's `Bindspace` context. - document the constraint rather than hiding it; a `RuntimeError` if namespace entry is attempted after bootstrap. - capabilities: iface/netns creation/config needs `CAP_NET_ADMIN`; @@ -641,7 +644,7 @@ server bound in the old namespace. - teardown follows capability ownership, not just address type: - owned listener bindspaces tear down after endpoints/channels and the actor process have exited; - - borrowed dial/actor-wide bindspaces only release their handle; + - borrowed dial/actor-wide bindspaces only release their capability; - nested resources exit inside-out, but shared resources remain until their owning supervisor drops the final capability. - teardown must be idempotent and tolerant: an iface/netns @@ -713,7 +716,7 @@ consider doing it *first* for exactly that reason. | namespace name is renamed/replaced between provision and spawn | pass an open namespace FD; verify `(key, inode)` after child entry | | child starts sockets/threads before `setns()` | enter in the spawn bootstrap trampoline before `_runtime.async_main()`; assert inode ordering | | ambient capabilities leak into actor app code | split provision/enter authority and drop caps before runtime initialization | -| dial path tears down a shared actor bindspace | encode ownership in `BindspaceHandle`; borrowed handles never remove resources | +| dial path tears down a shared actor bindspace | encode ownership in `Bindspace`; borrowed bindspaces never remove resources | | py-multiaddr#108 merged but unreleased | PEP 621 direct-revision pin + `_wg_proto_code()` gate; replace with a release floor once published | | `TunnelledAddress` leaks into transport reflection/type dispatch | keep wrappers through declaration/bindspace handling, call `strip_tunnels()` at channel/endpoint boundaries, and retain the boundary regressions | | privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default | diff --git a/tests/discovery/test_bindspace.py b/tests/discovery/test_bindspace.py index 5393c6d2..be03ba75 100644 --- a/tests/discovery/test_bindspace.py +++ b/tests/discovery/test_bindspace.py @@ -1,5 +1,5 @@ ''' -Bindspace declaration, identity and live-capability contracts. +Bindspace declaration, reference and live-capability contracts. ''' from __future__ import annotations @@ -14,9 +14,9 @@ import pytest import trio from tractor.discovery import ( - BindspaceHandle, - BindspaceIdentity, + Bindspace, BindspaceOwnership, + BindspaceRef, BindspaceSpec, CURRENT_NETNS, attach_netns, @@ -29,15 +29,15 @@ from tractor.msg import ProcessLocal def test_bindspace_declarations_roundtrip() -> None: ''' - Spawn configuration and realized identity must cross actor IPC. + Spawn configuration and realized refs must cross actor IPC. Encode both frozen structs through msgpack and decode with their - concrete types, proving names and stable inode identity survive + concrete types, proving names and stable inode refs survive without carrying any process-local capability state. ''' values: tuple[ - BindspaceSpec|BindspaceIdentity, + BindspaceSpec|BindspaceRef, ..., ] = ( BindspaceSpec( @@ -45,16 +45,16 @@ def test_bindspace_declarations_roundtrip() -> None: key='tractor-wg0', lifecycle='open', ), - BindspaceIdentity( + BindspaceRef( kind='netns', key='tractor-wg0', inode=1234, ), ) - value: BindspaceSpec|BindspaceIdentity + value: BindspaceSpec|BindspaceRef for value in values: encoded: bytes = msgspec.msgpack.encode(value) - decoded: BindspaceSpec|BindspaceIdentity = ( + decoded: BindspaceSpec|BindspaceRef = ( msgspec.msgpack.decode( encoded, type=type(value), @@ -63,16 +63,17 @@ def test_bindspace_declarations_roundtrip() -> None: assert decoded == value -def test_bindspace_handle_pins_local_capability( +def test_bindspace_pins_local_capability( tmp_path: Path, ) -> None: ''' - A live handle pins one exact FD and realized identity. + A live bindspace pins one exact FD and realized ref. - Open a stand-in platform handle, record its inode in the realized - identity and construct an owned capability. Prove the generic + Open a stand-in platform FD, record its inode in the realized + ref and construct an owned capability. Prove the generic msgspec struct retains that exact local state. Its ability to - encode ordinary fields is not authority to transfer the handle. + encode ordinary fields is not authority to transfer the + bindspace. ''' token_path: Path = tmp_path / 'bindspace' @@ -86,36 +87,36 @@ def test_bindspace_handle_pins_local_capability( key='tractor-wg0', lifecycle='open', ) - identity: BindspaceIdentity = BindspaceIdentity( + ref: BindspaceRef = BindspaceRef( kind='netns', key='tractor-wg0', inode=inode, ) - handle: BindspaceHandle = BindspaceHandle( + bindspace: Bindspace = Bindspace( spec=spec, - identity=identity, + ref=ref, namespace_fd=namespace_fd, ownership='owned', ) - assert handle.spec is spec - assert handle.identity is identity - assert handle.namespace_fd == namespace_file.fileno() - assert handle.ownership == 'owned' - assert isinstance(handle, msgspec.Struct) - assert isinstance(handle, ProcessLocal) + assert bindspace.spec is spec + assert bindspace.ref is ref + assert bindspace.namespace_fd == namespace_file.fileno() + assert bindspace.ownership == 'owned' + assert isinstance(bindspace, msgspec.Struct) + assert isinstance(bindspace, ProcessLocal) with pytest.raises( TypeError, match='_ProcessLocalToken.*unsupported', ): - msgspec.msgpack.encode(handle) + msgspec.msgpack.encode(bindspace) -def test_bindspace_handle_rejects_mismatched_identity( +def test_bindspace_rejects_mismatched_ref( tmp_path: Path, ) -> None: ''' - A name or inode mismatch would make a handle stale authority. + A name or inode mismatch would make a bindspace stale authority. Construct a requested named spec, then prove both a different realized name and an inode not belonging to the supplied FD are @@ -128,39 +129,39 @@ def test_bindspace_handle_rejects_mismatched_identity( kind='netns', key='tractor-wg0', ) - # Keep ownership and FD fixed so only identity changes below. + # Keep ownership and FD fixed so only the ref changes below. ownership: BindspaceOwnership = 'borrowed' namespace_file: BinaryIO with token_path.open('rb') as namespace_file: namespace_fd: int = namespace_file.fileno() - wrong_name: BindspaceIdentity = BindspaceIdentity( + wrong_name: BindspaceRef = BindspaceRef( kind='netns', key='other-wg', inode=token_path.stat().st_ino, ) with pytest.raises( ValueError, - match='Spec.key.*Identity.key', + match='Spec.key.*Ref.key', ): - BindspaceHandle( + Bindspace( spec=spec, - identity=wrong_name, + ref=wrong_name, namespace_fd=namespace_fd, ownership=ownership, ) - wrong_inode: BindspaceIdentity = BindspaceIdentity( + wrong_inode: BindspaceRef = BindspaceRef( kind='netns', key='tractor-wg0', inode=token_path.stat().st_ino + 1, ) with pytest.raises( ValueError, - match='FD inode.*identity inode', + match='FD inode.*reference inode', ): - BindspaceHandle( + Bindspace( spec=spec, - identity=wrong_inode, + ref=wrong_inode, namespace_fd=namespace_fd, ownership=ownership, ) @@ -170,14 +171,14 @@ def test_bindspace_handle_rejects_mismatched_identity( ('model', 'kwargs', 'match'), ( pytest.param( - BindspaceIdentity, + BindspaceRef, { 'kind': 'netns', 'key': None, 'inode': None, }, 'must be a positive `int`', - id='identity-requires-inode', + id='ref-requires-inode', ), pytest.param( BindspaceSpec, @@ -186,14 +187,14 @@ def test_bindspace_handle_rejects_mismatched_identity( id='spec-rejects-kind', ), pytest.param( - BindspaceIdentity, + BindspaceRef, { 'kind': 'vrf', 'key': 'blue', 'inode': 1234, }, 'Unsupported bindspace kind', - id='identity-rejects-kind', + id='ref-rejects-kind', ), pytest.param( BindspaceSpec, @@ -224,19 +225,19 @@ def test_bindspace_handle_rejects_mismatched_identity( id='spec-rejects-lifecycle', ), pytest.param( - BindspaceIdentity, + BindspaceRef, { 'kind': 'netns', 'key': '', 'inode': 1234, }, - 'BindspaceIdentity.key', - id='identity-rejects-empty-key', + 'BindspaceRef.key', + id='ref-rejects-empty-key', ), ), ) def test_bindspace_models_reject_invalid_values( - model: type[BindspaceSpec]|type[BindspaceIdentity], + model: type[BindspaceSpec]|type[BindspaceRef], kwargs: dict[str, object], match: str, ) -> None: @@ -245,7 +246,7 @@ def test_bindspace_models_reject_invalid_values( Parameterize the missing stable inode and future, unimplemented kinds. Prove neither serializable model can carry invalid - identity or provisioning instructions into spawn configuration. + refs or provisioning instructions into spawn configuration. ''' with pytest.raises(ValueError, match=match): @@ -260,7 +261,7 @@ def test_open_bindspace_attaches_current_netns() -> None: ''' The unnamed spec must borrow and pin the caller's current netns. - Open `/proc/self/ns/net`, prove the yielded handle records its + Open `/proc/self/ns/net`, prove the yielded bindspace records its stable inode and borrowed ownership, then exit the context and prove the exact descriptor was closed without altering the namespace itself. @@ -275,15 +276,15 @@ def test_open_bindspace_attaches_current_netns() -> None: kind='netns', ) assert spec.key is CURRENT_NETNS - async with open_bindspace(spec) as handle: - namespace_fd: int|None = handle.namespace_fd + async with open_bindspace(spec) as bindspace: + namespace_fd: int|None = bindspace.namespace_fd assert namespace_fd is not None - assert handle.spec is spec - assert handle.identity.key is None - assert handle.identity.inode == os.fstat( + assert bindspace.spec is spec + assert bindspace.ref.key is None + assert bindspace.ref.inode == os.fstat( namespace_fd ).st_ino - assert handle.ownership == 'borrowed' + assert bindspace.ownership == 'borrowed' return namespace_fd namespace_fd: int = trio.run(main) @@ -324,12 +325,12 @@ def test_attach_named_netns_uses_run_directory( kind='netns', key='tractor-wg0', ) - async with attach_netns(spec) as handle: - namespace_fd: int|None = handle.namespace_fd + async with attach_netns(spec) as bindspace: + namespace_fd: int|None = bindspace.namespace_fd assert namespace_fd is not None - assert handle.identity.key == 'tractor-wg0' - assert handle.identity.inode == netns_path.stat().st_ino - assert handle.ownership == 'borrowed' + assert bindspace.ref.key == 'tractor-wg0' + assert bindspace.ref.inode == netns_path.stat().st_ino + assert bindspace.ownership == 'borrowed' return namespace_fd namespace_fd: int = trio.run(main) @@ -389,7 +390,7 @@ def test_open_netns_owns_lifecycle( Successful creation must yield ownership and remove on exit. Fake pyroute2 creation with a named stand-in file, verify the - yielded FD and identity while it exists, then prove FD closure + yielded FD and ref while it exists, then prove FD closure precedes resource removal when the context exits. ''' @@ -444,11 +445,11 @@ def test_open_netns_owns_lifecycle( key='tractor-wg0', lifecycle='open', ) - async with open_bindspace(spec) as handle: - fd: int|None = handle.namespace_fd + async with open_bindspace(spec) as bindspace: + fd: int|None = bindspace.namespace_fd assert fd is not None - assert handle.ownership == 'owned' - assert handle.identity.inode == os.fstat(fd).st_ino + assert bindspace.ownership == 'owned' + assert bindspace.ref.inode == os.fstat(fd).st_ino namespace_fds.append(fd) events.append('yield') diff --git a/tests/discovery/test_wg_iface_lifecycle.py b/tests/discovery/test_wg_iface_lifecycle.py index 1c14be1c..c1e006ba 100644 --- a/tests/discovery/test_wg_iface_lifecycle.py +++ b/tests/discovery/test_wg_iface_lifecycle.py @@ -14,8 +14,8 @@ import pytest import trio from tractor.discovery import ( - BindspaceHandle, - BindspaceIdentity, + Bindspace, + BindspaceRef, BindspaceSpec, WGInterfaceConfig, WGPeerConfig, @@ -159,7 +159,7 @@ def test_open_wg_iface_shields_cancelled_cleanup( def create( spec: WGTunnelSpec, config: WGInterfaceConfig, - bindspace: BindspaceHandle, + bindspace: Bindspace, listen_port: int|None, peers: tuple[dict[str, object], ...], ) -> None: @@ -174,7 +174,7 @@ def test_open_wg_iface_shields_cancelled_cleanup( def remove( spec: WGTunnelSpec, - bindspace: BindspaceHandle, + bindspace: Bindspace, ) -> None: ''' Record shielded removal after cancellation. @@ -200,9 +200,9 @@ def test_open_wg_iface_shields_cancelled_cleanup( kind='netns', key='tractor-wg0', ) - bindspace: BindspaceHandle = BindspaceHandle( + bindspace: Bindspace = Bindspace( spec=bindspace_spec, - identity=BindspaceIdentity( + ref=BindspaceRef( kind='netns', key='tractor-wg0', inode=os.fstat(namespace_fd).st_ino, @@ -249,8 +249,8 @@ def test_open_wg_bindspace_nests_resource_lifetimes( 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 + mutable layer list at bindspace entry, then cancel from inside + the yielded application scope and checkpoint. The trace proves 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. @@ -261,16 +261,16 @@ def test_open_wg_bindspace_nests_resource_lifetimes( tuple[ WGTunnelSpec, WGInterfaceConfig, - BindspaceHandle, + Bindspace, _tunnel.WGRole, ] ] = [] bindspace_spec: BindspaceSpec = BindspaceSpec( kind='netns', ) - bindspace: BindspaceHandle = BindspaceHandle( + bindspace: Bindspace = Bindspace( spec=bindspace_spec, - identity=BindspaceIdentity( + ref=BindspaceRef( kind='netns', key=None, inode=1, @@ -302,7 +302,7 @@ def test_open_wg_bindspace_nests_resource_lifetimes( @acm async def fake_open_bindspace( spec: BindspaceSpec, - ) -> AsyncIterator[BindspaceHandle]: + ) -> AsyncIterator[Bindspace]: ''' Yield the stand-in bindspace and record its full lifetime. @@ -319,19 +319,19 @@ def test_open_wg_bindspace_nests_resource_lifetimes( async def fake_open_wg_iface( spec: WGTunnelSpec, config: WGInterfaceConfig, - handle: BindspaceHandle, + bindspace_arg: Bindspace, role: _tunnel.WGRole, ) -> AsyncIterator[WGTunnelSpec]: ''' Record one interface's arguments and nested lifetime. ''' - calls.append((spec, config, handle, role)) + calls.append((spec, config, bindspace_arg, role)) events.append(f'{spec.iface}-enter') try: yield spec finally: - assert handle is bindspace + assert bindspace_arg is bindspace events.append(f'{spec.iface}-exit') monkeypatch.setattr( @@ -355,8 +355,8 @@ def test_open_wg_bindspace_nests_resource_lifetimes( bindspace_spec, layers, 'dial', - ) as handle: - assert handle is bindspace + ) as opened_bindspace: + assert opened_bindspace is bindspace events.append('yield') scope.cancel() await trio.sleep_forever() diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index f454f4a4..6fc9ad5c 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -25,11 +25,11 @@ here to avoid circular imports; use direct module paths for those. ''' from ._bindspace import ( - BindspaceHandle as BindspaceHandle, - BindspaceIdentity as BindspaceIdentity, + Bindspace as Bindspace, BindspaceKind as BindspaceKind, BindspaceLifecycle as BindspaceLifecycle, BindspaceOwnership as BindspaceOwnership, + BindspaceRef as BindspaceRef, BindspaceSpec as BindspaceSpec, CURRENT_NETNS as CURRENT_NETNS, attach_netns as attach_netns, diff --git a/tractor/discovery/_bindspace.py b/tractor/discovery/_bindspace.py index 255b0fe3..2e2c788e 100644 --- a/tractor/discovery/_bindspace.py +++ b/tractor/discovery/_bindspace.py @@ -15,7 +15,7 @@ # License along with this program. If not, see # . ''' -Serializable bindspace declarations and live capability handles. +Serializable bindspace declarations and live capabilities. ''' from __future__ import annotations @@ -91,7 +91,7 @@ def _validate_bindspace_key( Reject empty or path-like platform-resource names. `None` is valid. Spell it `CURRENT_NETNS` for - `BindspaceSpec.key`; `BindspaceIdentity.key = None` records an + `BindspaceSpec.key`; `BindspaceRef.key = None` records an unnamed realized netns. ''' @@ -145,16 +145,17 @@ class BindspaceSpec( ) -class BindspaceIdentity( +class BindspaceRef( msgspec.Struct, frozen=True, ): ''' - Serializable stable identity of one realized bindspace. + Serializable, non-owning ref to one realized bindspace. - `.key` is an optional, mutable namespace name. `.inode` is the - required kernel identity which remains stable after rename or - unlink. + `.key` is an optional mutable namespace locator. `.inode` is a + host-local kernel fingerprint which remains stable while the + resource exists or a live `Bindspace` pins it. This ref grants no + authority and cannot reopen the resource by itself. ''' kind: BindspaceKind @@ -163,14 +164,14 @@ class BindspaceIdentity( def __post_init__(self) -> None: ''' - Require a stable platform identity and an optional name. + Require a host-local resource inode and an optional locator. ''' _validate_bindspace_kind(self.kind) _validate_bindspace_key( self.kind, self.key, - 'BindspaceIdentity.key', + 'BindspaceRef.key', ) if ( type(self.inode) is not int @@ -178,23 +179,23 @@ class BindspaceIdentity( self.inode <= 0 ): raise ValueError( - '`BindspaceIdentity.inode` must be a positive `int`!' + '`BindspaceRef.inode` must be a positive `int`!' ) -class BindspaceHandle( +class Bindspace( ProcessLocal, ): ''' Process-local capability for one live realized bindspace. `ProcessLocal` provides compact typed storage plus a default - wire-encoding guard. Explicit FD transfer and handle construction - belong to the supervisor's spawn/bootstrap path. + wire-encoding guard. `Bindspace` construction and explicit FD + transfer belong to the supervisor's spawn/bootstrap path. ''' spec: BindspaceSpec - identity: BindspaceIdentity + ref: BindspaceRef namespace_fd: int|None ownership: BindspaceOwnership @@ -204,23 +205,23 @@ class BindspaceHandle( ''' spec: BindspaceSpec = self.spec - identity: BindspaceIdentity = self.identity + ref: BindspaceRef = self.ref namespace_fd: int|None = self.namespace_fd ownership: BindspaceOwnership = self.ownership - if spec.kind != identity.kind: + if spec.kind != ref.kind: raise ValueError( '`BindspaceSpec.kind` does not match ' - '`BindspaceIdentity.kind`!' + '`BindspaceRef.kind`!' ) if ( spec.key is not None and - spec.key != identity.key + spec.key != ref.key ): raise ValueError( '`BindspaceSpec.key` does not match ' - '`BindspaceIdentity.key`!' + '`BindspaceRef.key`!' ) if ownership not in get_args(BindspaceOwnership): raise ValueError( @@ -246,20 +247,20 @@ class BindspaceHandle( '`namespace_fd` must be non-negative or `None`!' ) fd_inode: int = os.fstat(namespace_fd).st_ino - if identity.inode != fd_inode: + if ref.inode != fd_inode: raise ValueError( f'Namespace FD inode {fd_inode} does not match ' - f'identity inode {identity.inode}!' + f'reference inode {ref.inode}!' ) def __repr__(self) -> str: ''' - Render capability identity without dereferencing its FD. + Render the capability ref without dereferencing its FD. ''' return ( f'{type(self).__name__}(' - f'identity={self.identity!r}, ' + f'ref={self.ref!r}, ' f'ownership={self.ownership!r}, ' f'namespace_fd={self.namespace_fd!r})' ) @@ -269,7 +270,7 @@ class BindspaceHandle( async def _pin_netns( spec: BindspaceSpec, ownership: BindspaceOwnership, -) -> AsyncIterator[BindspaceHandle]: +) -> AsyncIterator[Bindspace]: ''' Pin one existing Linux network namespace with explicit ownership. @@ -286,18 +287,18 @@ async def _pin_netns( ) try: inode: int = os.fstat(namespace_fd).st_ino - identity: BindspaceIdentity = BindspaceIdentity( + ref: BindspaceRef = BindspaceRef( kind='netns', key=key, inode=inode, ) - handle: BindspaceHandle = BindspaceHandle( + bindspace: Bindspace = Bindspace( spec=spec, - identity=identity, + ref=ref, namespace_fd=namespace_fd, ownership=ownership, ) - yield handle + yield bindspace finally: os.close(namespace_fd) @@ -305,7 +306,7 @@ async def _pin_netns( @acm async def attach_netns( spec: BindspaceSpec, -) -> AsyncIterator[BindspaceHandle]: +) -> AsyncIterator[Bindspace]: ''' Borrow and pin one existing Linux network namespace. @@ -326,8 +327,8 @@ async def attach_netns( async with _pin_netns( spec, ownership='borrowed', - ) as handle: - yield handle + ) as bindspace: + yield bindspace def _create_netns( @@ -367,7 +368,7 @@ def _remove_netns( @acm async def open_netns( spec: BindspaceSpec, -) -> AsyncIterator[BindspaceHandle]: +) -> AsyncIterator[Bindspace]: ''' Create, pin and own one named Linux network namespace. @@ -404,8 +405,8 @@ async def open_netns( async with _pin_netns( spec, ownership='owned', - ) as handle: - yield handle + ) as bindspace: + yield bindspace finally: if created: with trio.CancelScope(shield=True): @@ -419,7 +420,7 @@ async def open_netns( @acm async def open_bindspace( spec: BindspaceSpec, -) -> AsyncIterator[BindspaceHandle]: +) -> AsyncIterator[Bindspace]: ''' Dispatch one declared bindspace lifecycle. @@ -428,8 +429,8 @@ async def open_bindspace( ''' if spec.lifecycle == 'attach': - async with attach_netns(spec) as handle: - yield handle + async with attach_netns(spec) as bindspace: + yield bindspace else: - async with open_netns(spec) as handle: - yield handle + async with open_netns(spec) as bindspace: + yield bindspace diff --git a/tractor/discovery/_tunnel.py b/tractor/discovery/_tunnel.py index 5cbc5e7c..92267427 100644 --- a/tractor/discovery/_tunnel.py +++ b/tractor/discovery/_tunnel.py @@ -96,7 +96,7 @@ import trio from ..msg._local import ProcessLocal from ._bindspace import ( - BindspaceHandle, + Bindspace, BindspaceSpec, open_bindspace, ) @@ -460,7 +460,7 @@ def _wg_iface_settings( def _sync_create_wg_iface( spec: WGTunnelSpec, config: WGInterfaceConfig, - bindspace: BindspaceHandle, + bindspace: Bindspace, listen_port: int|None, peers: tuple[dict[str, object], ...], ) -> None: @@ -556,7 +556,7 @@ def _sync_create_wg_iface( def _sync_remove_wg_iface( spec: WGTunnelSpec, - bindspace: BindspaceHandle, + bindspace: Bindspace, ) -> None: ''' Remove one owned WireGuard iface when it still exists. @@ -590,7 +590,7 @@ def _sync_remove_wg_iface( async def open_wg_iface( spec: WGTunnelSpec, config: WGInterfaceConfig, - bindspace: BindspaceHandle, + bindspace: Bindspace, role: WGRole, ) -> AsyncIterator[WGTunnelSpec]: ''' @@ -634,7 +634,7 @@ async def open_wg_bindspace( bindspace_spec: BindspaceSpec, layers: Sequence[tuple[WGTunnelSpec, WGInterfaceConfig]], role: WGRole, -) -> AsyncIterator[BindspaceHandle]: +) -> AsyncIterator[Bindspace]: ''' Open one bindspace and its ordered WireGuard interface stack. @@ -659,7 +659,7 @@ async def open_wg_bindspace( ..., ] = tuple(layers) async with AsyncExitStack() as stack: - bindspace: BindspaceHandle = await ( + bindspace: Bindspace = await ( stack.enter_async_context( open_bindspace(bindspace_spec) )