Compare commits

..

No commits in common. "d067505addc69690962232e80fbdff939dabebd1" and "d130431ca1981429c45e3c9d738cd4408e84211e" have entirely different histories.

13 changed files with 169 additions and 465 deletions

View File

@ -1,54 +0,0 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking
timestamp: 2026-08-26T03:05:34Z
git_ref: d130431c
scope: code
substantive: true
raw_file: 20260826T030534Z_d130431c_prompt_io.raw.md
---
## Prompt
After committing the reviewed bindspace/interface stack, the human
asked to continue with the next established Layer C step.
## Response summary
Retained a serializable realized `BindspaceRef` on frozen tunnelled
address declarations without carrying the process-local `Bindspace`
capability. The namespace API remains tuple-shaped, using the declared
key before realization and stable inode afterward. Existing transport
peeling and unrealized serialization remain unchanged.
## Files changed
- `tractor/discovery/_tunnel.py` - realized bindspace-ref retention and
immutable annotation method.
- `tests/discovery/test_tunnelled_addr.py` - ref, compatibility and
mismatch coverage.
- `tests/ipc/test_server_tunnel_boundary.py` - real listener diagnostic
coverage for the realized inode.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - realized namespace
visibility contract.
## Human edits
The human accepted and committed the preceding reviewed composition
boundary, then requested the next step. Their stated preference for
end-to-end tests informed use of the existing real TCP listener path to
verify endpoint/server diagnostics, while focused unit coverage handles
metadata invariants without pyroute2 overhead. During review, the human
chose the final unshipped terminology: `BindspaceRef` for the
serializable non-owning reference, `Bindspace` for the live
process-local capability, `.ref` for that capability's reference, and
`bindspace_ref` at the tunnel declaration API.
They then requested the prerequisite model rename as a separate commit
before this feature. The human also replaced the module-level helper
with `TunnelledAddress.with_bindspace_ref()` and requested inline
msgspec encode/decode expressions in the serialization assertions. The
human chose not to add a second typed `.namespace` projection, and
requested native tagged `TunnelledAddress` decoding remain as a separate
design-plan follow-up. The agent applied those human-directed changes;
no direct manual source edits were observed.

View File

@ -1,31 +0,0 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-26T03:05:34Z
git_ref: d130431c
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - retain realized bindspace identity
After committing WireGuard bindspace composition, the human requested
the next incremental Layer C change.
> `git diff HEAD~1..HEAD -- tractor/discovery/_tunnel.py tractor/discovery/__init__.py tests/discovery/test_tunnelled_addr.py tests/ipc/test_server_tunnel_boundary.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
Added optional `BindspaceIdentity` metadata to frozen
`TunnelledAddress` declarations and a pure
`with_bindspace_identity()` annotation helper. Unrealized declarations
retain their prior serialized shape. Realized declarations retain only
serializable key/inode identity, never the FD-bearing capability.
The existing `.namespace` tuple contract remains compatible:
unrealized declarations report `(kind, key)`, while realized
declarations report the stable `(kind, inode)`. Name mismatches between
the tunnel declaration and realized bindspace are rejected.
Unit coverage verifies immutability, serialization, delegation and
mismatch handling. The existing real TCP listener test proves endpoint
and server diagnostics expose the retained inode without implying that
the process entered that namespace. Ruff passed and focused tunnel,
listener and bindspace coverage passed 30 tests.

View File

@ -1,43 +0,0 @@
---
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

@ -1,26 +0,0 @@
---
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,10 +115,9 @@ 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` 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.
`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.
`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 capabilities. A manager crash fails closed:
underlying namespace/capability handles. 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 three bindspace representations with deliberately different roles
and lifetimes:
Keep two bindspace representations with deliberately different
lifetimes:
```python
class BindspaceSpec(msgspec.Struct, frozen=True):
@ -411,17 +411,17 @@ class BindspaceSpec(msgspec.Struct, frozen=True):
lifecycle: Literal['attach', 'open']
class BindspaceRef(msgspec.Struct, frozen=True):
'''Wire-safe, non-owning ref to the realized resource.'''
class BindspaceIdentity(msgspec.Struct, frozen=True):
'''Stable identity of the realized platform resource.'''
kind: str
key: str|None # mutable name, absent after unlink
inode: int # host-local Linux nsfs fingerprint
inode: int # stable Linux namespace identity
class Bindspace(ProcessLocal):
class BindspaceHandle(ProcessLocal):
'''Scoped, non-serializable capability for one live bindspace.'''
spec: BindspaceSpec
ref: BindspaceRef
identity: BindspaceIdentity
namespace_fd: int|None
ownership: Literal['owned', 'borrowed']
@ -429,7 +429,7 @@ class Bindspace(ProcessLocal):
@acm
async def open_bindspace(
spec: BindspaceSpec,
) -> AsyncGenerator[Bindspace, None]:
) -> AsyncGenerator[BindspaceHandle, None]:
'''
Provision/borrow one bindspace and yield its live capability.
@ -437,17 +437,15 @@ async def open_bindspace(
```
The initial model limits `BindspaceKind` to `netns` while preserving
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,
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,
survives rename/unlink, and identifies the exact namespace the parent
provisioned. Extend the kind/field union only when a second platform
resource is implemented.
@ -461,7 +459,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 a `BindspaceRef` from the opened FD, yields
`/var/run/netns`. It derives identity 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
@ -476,8 +474,7 @@ 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 live bindspace to replace an overlay while preserving every
tunnel:
use the handle to replace an overlay while preserving every tunnel:
```python
async with open_bindspace(
@ -506,13 +503,13 @@ tunnel/bindspace layer:
@acm
async def open_netns(
spec: BindspaceSpec,
) -> AsyncGenerator[Bindspace, None]: ...
) -> AsyncGenerator[BindspaceHandle, None]: ...
@acm
async def open_wg_iface(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: Bindspace,
bindspace: BindspaceHandle,
role: Literal['listen', 'dial'],
) -> AsyncGenerator[WGTunnelSpec, None]: ...
```
@ -545,7 +542,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 `Bindspace` for endpoint
`AsyncExitStack`, and yields the live `BindspaceHandle` for endpoint
allocation. Exit is inside-out, so every interface is removed while the
namespace FD remains pinned; only then can an owned namespace be
removed. Endpoint/channel lifetimes belong inside the yielded scope.
@ -557,12 +554,8 @@ composed maddr can name a server source or client destination (§5.4).
### 5.3 `Address.namespace`, at last
- an unrealized `TunnelledAddress.namespace` reports its declared name
as `(kind, key)`, e.g. `('netns', 'tractor-wg0')`;
- `TunnelledAddress.with_bindspace_ref()` returns a frozen declaration
annotated with `bindspace.ref`, never the FD-bearing `Bindspace`.
Its `.namespace` reports `(kind, inode)` so the
realized ref remains stable across rename or unlink;
- `TunnelledAddress.namespace``(kind, id)` e.g.
`('netns', 'tractor-wg0')`.
- existing plain backends implement it explicitly as `None`, so the
Protocol does not lie and tunnel delegation needs no `getattr()`
fallback.
@ -571,19 +564,12 @@ composed maddr can name a server source or client destination (§5.4).
`Server.pformat()` expose that value as the cheapest proof the layer
is wired.
Deferred follow-ups:
- add native tagged encoding for the complete `TunnelledAddress` graph,
including its concrete overlay-address union, tunnel-spec union and
optional `BindspaceRef`. Once that codec exists, tests should perform
typed roundtrips instead of inspecting an untyped decoded payload.
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)` reference
record. Layer C should move that shape into `BindspaceRef`, avoid a
`ip netns identify` probe establish the useful `(key, inode)` identity
pair. Layer C should move that shape into `BindspaceIdentity`, avoid a
subprocess where netlink/procfs suffices, and hold the namespace FD in
`Bindspace` to pin the referenced resource.
`BindspaceHandle` to pin the identity.
### 5.4 the netns/process reality — read this before designing
@ -626,7 +612,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 `Bindspace` context.
the parent/supervisor's `BindspaceHandle` 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`;
@ -655,7 +641,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 capability;
- borrowed dial/actor-wide bindspaces only release their handle;
- 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
@ -727,7 +713,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 `Bindspace`; borrowed bindspaces never remove resources |
| dial path tears down a shared actor bindspace | encode ownership in `BindspaceHandle`; borrowed handles 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, reference and live-capability contracts.
Bindspace declaration, identity and live-capability contracts.
'''
from __future__ import annotations
@ -14,9 +14,9 @@ import pytest
import trio
from tractor.discovery import (
Bindspace,
BindspaceHandle,
BindspaceIdentity,
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 refs must cross actor IPC.
Spawn configuration and realized identity must cross actor IPC.
Encode both frozen structs through msgpack and decode with their
concrete types, proving names and stable inode refs survive
concrete types, proving names and stable inode identity survive
without carrying any process-local capability state.
'''
values: tuple[
BindspaceSpec|BindspaceRef,
BindspaceSpec|BindspaceIdentity,
...,
] = (
BindspaceSpec(
@ -45,16 +45,16 @@ def test_bindspace_declarations_roundtrip() -> None:
key='tractor-wg0',
lifecycle='open',
),
BindspaceRef(
BindspaceIdentity(
kind='netns',
key='tractor-wg0',
inode=1234,
),
)
value: BindspaceSpec|BindspaceRef
value: BindspaceSpec|BindspaceIdentity
for value in values:
encoded: bytes = msgspec.msgpack.encode(value)
decoded: BindspaceSpec|BindspaceRef = (
decoded: BindspaceSpec|BindspaceIdentity = (
msgspec.msgpack.decode(
encoded,
type=type(value),
@ -63,17 +63,16 @@ def test_bindspace_declarations_roundtrip() -> None:
assert decoded == value
def test_bindspace_pins_local_capability(
def test_bindspace_handle_pins_local_capability(
tmp_path: Path,
) -> None:
'''
A live bindspace pins one exact FD and realized ref.
A live handle pins one exact FD and realized identity.
Open a stand-in platform FD, record its inode in the realized
ref and construct an owned capability. Prove the generic
Open a stand-in platform handle, record its inode in the realized
identity 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
bindspace.
encode ordinary fields is not authority to transfer the handle.
'''
token_path: Path = tmp_path / 'bindspace'
@ -87,36 +86,36 @@ def test_bindspace_pins_local_capability(
key='tractor-wg0',
lifecycle='open',
)
ref: BindspaceRef = BindspaceRef(
identity: BindspaceIdentity = BindspaceIdentity(
kind='netns',
key='tractor-wg0',
inode=inode,
)
bindspace: Bindspace = Bindspace(
handle: BindspaceHandle = BindspaceHandle(
spec=spec,
ref=ref,
identity=identity,
namespace_fd=namespace_fd,
ownership='owned',
)
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)
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)
with pytest.raises(
TypeError,
match='_ProcessLocalToken.*unsupported',
):
msgspec.msgpack.encode(bindspace)
msgspec.msgpack.encode(handle)
def test_bindspace_rejects_mismatched_ref(
def test_bindspace_handle_rejects_mismatched_identity(
tmp_path: Path,
) -> None:
'''
A name or inode mismatch would make a bindspace stale authority.
A name or inode mismatch would make a handle stale authority.
Construct a requested named spec, then prove both a different
realized name and an inode not belonging to the supplied FD are
@ -129,39 +128,39 @@ def test_bindspace_rejects_mismatched_ref(
kind='netns',
key='tractor-wg0',
)
# Keep ownership and FD fixed so only the ref changes below.
# Keep ownership and FD fixed so only identity changes below.
ownership: BindspaceOwnership = 'borrowed'
namespace_file: BinaryIO
with token_path.open('rb') as namespace_file:
namespace_fd: int = namespace_file.fileno()
wrong_name: BindspaceRef = BindspaceRef(
wrong_name: BindspaceIdentity = BindspaceIdentity(
kind='netns',
key='other-wg',
inode=token_path.stat().st_ino,
)
with pytest.raises(
ValueError,
match='Spec.key.*Ref.key',
match='Spec.key.*Identity.key',
):
Bindspace(
BindspaceHandle(
spec=spec,
ref=wrong_name,
identity=wrong_name,
namespace_fd=namespace_fd,
ownership=ownership,
)
wrong_inode: BindspaceRef = BindspaceRef(
wrong_inode: BindspaceIdentity = BindspaceIdentity(
kind='netns',
key='tractor-wg0',
inode=token_path.stat().st_ino + 1,
)
with pytest.raises(
ValueError,
match='FD inode.*reference inode',
match='FD inode.*identity inode',
):
Bindspace(
BindspaceHandle(
spec=spec,
ref=wrong_inode,
identity=wrong_inode,
namespace_fd=namespace_fd,
ownership=ownership,
)
@ -171,14 +170,14 @@ def test_bindspace_rejects_mismatched_ref(
('model', 'kwargs', 'match'),
(
pytest.param(
BindspaceRef,
BindspaceIdentity,
{
'kind': 'netns',
'key': None,
'inode': None,
},
'must be a positive `int`',
id='ref-requires-inode',
id='identity-requires-inode',
),
pytest.param(
BindspaceSpec,
@ -187,14 +186,14 @@ def test_bindspace_rejects_mismatched_ref(
id='spec-rejects-kind',
),
pytest.param(
BindspaceRef,
BindspaceIdentity,
{
'kind': 'vrf',
'key': 'blue',
'inode': 1234,
},
'Unsupported bindspace kind',
id='ref-rejects-kind',
id='identity-rejects-kind',
),
pytest.param(
BindspaceSpec,
@ -225,19 +224,19 @@ def test_bindspace_rejects_mismatched_ref(
id='spec-rejects-lifecycle',
),
pytest.param(
BindspaceRef,
BindspaceIdentity,
{
'kind': 'netns',
'key': '',
'inode': 1234,
},
'BindspaceRef.key',
id='ref-rejects-empty-key',
'BindspaceIdentity.key',
id='identity-rejects-empty-key',
),
),
)
def test_bindspace_models_reject_invalid_values(
model: type[BindspaceSpec]|type[BindspaceRef],
model: type[BindspaceSpec]|type[BindspaceIdentity],
kwargs: dict[str, object],
match: str,
) -> None:
@ -246,7 +245,7 @@ def test_bindspace_models_reject_invalid_values(
Parameterize the missing stable inode and future, unimplemented
kinds. Prove neither serializable model can carry invalid
refs or provisioning instructions into spawn configuration.
identity or provisioning instructions into spawn configuration.
'''
with pytest.raises(ValueError, match=match):
@ -261,7 +260,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 bindspace records its
Open `/proc/self/ns/net`, prove the yielded handle records its
stable inode and borrowed ownership, then exit the context and
prove the exact descriptor was closed without altering the
namespace itself.
@ -276,15 +275,15 @@ def test_open_bindspace_attaches_current_netns() -> None:
kind='netns',
)
assert spec.key is CURRENT_NETNS
async with open_bindspace(spec) as bindspace:
namespace_fd: int|None = bindspace.namespace_fd
async with open_bindspace(spec) as handle:
namespace_fd: int|None = handle.namespace_fd
assert namespace_fd is not None
assert bindspace.spec is spec
assert bindspace.ref.key is None
assert bindspace.ref.inode == os.fstat(
assert handle.spec is spec
assert handle.identity.key is None
assert handle.identity.inode == os.fstat(
namespace_fd
).st_ino
assert bindspace.ownership == 'borrowed'
assert handle.ownership == 'borrowed'
return namespace_fd
namespace_fd: int = trio.run(main)
@ -325,12 +324,12 @@ def test_attach_named_netns_uses_run_directory(
kind='netns',
key='tractor-wg0',
)
async with attach_netns(spec) as bindspace:
namespace_fd: int|None = bindspace.namespace_fd
async with attach_netns(spec) as handle:
namespace_fd: int|None = handle.namespace_fd
assert namespace_fd is not None
assert bindspace.ref.key == 'tractor-wg0'
assert bindspace.ref.inode == netns_path.stat().st_ino
assert bindspace.ownership == 'borrowed'
assert handle.identity.key == 'tractor-wg0'
assert handle.identity.inode == netns_path.stat().st_ino
assert handle.ownership == 'borrowed'
return namespace_fd
namespace_fd: int = trio.run(main)
@ -390,7 +389,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 ref while it exists, then prove FD closure
yielded FD and identity while it exists, then prove FD closure
precedes resource removal when the context exits.
'''
@ -445,11 +444,11 @@ def test_open_netns_owns_lifecycle(
key='tractor-wg0',
lifecycle='open',
)
async with open_bindspace(spec) as bindspace:
fd: int|None = bindspace.namespace_fd
async with open_bindspace(spec) as handle:
fd: int|None = handle.namespace_fd
assert fd is not None
assert bindspace.ownership == 'owned'
assert bindspace.ref.inode == os.fstat(fd).st_ino
assert handle.ownership == 'owned'
assert handle.identity.inode == os.fstat(fd).st_ino
namespace_fds.append(fd)
events.append('yield')

View File

@ -15,7 +15,6 @@ import msgspec
import pytest
from tractor.discovery import (
BindspaceRef,
TunnelledAddress,
WGTunnelSpec,
mb_pubkey,
@ -197,71 +196,6 @@ def test_namespace_comes_from_the_tunnel(
assert in_ns.namespace == ('netns', 'wg-test')
def test_realized_namespace_uses_stable_ref(
overlay: TCPAddress,
) -> None:
'''
Realization must retain a stable ref without mutating the maddr.
Build an unrealized named declaration, annotate it with the
matching realized key and inode, and prove the frozen original is
unchanged. The annotated copy must preserve transport delegation
and expose the stable inode through `.namespace`. Direct msgspec
encoding also proves only serializable ref metadata was retained.
'''
declared: TunnelledAddress = TunnelledAddress(
overlay=overlay,
tunnel=WGTunnelSpec(
peer_pubkey=_PUBKEY,
netns='wg-test',
),
)
ref: BindspaceRef = BindspaceRef(
kind='netns',
key='wg-test',
inode=1234,
)
realized: TunnelledAddress = declared.with_bindspace_ref(
ref,
)
assert declared.bindspace_ref is None
assert realized.bindspace_ref is ref
assert realized.namespace == ('netns', 1234)
assert realized.overlay is declared.overlay
assert realized.tunnel is declared.tunnel
assert realized.unwrap() == declared.unwrap()
assert realized.bindspace == declared.bindspace
declared_payload: dict[str, object] = msgspec.msgpack.decode(
msgspec.msgpack.encode(declared)
)
assert 'bindspace_ref' not in declared_payload
decoded: dict[str, object] = msgspec.msgpack.decode(
msgspec.msgpack.encode(realized)
)
assert decoded['bindspace_ref'] == {
'kind': 'netns',
'key': 'wg-test',
'inode': 1234,
}
mismatched: BindspaceRef = BindspaceRef(
kind='netns',
key='other-netns',
inode=5678,
)
with pytest.raises(
ValueError,
match='wg-test.*other-netns',
):
declared.with_bindspace_ref(
mismatched,
)
def test_strip_tunnels(
tunnelled: TunnelledAddress,
overlay: TCPAddress,

View File

@ -14,8 +14,8 @@ import pytest
import trio
from tractor.discovery import (
Bindspace,
BindspaceRef,
BindspaceHandle,
BindspaceIdentity,
BindspaceSpec,
WGInterfaceConfig,
WGPeerConfig,
@ -159,7 +159,7 @@ def test_open_wg_iface_shields_cancelled_cleanup(
def create(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: Bindspace,
bindspace: BindspaceHandle,
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: Bindspace,
bindspace: BindspaceHandle,
) -> None:
'''
Record shielded removal after cancellation.
@ -200,9 +200,9 @@ def test_open_wg_iface_shields_cancelled_cleanup(
kind='netns',
key='tractor-wg0',
)
bindspace: Bindspace = Bindspace(
bindspace: BindspaceHandle = BindspaceHandle(
spec=bindspace_spec,
ref=BindspaceRef(
identity=BindspaceIdentity(
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
mutable layer list at bindspace entry, then cancel from inside the
yielded application scope and checkpoint. The trace proves the
stack snapshots its declaration before entry, layers enter
outermost-first, cancellation exits them inside-out, and the live
bindspace remains available through every interface exit.
@ -261,16 +261,16 @@ def test_open_wg_bindspace_nests_resource_lifetimes(
tuple[
WGTunnelSpec,
WGInterfaceConfig,
Bindspace,
BindspaceHandle,
_tunnel.WGRole,
]
] = []
bindspace_spec: BindspaceSpec = BindspaceSpec(
kind='netns',
)
bindspace: Bindspace = Bindspace(
bindspace: BindspaceHandle = BindspaceHandle(
spec=bindspace_spec,
ref=BindspaceRef(
identity=BindspaceIdentity(
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[Bindspace]:
) -> AsyncIterator[BindspaceHandle]:
'''
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,
bindspace_arg: Bindspace,
handle: BindspaceHandle,
role: _tunnel.WGRole,
) -> AsyncIterator[WGTunnelSpec]:
'''
Record one interface's arguments and nested lifetime.
'''
calls.append((spec, config, bindspace_arg, role))
calls.append((spec, config, handle, role))
events.append(f'{spec.iface}-enter')
try:
yield spec
finally:
assert bindspace_arg is bindspace
assert handle 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 opened_bindspace:
assert opened_bindspace is bindspace
) as handle:
assert handle is bindspace
events.append('yield')
scope.cancel()
await trio.sleep_forever()

View File

@ -7,7 +7,6 @@ from __future__ import annotations
import trio
from tractor.discovery import (
BindspaceRef,
TunnelledAddress,
WGTunnelSpec,
tunnels_of,
@ -26,9 +25,8 @@ def test_server_peels_before_endpoint_construction() -> None:
instead of the TCP backend. Start a real listener from the
wrapper, assert the resulting `Endpoint` contains only a resolved
`TCPAddress`. Prove `Endpoint.declared_addr` still retains the
original declaration and realized bindspace ref for
diagnostics. This retained metadata does not claim the listener
process entered that namespace.
original tunnel namespace for diagnostics and the future
bindspace lifecycle.
'''
overlay = TCPAddress('127.0.0.1', 0)
@ -40,19 +38,11 @@ def test_server_peels_before_endpoint_construction() -> None:
netns='actor-net',
),
)
ref: BindspaceRef = BindspaceRef(
kind='netns',
key='actor-net',
inode=1234,
)
declared: TunnelledAddress = tunnelled.with_bindspace_ref(
ref,
)
async def main() -> None:
async with open_ipc_server() as server:
eps = await server.listen_on(
accept_addrs=[declared],
accept_addrs=[tunnelled],
)
assert len(eps) == 1
endpoint = eps[0]
@ -61,9 +51,9 @@ def test_server_peels_before_endpoint_construction() -> None:
_, host, port = endpoint.addr.unwrap()
assert host == overlay.unwrap()[1]
assert port > 0
assert endpoint.addr is not declared
assert endpoint.declared_addr is declared
namespace: tuple[str, int] = ('netns', 1234)
assert endpoint.addr is not tunnelled
assert endpoint.declared_addr is tunnelled
namespace: tuple[str, str] = ('netns', 'actor-net')
assert endpoint.namespace == namespace
endpoint_repr: str = endpoint.pformat()
server_repr: str = server.pformat()
@ -71,9 +61,9 @@ def test_server_peels_before_endpoint_construction() -> None:
assert expected_namespace in endpoint_repr
assert ' |_namespaces:' in server_repr
assert 'netns' in server_repr
assert '1234' in server_repr
assert tunnels_of(declared) == (
declared.tunnel,
assert 'actor-net' in server_repr
assert tunnels_of(tunnelled) == (
tunnelled.tunnel,
)
server.cancel()

View File

@ -25,11 +25,11 @@ here to avoid circular imports; use direct module paths for those.
'''
from ._bindspace import (
Bindspace as Bindspace,
BindspaceHandle as BindspaceHandle,
BindspaceIdentity as BindspaceIdentity,
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 capabilities.
Serializable bindspace declarations and live capability handles.
'''
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`; `BindspaceRef.key = None` records an
`BindspaceSpec.key`; `BindspaceIdentity.key = None` records an
unnamed realized netns.
'''
@ -145,17 +145,16 @@ class BindspaceSpec(
)
class BindspaceRef(
class BindspaceIdentity(
msgspec.Struct,
frozen=True,
):
'''
Serializable, non-owning ref to one realized bindspace.
Serializable stable identity of one realized bindspace.
`.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.
`.key` is an optional, mutable namespace name. `.inode` is the
required kernel identity which remains stable after rename or
unlink.
'''
kind: BindspaceKind
@ -164,14 +163,14 @@ class BindspaceRef(
def __post_init__(self) -> None:
'''
Require a host-local resource inode and an optional locator.
Require a stable platform identity and an optional name.
'''
_validate_bindspace_kind(self.kind)
_validate_bindspace_key(
self.kind,
self.key,
'BindspaceRef.key',
'BindspaceIdentity.key',
)
if (
type(self.inode) is not int
@ -179,23 +178,23 @@ class BindspaceRef(
self.inode <= 0
):
raise ValueError(
'`BindspaceRef.inode` must be a positive `int`!'
'`BindspaceIdentity.inode` must be a positive `int`!'
)
class Bindspace(
class BindspaceHandle(
ProcessLocal,
):
'''
Process-local capability for one live realized bindspace.
`ProcessLocal` provides compact typed storage plus a default
wire-encoding guard. `Bindspace` construction and explicit FD
transfer belong to the supervisor's spawn/bootstrap path.
wire-encoding guard. Explicit FD transfer and handle construction
belong to the supervisor's spawn/bootstrap path.
'''
spec: BindspaceSpec
ref: BindspaceRef
identity: BindspaceIdentity
namespace_fd: int|None
ownership: BindspaceOwnership
@ -205,23 +204,23 @@ class Bindspace(
'''
spec: BindspaceSpec = self.spec
ref: BindspaceRef = self.ref
identity: BindspaceIdentity = self.identity
namespace_fd: int|None = self.namespace_fd
ownership: BindspaceOwnership = self.ownership
if spec.kind != ref.kind:
if spec.kind != identity.kind:
raise ValueError(
'`BindspaceSpec.kind` does not match '
'`BindspaceRef.kind`!'
'`BindspaceIdentity.kind`!'
)
if (
spec.key is not None
and
spec.key != ref.key
spec.key != identity.key
):
raise ValueError(
'`BindspaceSpec.key` does not match '
'`BindspaceRef.key`!'
'`BindspaceIdentity.key`!'
)
if ownership not in get_args(BindspaceOwnership):
raise ValueError(
@ -247,20 +246,20 @@ class Bindspace(
'`namespace_fd` must be non-negative or `None`!'
)
fd_inode: int = os.fstat(namespace_fd).st_ino
if ref.inode != fd_inode:
if identity.inode != fd_inode:
raise ValueError(
f'Namespace FD inode {fd_inode} does not match '
f'reference inode {ref.inode}!'
f'identity inode {identity.inode}!'
)
def __repr__(self) -> str:
'''
Render the capability ref without dereferencing its FD.
Render capability identity without dereferencing its FD.
'''
return (
f'{type(self).__name__}('
f'ref={self.ref!r}, '
f'identity={self.identity!r}, '
f'ownership={self.ownership!r}, '
f'namespace_fd={self.namespace_fd!r})'
)
@ -270,7 +269,7 @@ class Bindspace(
async def _pin_netns(
spec: BindspaceSpec,
ownership: BindspaceOwnership,
) -> AsyncIterator[Bindspace]:
) -> AsyncIterator[BindspaceHandle]:
'''
Pin one existing Linux network namespace with explicit ownership.
@ -287,18 +286,18 @@ async def _pin_netns(
)
try:
inode: int = os.fstat(namespace_fd).st_ino
ref: BindspaceRef = BindspaceRef(
identity: BindspaceIdentity = BindspaceIdentity(
kind='netns',
key=key,
inode=inode,
)
bindspace: Bindspace = Bindspace(
handle: BindspaceHandle = BindspaceHandle(
spec=spec,
ref=ref,
identity=identity,
namespace_fd=namespace_fd,
ownership=ownership,
)
yield bindspace
yield handle
finally:
os.close(namespace_fd)
@ -306,7 +305,7 @@ async def _pin_netns(
@acm
async def attach_netns(
spec: BindspaceSpec,
) -> AsyncIterator[Bindspace]:
) -> AsyncIterator[BindspaceHandle]:
'''
Borrow and pin one existing Linux network namespace.
@ -327,8 +326,8 @@ async def attach_netns(
async with _pin_netns(
spec,
ownership='borrowed',
) as bindspace:
yield bindspace
) as handle:
yield handle
def _create_netns(
@ -368,7 +367,7 @@ def _remove_netns(
@acm
async def open_netns(
spec: BindspaceSpec,
) -> AsyncIterator[Bindspace]:
) -> AsyncIterator[BindspaceHandle]:
'''
Create, pin and own one named Linux network namespace.
@ -405,8 +404,8 @@ async def open_netns(
async with _pin_netns(
spec,
ownership='owned',
) as bindspace:
yield bindspace
) as handle:
yield handle
finally:
if created:
with trio.CancelScope(shield=True):
@ -420,7 +419,7 @@ async def open_netns(
@acm
async def open_bindspace(
spec: BindspaceSpec,
) -> AsyncIterator[Bindspace]:
) -> AsyncIterator[BindspaceHandle]:
'''
Dispatch one declared bindspace lifecycle.
@ -429,8 +428,8 @@ async def open_bindspace(
'''
if spec.lifecycle == 'attach':
async with attach_netns(spec) as bindspace:
yield bindspace
async with attach_netns(spec) as handle:
yield handle
else:
async with open_netns(spec) as bindspace:
yield bindspace
async with open_netns(spec) as handle:
yield handle

View File

@ -96,8 +96,7 @@ import trio
from ..msg._local import ProcessLocal
from ._bindspace import (
Bindspace,
BindspaceRef,
BindspaceHandle,
BindspaceSpec,
open_bindspace,
)
@ -461,7 +460,7 @@ def _wg_iface_settings(
def _sync_create_wg_iface(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: Bindspace,
bindspace: BindspaceHandle,
listen_port: int|None,
peers: tuple[dict[str, object], ...],
) -> None:
@ -557,7 +556,7 @@ def _sync_create_wg_iface(
def _sync_remove_wg_iface(
spec: WGTunnelSpec,
bindspace: Bindspace,
bindspace: BindspaceHandle,
) -> None:
'''
Remove one owned WireGuard iface when it still exists.
@ -591,7 +590,7 @@ def _sync_remove_wg_iface(
async def open_wg_iface(
spec: WGTunnelSpec,
config: WGInterfaceConfig,
bindspace: Bindspace,
bindspace: BindspaceHandle,
role: WGRole,
) -> AsyncIterator[WGTunnelSpec]:
'''
@ -635,7 +634,7 @@ async def open_wg_bindspace(
bindspace_spec: BindspaceSpec,
layers: Sequence[tuple[WGTunnelSpec, WGInterfaceConfig]],
role: WGRole,
) -> AsyncIterator[Bindspace]:
) -> AsyncIterator[BindspaceHandle]:
'''
Open one bindspace and its ordered WireGuard interface stack.
@ -660,7 +659,7 @@ async def open_wg_bindspace(
...,
] = tuple(layers)
async with AsyncExitStack() as stack:
bindspace: Bindspace = await (
bindspace: BindspaceHandle = await (
stack.enter_async_context(
open_bindspace(bindspace_spec)
)
@ -859,7 +858,6 @@ def _wg_proto_code() -> int:
class TunnelledAddress(
msgspec.Struct,
frozen=True,
omit_defaults=True,
):
'''
An `Address` annotated with the tunnel it must be reached
@ -874,32 +872,6 @@ class TunnelledAddress(
'''
overlay: Address|TunnelledAddress
tunnel: TunnelSpec
bindspace_ref: BindspaceRef|None = None
def __post_init__(self) -> None:
'''
Validate the retained ref against the tunnel declaration.
'''
ref: BindspaceRef|None = self.bindspace_ref
if ref is None:
return
if not isinstance(ref, BindspaceRef):
raise TypeError(
'`TunnelledAddress.bindspace_ref` must be a '
'`BindspaceRef` or `None`!'
)
declared_netns: str|None = self.tunnel.netns
if (
declared_netns is not None
and
ref.key != declared_netns
):
raise ValueError(
f'Declared netns {declared_netns!r} does not match '
f'realized bindspace key {ref.key!r}!'
)
# ---- delegated, so the runtime can't tell the difference ----
@ -939,35 +911,14 @@ class TunnelledAddress(
@property
def namespace(self) -> tuple[str, str|int]|None:
'''
Return the realized ref or declared tunnel netns.
The tunnel's netns, when it declares one.
'''
ref: BindspaceRef|None = self.bindspace_ref
if ref is not None:
return (
ref.kind,
ref.inode,
)
if (netns := self.tunnel.netns) is None:
return self.overlay.namespace
return ('netns', netns)
def with_bindspace_ref(
self,
ref: BindspaceRef,
) -> TunnelledAddress:
'''
Return a copy retaining one realized bindspace ref.
'''
realized: TunnelledAddress = msgspec.structs.replace(
self,
bindspace_ref=ref,
)
return realized
def __repr__(self) -> str:
return (
f'{type(self).__name__}(\n'