Rename bindspace resource models

Replace the unshipped `BindspaceIdentity` and `BindspaceHandle` names
with `BindspaceRef` and `Bindspace` across existing lifecycle APIs.

Deats,
- define refs as wire-safe, host-local and non-owning records
- reserve `Bindspace` for the live FD-backed capability
- rename the capability's realized-resource field to `.ref`
- update lifecycle tests and active design contracts
- omit compatibility aliases for the unshipped model names

Prompt-IO: ai/prompt-io/opencode/20260827T211115Z_d130431c_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Gud Boi 2026-08-27 17:22:28 -04:00
parent d130431ca1
commit f538c4f5d1
9 changed files with 232 additions and 157 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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/<pid>/ns/<type>` 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 |

View File

@ -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')

View File

@ -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()

View File

@ -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,

View File

@ -15,7 +15,7 @@
# License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
'''
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

View File

@ -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)
)