Emit canonical tagged addresses

- Make `TCPAddress.unwrap()` emit `('tcp', host, port)` and
  `UDSAddress.unwrap()` emit `('unix', path)` while retaining the
  compatibility readers from the preceding change.

- Pass concrete TCP fields to Trio, compose multiaddrs from tagged
  values, and let `SpawnSpec` carry protocol-specific tuple shapes
  for validation by `wrap_address()`.

- Compare runtime, registry, bind, and tunnel addresses through
  canonical serialized forms and cover both TCP and UDS operation.

Prompt-IO: ai/prompt-io/opencode/20260820T033108Z_ba07e09d_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
wkt/addr_unpacking
Gud Boi 2026-08-20 10:42:18 -04:00
parent 284a967936
commit 5d92595fb4
13 changed files with 144 additions and 60 deletions

View File

@ -0,0 +1,45 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking
timestamp: 2026-08-20T03:31:08Z
git_ref: ba07e09d
scope: code
substantive: true
raw_file: 20260820T033108Z_ba07e09d_prompt_io.raw.md
---
## Prompt
The human asked the agent to continue after adding tagged readers,
complete canonical address emission without dropping legacy input
compatibility, verify the migration, and prepare a complete multi-commit
package when the turn was done.
## Response summary
Changed `.unwrap()` to emit tagged TCP and Unix addresses, updated direct
tuple consumers and spawn payload declarations, and aligned multiaddr,
runtime, IPC, and discovery tests with canonical serialized equality.
Kept untagged tuples and the `uds` spelling readable at input boundaries.
## Files changed
- `tractor/discovery/_addr.py` - canonical output alias.
- `tractor/discovery/_multiaddr.py` - tagged address composition.
- `tractor/ipc/_tcp.py` - tagged emission and direct socket dialing.
- `tractor/ipc/_uds.py` - tagged full-path emission.
- `tractor/msg/types.py` - protocol-neutral spawn tuple containers.
- `tests/discovery/test_address_serialization.py` - writer assertions.
- `tests/discovery/test_multiaddr.py` - canonical round-trip assertions.
- `tests/discovery/test_tpt_bind_addrs.py` - tagged bind assertions.
- `tests/ipc/test_each_tpt.py` - canonical runtime address assertions.
- `tests/ipc/test_server_tunnel_boundary.py` - tagged TCP destructuring.
- `tests/test_local.py` - canonical registry comparison.
## Human edits
The human established the reader-before-writer sequencing, canonical tag
spellings, retained compatibility expectations, and requested final
multi-commit packaging. The agent implemented and tested those choices;
no direct manual source edits were observed.

View File

@ -0,0 +1,26 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-20T03:31:08Z
git_ref: ba07e09d
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - canonical tagged address writers
After the reader compatibility boundary, the human asked the agent to
continue the migration and package the completed work as dependency-
ordered commits.
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py tractor/discovery/_multiaddr.py tractor/ipc/_tcp.py tractor/ipc/_uds.py tractor/msg/types.py tests/discovery/test_address_serialization.py tests/discovery/test_multiaddr.py tests/discovery/test_tpt_bind_addrs.py tests/ipc/test_each_tpt.py tests/ipc/test_server_tunnel_boundary.py tests/test_local.py`
Switched TCP and Unix `.unwrap()` output to canonical tagged tuples,
updated direct transport and multiaddr consumers, widened spawn message
tuple containers for protocol-specific shapes, and migrated runtime and
test comparisons to serialized address equality. Legacy inputs remain
accepted at `wrap_address()` and backend reader boundaries.
Ruff and focused tests passed. The complete non-debugger TCP suite passed
with 412 tests; the UDS suite reached 80% without failure before the
harness timeout, then all 97 remaining tests passed on resume. Debugger
PTY coverage was excluded after an unrelated timeout.

View File

@ -23,13 +23,13 @@ def test_decode_tagged_tcp_address(value):
Shape-only decoding cannot distinguish future transport address Shape-only decoding cannot distinguish future transport address
forms. Feed canonical tuple and msgpack-style list values through forms. Feed canonical tuple and msgpack-style list values through
the compatibility boundary and prove the explicit `tcp` tag the compatibility boundary and prove the explicit `tcp` tag
selects the TCP backend without changing legacy emission yet. selects the TCP backend and emits the canonical tagged form.
''' '''
addr = wrap_address(value) addr = wrap_address(value)
assert type(addr) is TCPAddress assert type(addr) is TCPAddress
assert addr.unwrap() == ('127.0.0.1', 1616) assert addr.unwrap() == ('tcp', '127.0.0.1', 1616)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -44,8 +44,8 @@ def test_decode_tagged_unix_address(
''' '''
Multiaddr calls the protocol `unix` while tractor's transport key Multiaddr calls the protocol `unix` while tractor's transport key
remains `uds`. Decode both spellings from tuple/list containers, remains `uds`. Decode both spellings from tuple/list containers,
normalize them to one `UDSAddress`, and retain legacy pair normalize them to one `UDSAddress`, and emit the canonical `unix`
emission until the writer migration lands. spelling.
''' '''
value = container((tag, '/tmp/tractor/registry.sock')) value = container((tag, '/tmp/tractor/registry.sock'))
@ -54,8 +54,8 @@ def test_decode_tagged_unix_address(
assert type(addr) is UDSAddress assert type(addr) is UDSAddress
assert addr.sockpath == Path('/tmp/tractor/registry.sock') assert addr.sockpath == Path('/tmp/tractor/registry.sock')
assert addr.unwrap() == ( assert addr.unwrap() == (
'/tmp/tractor', 'unix',
'registry.sock', '/tmp/tractor/registry.sock',
) )
@ -75,11 +75,14 @@ def test_decode_legacy_address_forms(
''' '''
Existing callers, config, and older msgpack payloads still Existing callers, config, and older msgpack payloads still
provide untagged pairs. Keep tuple/list forms readable while provide untagged pairs. Keep tuple/list forms readable while
canonical tagged decoding is introduced, proving this canonical tagged emission is introduced, proving the writer
reader-first commit does not break shipped input behavior. migration does not break shipped input behavior.
''' '''
assert type(wrap_address(value)) is expected_type addr = wrap_address(value)
assert type(addr) is expected_type
assert addr.unwrap()[0] in {'tcp', 'unix'}
def test_tcp_from_native_ipv6_sockname(): def test_tcp_from_native_ipv6_sockname():
@ -94,4 +97,4 @@ def test_tcp_from_native_ipv6_sockname():
('::1', 1616, 0, 0) ('::1', 1616, 0, 0)
) )
assert addr.unwrap() == ('::1', 1616) assert addr.unwrap() == ('tcp', '::1', 1616)

View File

@ -188,7 +188,7 @@ def test_parse_maddr_tcp_ipv4():
result = parse_maddr('/ip4/127.0.0.1/tcp/1234') result = parse_maddr('/ip4/127.0.0.1/tcp/1234')
assert isinstance(result, TCPAddress) assert isinstance(result, TCPAddress)
assert result.unwrap() == ('127.0.0.1', 1234) assert result.unwrap() == ('tcp', '127.0.0.1', 1234)
def test_parse_maddr_tcp_ipv6(): def test_parse_maddr_tcp_ipv6():
@ -200,7 +200,7 @@ def test_parse_maddr_tcp_ipv6():
result = parse_maddr('/ip6/::1/tcp/5678') result = parse_maddr('/ip6/::1/tcp/5678')
assert isinstance(result, TCPAddress) assert isinstance(result, TCPAddress)
assert result.unwrap() == ('::1', 5678) assert result.unwrap() == ('tcp', '::1', 5678)
def test_parse_maddr_uds(): def test_parse_maddr_uds():
@ -213,9 +213,10 @@ def test_parse_maddr_uds():
result = parse_maddr('/unix/tmp/tractor_test/test.sock') result = parse_maddr('/unix/tmp/tractor_test/test.sock')
assert isinstance(result, UDSAddress) assert isinstance(result, UDSAddress)
filedir, filename = result.unwrap() assert result.unwrap() == (
assert filename == 'test.sock' 'unix',
assert str(filedir) == '/tmp/tractor_test' '/tmp/tractor_test/test.sock',
)
def test_parse_maddr_unsupported(): def test_parse_maddr_unsupported():
@ -249,7 +250,7 @@ def test_parse_wg_maddr():
bearer=('192.168.1.50', 51820), bearer=('192.168.1.50', 51820),
) )
assert isinstance(parsed.overlay, TCPAddress) assert isinstance(parsed.overlay, TCPAddress)
assert parsed.overlay.unwrap() == ('10.0.11.1', 1616) assert parsed.overlay.unwrap() == ('tcp', '10.0.11.1', 1616)
def test_mk_wg_maddr_roundtrip(): def test_mk_wg_maddr_roundtrip():
@ -445,7 +446,7 @@ def test_wrap_address_maddr_str():
result = wrap_address('/ip4/127.0.0.1/tcp/9999') result = wrap_address('/ip4/127.0.0.1/tcp/9999')
assert isinstance(result, TCPAddress) assert isinstance(result, TCPAddress)
assert result.unwrap() == ('127.0.0.1', 9999) assert result.unwrap() == ('tcp', '127.0.0.1', 9999)
def test_wrap_address_wg_maddr_str(): def test_wrap_address_wg_maddr_str():
@ -460,7 +461,7 @@ def test_wrap_address_wg_maddr_str():
assert isinstance(result, TunnelledAddress) assert isinstance(result, TunnelledAddress)
assert result.tunnel.peer_pubkey == _WG_PUBKEY assert result.tunnel.peer_pubkey == _WG_PUBKEY
assert result.overlay.unwrap() == ('10.0.11.1', 1616) assert result.overlay.unwrap() == ('tcp', '10.0.11.1', 1616)
# ------ parse_endpoints() tests ------ # ------ parse_endpoints() tests ------
@ -481,11 +482,11 @@ def test_parse_endpoints_tcp_only():
reg_addr = result['registry'][0] reg_addr = result['registry'][0]
assert isinstance(reg_addr, TCPAddress) assert isinstance(reg_addr, TCPAddress)
assert reg_addr.unwrap() == ('127.0.0.1', 1616) assert reg_addr.unwrap() == ('tcp', '127.0.0.1', 1616)
feed_addr = result['data_feed'][0] feed_addr = result['data_feed'][0]
assert isinstance(feed_addr, TCPAddress) assert isinstance(feed_addr, TCPAddress)
assert feed_addr.unwrap() == ('0.0.0.0', 5555) assert feed_addr.unwrap() == ('tcp', '0.0.0.0', 5555)
def test_parse_endpoints_mixed_tpts(): def test_parse_endpoints_mixed_tpts():
@ -505,12 +506,13 @@ def test_parse_endpoints_mixed_tpts():
assert len(addrs) == 2 assert len(addrs) == 2
assert isinstance(addrs[0], TCPAddress) assert isinstance(addrs[0], TCPAddress)
assert addrs[0].unwrap() == ('127.0.0.1', 4040) assert addrs[0].unwrap() == ('tcp', '127.0.0.1', 4040)
assert isinstance(addrs[1], UDSAddress) assert isinstance(addrs[1], UDSAddress)
filedir, filename = addrs[1].unwrap() assert addrs[1].unwrap() == (
assert filename == 'broker.sock' 'unix',
assert str(filedir) == '/tmp/tractor' '/tmp/tractor/broker.sock',
)
def test_parse_endpoints_wg_maddr(): def test_parse_endpoints_wg_maddr():
@ -550,7 +552,7 @@ def test_parse_endpoints_unwrapped_tuples():
addr = result['ems'][0] addr = result['ems'][0]
assert isinstance(addr, TCPAddress) assert isinstance(addr, TCPAddress)
assert addr.unwrap() == ('127.0.0.1', 6666) assert addr.unwrap() == ('tcp', '127.0.0.1', 6666)
def test_parse_endpoints_mixed_str_and_tuple(): def test_parse_endpoints_mixed_str_and_tuple():
@ -570,10 +572,10 @@ def test_parse_endpoints_mixed_str_and_tuple():
assert len(addrs) == 2 assert len(addrs) == 2
assert isinstance(addrs[0], TCPAddress) assert isinstance(addrs[0], TCPAddress)
assert addrs[0].unwrap() == ('127.0.0.1', 7777) assert addrs[0].unwrap() == ('tcp', '127.0.0.1', 7777)
assert isinstance(addrs[1], TCPAddress) assert isinstance(addrs[1], TCPAddress)
assert addrs[1].unwrap() == ('127.0.0.1', 8888) assert addrs[1].unwrap() == ('tcp', '127.0.0.1', 8888)
def test_parse_endpoints_unsupported_proto(): def test_parse_endpoints_unsupported_proto():

View File

@ -183,7 +183,7 @@ def test_non_registrar_root_tpt_bind_addrs(
for uw_addr in bound: for uw_addr in bound:
w = wrap_address(uw_addr) w = wrap_address(uw_addr)
if w.proto_key == 'tcp': if w.proto_key == 'tcp':
_host, port = uw_addr _, _host, port = uw_addr
assert port > 0 assert port > 0
trio.run(_main) trio.run(_main)
@ -255,7 +255,7 @@ def test_tpt_bind_addrs_as_maddr_str(
for uw_addr in actor.accept_addrs: for uw_addr in actor.accept_addrs:
w = wrap_address(uw_addr) w = wrap_address(uw_addr)
if w.proto_key == 'tcp': if w.proto_key == 'tcp':
_host, port = uw_addr _, _host, port = uw_addr
assert port > 0 assert port > 0
trio.run(_main) trio.run(_main)
@ -287,7 +287,7 @@ def test_registrar_merge_binds_union(
# actually differ (always true for TCP, may # actually differ (always true for TCP, may
# collide for UDS). # collide for UDS).
expect_disjoint: bool = ( expect_disjoint: bool = (
tuple(reg_addr) != rando.unwrap() reg_wrapped.unwrap() != rando.unwrap()
) )
async def _main(): async def _main():

View File

@ -57,17 +57,20 @@ def test_uds_bindspace_created_implicitly(
root: Actor = tractor.current_actor() root: Actor = tractor.current_actor()
assert root.is_registrar assert root.is_registrar
canonical_addr = _addr.wrap_address(
registry_addr,
).unwrap()
assert registry_addr in root.reg_addrs assert canonical_addr in root.reg_addrs
assert ( assert (
registry_addr canonical_addr
in in
_state._runtime_vars['_registry_addrs'] _state._runtime_vars['_registry_addrs']
) )
assert ( assert (
_addr.wrap_address(registry_addr) canonical_addr
in in
root.registry_addrs [addr.unwrap() for addr in root.registry_addrs]
) )
trio.run(main) trio.run(main)

View File

@ -46,8 +46,8 @@ def test_server_peels_before_endpoint_construction():
endpoint = eps[0] endpoint = eps[0]
assert type(endpoint.addr) is TCPAddress assert type(endpoint.addr) is TCPAddress
host, port = endpoint.addr.unwrap() _, host, port = endpoint.addr.unwrap()
assert host == overlay.unwrap()[0] assert host == overlay.unwrap()[1]
assert port > 0 assert port > 0
assert endpoint.addr is not tunnelled assert endpoint.addr is not tunnelled
assert tunnels_of(tunnelled) == ( assert tunnels_of(tunnelled) == (

View File

@ -8,6 +8,7 @@ import trio
import tractor import tractor
from tractor._testing import tractor_test from tractor._testing import tractor_test
from tractor.discovery._addr import wrap_address
def test_no_runtime(): def test_no_runtime():
@ -48,7 +49,7 @@ async def test_self_is_registered_localportal(reg_addr):
with trio.fail_after(0.2): with trio.fail_after(0.2):
sockaddr = await portal.run_from_ns( sockaddr = await portal.run_from_ns(
'self', 'wait_for_actor', name='root') 'self', 'wait_for_actor', name='root')
assert sockaddr[0] == reg_addr assert sockaddr[0] == wrap_address(reg_addr).unwrap()
def test_local_actor_async_func(reg_addr): def test_local_actor_async_func(reg_addr):

View File

@ -84,16 +84,15 @@ TaggedAddress: TypeAlias = (
|TaggedUnixAddress |TaggedUnixAddress
) )
# Input-only compatibility forms. `UnwrappedAddress` remains the # Input-only compatibility forms retained for older callers and
# emitted form until the tagged writer migration switches it in the # serialized payloads.
# next atomic change.
LegacyTCPAddress: TypeAlias = tuple[str, int] LegacyTCPAddress: TypeAlias = tuple[str, int]
LegacyUDSAddress: TypeAlias = tuple[str, str] LegacyUDSAddress: TypeAlias = tuple[str, str]
LegacyUnwrappedAddress: TypeAlias = ( LegacyUnwrappedAddress: TypeAlias = (
LegacyTCPAddress LegacyTCPAddress
|LegacyUDSAddress |LegacyUDSAddress
) )
UnwrappedAddress = LegacyUnwrappedAddress UnwrappedAddress = TaggedAddress
# ?TODO? should we also include another 2 fields from our `Aid` msg # ?TODO? should we also include another 2 fields from our `Aid` msg
# such that we include the runtime `Actor.uid` of `.name` and `.uuid`? # such that we include the runtime `Actor.uid` of `.name` and `.uuid`?
# - would ensure uniqueness across entire net? # - would ensure uniqueness across entire net?
@ -104,7 +103,7 @@ UnwrappedAddress = LegacyUnwrappedAddress
# TODO, maybe rename to `SocketAddress`? # TODO, maybe rename to `SocketAddress`?
class Address(Protocol): class Address(Protocol):
proto_key: ClassVar[str] proto_key: ClassVar[str]
unwrapped_type: ClassVar[UnwrappedAddress] unwrapped_type: ClassVar[type]
# TODO, i feel like an `.is_bound()` is a better thing to # TODO, i feel like an `.is_bound()` is a better thing to
# support? # support?

View File

@ -73,7 +73,7 @@ def mk_maddr(
match proto_key: match proto_key:
case 'tcp': case 'tcp':
host, port = addr.unwrap() _, host, port = addr.unwrap()
ip = ipaddress.ip_address(host) ip = ipaddress.ip_address(host)
net_proto: str = ( net_proto: str = (
'ip4' if ip.version == 4 'ip4' if ip.version == 4
@ -84,13 +84,12 @@ def mk_maddr(
) )
case 'uds': case 'uds':
filedir, filename = addr.unwrap() _, sockpath = addr.unwrap()
filepath = Path(filedir) / filename
# NOTE, strip any leading `/` to avoid # NOTE, strip any leading `/` to avoid
# double-slash `/unix//run/..` which the # double-slash `/unix//run/..` which the
# multiaddr parser rejects as "empty # multiaddr parser rejects as "empty
# protocol path". # protocol path".
fpath_str: str = str(filepath).lstrip('/') fpath_str: str = sockpath.lstrip('/')
return Multiaddr( return Multiaddr(
f'/{maddr_proto}/{fpath_str}' f'/{maddr_proto}/{fpath_str}'
) )

View File

@ -21,6 +21,7 @@ from __future__ import annotations
import ipaddress import ipaddress
from typing import ( from typing import (
ClassVar, ClassVar,
TYPE_CHECKING,
) )
# from contextlib import ( # from contextlib import (
# asynccontextmanager as acm, # asynccontextmanager as acm,
@ -42,6 +43,9 @@ from tractor.ipc._transport import (
MsgpackTransport, MsgpackTransport,
) )
if TYPE_CHECKING:
from tractor.discovery._addr import TaggedTCPAddress
log = get_logger() log = get_logger()
@ -62,7 +66,7 @@ class TCPAddress(
) from valerr ) from valerr
proto_key: ClassVar[str] = 'tcp' proto_key: ClassVar[str] = 'tcp'
unwrapped_type: ClassVar[type] = tuple[str, int] unwrapped_type: ClassVar[type] = tuple
def_bindspace: ClassVar[str] = '127.0.0.1' def_bindspace: ClassVar[str] = '127.0.0.1'
# ?TODO, actually validate ipv4/6 with stdlib's `ipaddress` # ?TODO, actually validate ipv4/6 with stdlib's `ipaddress`
@ -130,8 +134,9 @@ class TCPAddress(
f'{addr}\n' f'{addr}\n'
) )
def unwrap(self) -> tuple[str, int]: def unwrap(self) -> TaggedTCPAddress:
return ( return (
self.proto_key,
self._host, self._host,
self._port, self._port,
) )
@ -230,7 +235,8 @@ class MsgpackTCPStream(MsgpackTransport):
**kwargs **kwargs
) -> MsgpackTCPStream: ) -> MsgpackTCPStream:
stream = await trio.open_tcp_stream( stream = await trio.open_tcp_stream(
*destaddr.unwrap(), destaddr._host,
destaddr._port,
**kwargs **kwargs
) )
return MsgpackTCPStream( return MsgpackTCPStream(

View File

@ -63,6 +63,7 @@ from tractor.runtime._state import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from tractor.discovery._addr import TaggedUnixAddress
from tractor.runtime._runtime import Actor from tractor.runtime._runtime import Actor
@ -114,7 +115,7 @@ class UDSAddress(
# -[ ] need to check what other mult-transport frameworks do # -[ ] need to check what other mult-transport frameworks do
# like zmq, nng, uri-spec et al! # like zmq, nng, uri-spec et al!
proto_key: ClassVar[str] = 'uds' proto_key: ClassVar[str] = 'uds'
unwrapped_type: ClassVar[type] = tuple[str, int] unwrapped_type: ClassVar[type] = tuple
def_bindspace: ClassVar[Path] = get_rt_dir() def_bindspace: ClassVar[Path] = get_rt_dir()
@property @property
@ -132,7 +133,7 @@ class UDSAddress(
@property @property
def sockpath(self) -> Path: def sockpath(self) -> Path:
return self.bindspace / self.filename return Path(self.bindspace) / self.filename
@property @property
def is_valid(self) -> bool: def is_valid(self) -> bool:
@ -180,12 +181,10 @@ class UDSAddress(
f'{addr!r}\n' f'{addr!r}\n'
) )
def unwrap(self) -> tuple[str, int]: def unwrap(self) -> TaggedUnixAddress:
# XXX NOTE, since this gets passed DIRECTLY to
# `.ipc._uds.open_unix_socket_w_passcred()`
return ( return (
str(self.filedir), 'unix',
str(self.filename), str(self.sockpath),
) )
@classmethod @classmethod

View File

@ -211,10 +211,11 @@ class SpawnSpec(
# module import capability # module import capability
enable_modules: dict[str, str] enable_modules: dict[str, str]
# TODO: not just sockaddr pairs? # Tagged addresses have protocol-specific tuple shapes which
# -[ ] abstract into a `TransportAddr` type? # msgspec cannot express as one decodable union. `wrap_address()`
reg_addrs: list[tuple[str, str|int]] # validates each tuple at the transport boundary.
bind_addrs: list[tuple[str, str|int]]|None reg_addrs: list[tuple]
bind_addrs: list[tuple]|None
# TODO: caps based RPC support in the payload? # TODO: caps based RPC support in the payload?