Retain realized `BindspaceRef` metadata
Add `TunnelledAddress.with_bindspace_ref()` to annotate frozen tunnel declarations with stable, serializable namespace metadata. Deats, - omit absent refs from the existing msgspec shape - reject declared and realized namespace-name mismatches - report the declared key before realization and inode afterward - keep transport peeling free of live bindspace capability state - expose realized refs through real listener diagnostics Prompt-IO: ai/prompt-io/opencode/20260826T030534Z_d130431c_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))wkt/wg_pyroute2_read
parent
244e080efe
commit
d1ffb436d0
|
|
@ -0,0 +1,54 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
|
|
@ -557,8 +557,12 @@ composed maddr can name a server source or client destination (§5.4).
|
||||||
|
|
||||||
### 5.3 `Address.namespace`, at last
|
### 5.3 `Address.namespace`, at last
|
||||||
|
|
||||||
- `TunnelledAddress.namespace` → `(kind, id)` e.g.
|
- an unrealized `TunnelledAddress.namespace` reports its declared name
|
||||||
`('netns', 'tractor-wg0')`.
|
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;
|
||||||
- existing plain backends implement it explicitly as `None`, so the
|
- existing plain backends implement it explicitly as `None`, so the
|
||||||
Protocol does not lie and tunnel delegation needs no `getattr()`
|
Protocol does not lie and tunnel delegation needs no `getattr()`
|
||||||
fallback.
|
fallback.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import msgspec
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tractor.discovery import (
|
from tractor.discovery import (
|
||||||
|
BindspaceRef,
|
||||||
TunnelledAddress,
|
TunnelledAddress,
|
||||||
WGTunnelSpec,
|
WGTunnelSpec,
|
||||||
mb_pubkey,
|
mb_pubkey,
|
||||||
|
|
@ -196,6 +197,71 @@ def test_namespace_comes_from_the_tunnel(
|
||||||
assert in_ns.namespace == ('netns', 'wg-test')
|
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(
|
def test_strip_tunnels(
|
||||||
tunnelled: TunnelledAddress,
|
tunnelled: TunnelledAddress,
|
||||||
overlay: TCPAddress,
|
overlay: TCPAddress,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from __future__ import annotations
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from tractor.discovery import (
|
from tractor.discovery import (
|
||||||
|
BindspaceRef,
|
||||||
TunnelledAddress,
|
TunnelledAddress,
|
||||||
WGTunnelSpec,
|
WGTunnelSpec,
|
||||||
tunnels_of,
|
tunnels_of,
|
||||||
|
|
@ -25,8 +26,9 @@ def test_server_peels_before_endpoint_construction() -> None:
|
||||||
instead of the TCP backend. Start a real listener from the
|
instead of the TCP backend. Start a real listener from the
|
||||||
wrapper, assert the resulting `Endpoint` contains only a resolved
|
wrapper, assert the resulting `Endpoint` contains only a resolved
|
||||||
`TCPAddress`. Prove `Endpoint.declared_addr` still retains the
|
`TCPAddress`. Prove `Endpoint.declared_addr` still retains the
|
||||||
original tunnel namespace for diagnostics and the future
|
original declaration and realized bindspace ref for
|
||||||
bindspace lifecycle.
|
diagnostics. This retained metadata does not claim the listener
|
||||||
|
process entered that namespace.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
overlay = TCPAddress('127.0.0.1', 0)
|
overlay = TCPAddress('127.0.0.1', 0)
|
||||||
|
|
@ -38,11 +40,19 @@ def test_server_peels_before_endpoint_construction() -> None:
|
||||||
netns='actor-net',
|
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 def main() -> None:
|
||||||
async with open_ipc_server() as server:
|
async with open_ipc_server() as server:
|
||||||
eps = await server.listen_on(
|
eps = await server.listen_on(
|
||||||
accept_addrs=[tunnelled],
|
accept_addrs=[declared],
|
||||||
)
|
)
|
||||||
assert len(eps) == 1
|
assert len(eps) == 1
|
||||||
endpoint = eps[0]
|
endpoint = eps[0]
|
||||||
|
|
@ -51,9 +61,9 @@ def test_server_peels_before_endpoint_construction() -> None:
|
||||||
_, host, port = endpoint.addr.unwrap()
|
_, host, port = endpoint.addr.unwrap()
|
||||||
assert host == overlay.unwrap()[1]
|
assert host == overlay.unwrap()[1]
|
||||||
assert port > 0
|
assert port > 0
|
||||||
assert endpoint.addr is not tunnelled
|
assert endpoint.addr is not declared
|
||||||
assert endpoint.declared_addr is tunnelled
|
assert endpoint.declared_addr is declared
|
||||||
namespace: tuple[str, str] = ('netns', 'actor-net')
|
namespace: tuple[str, int] = ('netns', 1234)
|
||||||
assert endpoint.namespace == namespace
|
assert endpoint.namespace == namespace
|
||||||
endpoint_repr: str = endpoint.pformat()
|
endpoint_repr: str = endpoint.pformat()
|
||||||
server_repr: str = server.pformat()
|
server_repr: str = server.pformat()
|
||||||
|
|
@ -61,9 +71,9 @@ def test_server_peels_before_endpoint_construction() -> None:
|
||||||
assert expected_namespace in endpoint_repr
|
assert expected_namespace in endpoint_repr
|
||||||
assert ' |_namespaces:' in server_repr
|
assert ' |_namespaces:' in server_repr
|
||||||
assert 'netns' in server_repr
|
assert 'netns' in server_repr
|
||||||
assert 'actor-net' in server_repr
|
assert '1234' in server_repr
|
||||||
assert tunnels_of(tunnelled) == (
|
assert tunnels_of(declared) == (
|
||||||
tunnelled.tunnel,
|
declared.tunnel,
|
||||||
)
|
)
|
||||||
|
|
||||||
server.cancel()
|
server.cancel()
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ import trio
|
||||||
from ..msg._local import ProcessLocal
|
from ..msg._local import ProcessLocal
|
||||||
from ._bindspace import (
|
from ._bindspace import (
|
||||||
Bindspace,
|
Bindspace,
|
||||||
|
BindspaceRef,
|
||||||
BindspaceSpec,
|
BindspaceSpec,
|
||||||
open_bindspace,
|
open_bindspace,
|
||||||
)
|
)
|
||||||
|
|
@ -864,6 +865,7 @@ def _wg_proto_code() -> int:
|
||||||
class TunnelledAddress(
|
class TunnelledAddress(
|
||||||
msgspec.Struct,
|
msgspec.Struct,
|
||||||
frozen=True,
|
frozen=True,
|
||||||
|
omit_defaults=True,
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
An `Address` annotated with the tunnel it must be reached
|
An `Address` annotated with the tunnel it must be reached
|
||||||
|
|
@ -878,6 +880,32 @@ class TunnelledAddress(
|
||||||
'''
|
'''
|
||||||
overlay: Address|TunnelledAddress
|
overlay: Address|TunnelledAddress
|
||||||
tunnel: TunnelSpec
|
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 ----
|
# ---- delegated, so the runtime can't tell the difference ----
|
||||||
|
|
||||||
|
|
@ -917,14 +945,35 @@ class TunnelledAddress(
|
||||||
@property
|
@property
|
||||||
def namespace(self) -> tuple[str, str|int]|None:
|
def namespace(self) -> tuple[str, str|int]|None:
|
||||||
'''
|
'''
|
||||||
The tunnel's netns, when it declares one.
|
Return the realized ref or declared tunnel netns.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
ref: BindspaceRef|None = self.bindspace_ref
|
||||||
|
if ref is not None:
|
||||||
|
return (
|
||||||
|
ref.kind,
|
||||||
|
ref.inode,
|
||||||
|
)
|
||||||
|
|
||||||
if (netns := self.tunnel.netns) is None:
|
if (netns := self.tunnel.netns) is None:
|
||||||
return self.overlay.namespace
|
return self.overlay.namespace
|
||||||
|
|
||||||
return ('netns', netns)
|
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:
|
def __repr__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'{type(self).__name__}(\n'
|
f'{type(self).__name__}(\n'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue