Decode tagged transport addresses

- Define canonical `tcp` and `unix` tuple shapes while retaining
  legacy pair aliases as the emitted `UnwrappedAddress`.

- Dispatch tagged tuple/list payloads explicitly, accept `uds` as a
  Unix input alias, and preserve legacy TCP, UDS, and native IPv6
  readers.

- Cover tag aliases, msgpack-style lists, legacy payloads, and IPv6
  socket addresses before switching writers.

Prompt-IO: ai/prompt-io/opencode/20260820T033107Z_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:18:54 -04:00
parent ba07e09d2a
commit 284a967936
6 changed files with 254 additions and 22 deletions

View File

@ -0,0 +1,41 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking
timestamp: 2026-08-20T03:31:07Z
git_ref: ba07e09d
scope: code
substantive: true
raw_file: 20260820T033107Z_ba07e09d_prompt_io.raw.md
---
## Prompt
The human requested canonical tagged transport addresses with a
reader-first migration. TCP should decode `('tcp', host, port)`, Unix
should decode `('unix', path)`, `uds` should remain an accepted input
alias and internal transport key, and legacy tuple/list inputs must keep
working before writers switch formats.
## Response summary
Introduced canonical and compatibility address aliases, explicit tagged
dispatch, transport-specific tagged readers, and focused serialization
tests. Kept legacy pair inputs and native IPv6 socket values readable so
this boundary can ship before tagged emission.
## Files changed
- `tractor/discovery/_addr.py` - address aliases and tagged dispatch.
- `tractor/ipc/_tcp.py` - tagged, legacy, and IPv6 TCP decoding.
- `tractor/ipc/_uds.py` - canonical Unix and UDS-alias decoding.
- `tests/discovery/test_address_serialization.py` - reader compatibility
coverage.
## Human edits
The human supplied the canonical `tcp` and `unix` forms, chose `uds` as
an input-only serialization alias while preserving it as the runtime
transport key, and required a reader-first commit boundary. The agent
implemented those decisions; no direct manual source edits were
observed.

View File

@ -0,0 +1,25 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-20T03:31:07Z
git_ref: ba07e09d
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - tagged address readers
The human requested a migration away from ambiguous untagged transport
tuples. They established `('tcp', host, port)` and `('unix', path)` as
canonical forms, retained `('uds', path)` as an input alias, and required
a reader-first compatibility boundary before changing emitted values.
> `git diff HEAD~1..HEAD -- tractor/discovery/_addr.py tractor/ipc/_tcp.py tractor/ipc/_uds.py tests/discovery/test_address_serialization.py`
Added explicit tagged address aliases and dispatch, taught TCP and UDS
readers to decode tagged tuple/list payloads, preserved legacy pair input,
and retained native IPv6 socket-address decoding. Added focused tests for
canonical tags, the UDS alias, msgpack-style lists, legacy pairs, and IPv6
socket values.
Focused reader tests and Ruff checks passed before the writer migration
was applied.

View File

@ -0,0 +1,97 @@
'''
Canonical tagged-address decoding and legacy input compatibility.
'''
from pathlib import Path
import pytest
from tractor.discovery._addr import wrap_address
from tractor.ipc._tcp import TCPAddress
from tractor.ipc._uds import UDSAddress
@pytest.mark.parametrize(
'value',
[
('tcp', '127.0.0.1', 1616),
['tcp', '127.0.0.1', 1616],
],
)
def test_decode_tagged_tcp_address(value):
'''
Shape-only decoding cannot distinguish future transport address
forms. Feed canonical tuple and msgpack-style list values through
the compatibility boundary and prove the explicit `tcp` tag
selects the TCP backend without changing legacy emission yet.
'''
addr = wrap_address(value)
assert type(addr) is TCPAddress
assert addr.unwrap() == ('127.0.0.1', 1616)
@pytest.mark.parametrize(
'tag',
['unix', 'uds'],
)
@pytest.mark.parametrize('container', [tuple, list])
def test_decode_tagged_unix_address(
tag: str,
container: type,
):
'''
Multiaddr calls the protocol `unix` while tractor's transport key
remains `uds`. Decode both spellings from tuple/list containers,
normalize them to one `UDSAddress`, and retain legacy pair
emission until the writer migration lands.
'''
value = container((tag, '/tmp/tractor/registry.sock'))
addr = wrap_address(value)
assert type(addr) is UDSAddress
assert addr.sockpath == Path('/tmp/tractor/registry.sock')
assert addr.unwrap() == (
'/tmp/tractor',
'registry.sock',
)
@pytest.mark.parametrize(
'value, expected_type',
[
(('127.0.0.1', 1616), TCPAddress),
(['127.0.0.1', 1616], TCPAddress),
(('/tmp/tractor', 'registry.sock'), UDSAddress),
(['/tmp/tractor', 'registry.sock'], UDSAddress),
],
)
def test_decode_legacy_address_forms(
value,
expected_type: type,
):
'''
Existing callers, config, and older msgpack payloads still
provide untagged pairs. Keep tuple/list forms readable while
canonical tagged decoding is introduced, proving this
reader-first commit does not break shipped input behavior.
'''
assert type(wrap_address(value)) is expected_type
def test_tcp_from_native_ipv6_sockname():
'''
`socket.getsockname()` returns a four-item IPv6 sockaddr which is
neither a wire form nor a legacy two-item pair. Preserve it as an
OS compatibility boundary and intentionally ignore unsupported
flow-info/scope-id fields when constructing `TCPAddress`.
'''
addr = TCPAddress.from_addr(
('::1', 1616, 0, 0)
)
assert addr.unwrap() == ('::1', 1616)

View File

@ -18,7 +18,9 @@ from uuid import uuid4
from typing import ( from typing import (
Protocol, Protocol,
ClassVar, ClassVar,
Literal,
Type, Type,
TypeAlias,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -64,20 +66,40 @@ log = get_logger()
# seems like the right name as per, # seems like the right name as per,
# https://www.geeksforgeeks.org/introduction-to-address-descriptor/ # https://www.geeksforgeeks.org/introduction-to-address-descriptor/
# #
UnwrappedAddress = ( TaggedTCPAddress: TypeAlias = tuple[
# tcp/udp/uds Literal['tcp'],
tuple[ str,
str, # host/domain(tcp), filesys-dir(uds) int,
int|str, # port/path(uds)
] ]
# ?TODO? should we also include another 2 fields from TaggedUnixAddress: TypeAlias = tuple[
# our `Aid` msg such that we include the runtime `Actor.uid` Literal['unix'],
# of `.name` and `.uuid`? str,
# - would ensure uniqueness across entire net? ]
# - allows for easier runtime-level filtering of "actors by TaggedUDSAlias: TypeAlias = tuple[
# service name" Literal['uds'],
str,
]
TaggedAddress: TypeAlias = (
TaggedTCPAddress
|TaggedUnixAddress
) )
# Input-only compatibility forms. `UnwrappedAddress` remains the
# emitted form until the tagged writer migration switches it in the
# next atomic change.
LegacyTCPAddress: TypeAlias = tuple[str, int]
LegacyUDSAddress: TypeAlias = tuple[str, str]
LegacyUnwrappedAddress: TypeAlias = (
LegacyTCPAddress
|LegacyUDSAddress
)
UnwrappedAddress = LegacyUnwrappedAddress
# ?TODO? should we also include another 2 fields from our `Aid` msg
# such that we include the runtime `Actor.uid` of `.name` and `.uuid`?
# - would ensure uniqueness across entire net?
# - allows for easier runtime-level filtering of "actors by service
# name"
# TODO, maybe rename to `SocketAddress`? # TODO, maybe rename to `SocketAddress`?
class Address(Protocol): class Address(Protocol):
@ -216,7 +238,15 @@ def mk_uuid() -> str:
def wrap_address( def wrap_address(
addr: UnwrappedAddress|str|Address|TunnelledAddress, addr: (
TaggedAddress
|TaggedUDSAlias
|LegacyUnwrappedAddress
|list[str|int]
|str
|Address
|TunnelledAddress
),
) -> Address|TunnelledAddress: ) -> Address|TunnelledAddress:
''' '''
Wrap an `UnwrappedAddress` as an `Address`-type based Wrap an `UnwrappedAddress` as an `Address`-type based
@ -239,6 +269,20 @@ def wrap_address(
# import pdbp; pdbp.set_trace() # import pdbp; pdbp.set_trace()
match addr: match addr:
case (
('tcp', str(), int())
|
['tcp', str(), int()]
):
return TCPAddress.from_addr(addr)
case (
(('unix' | 'uds'), str())
|
[('unix' | 'uds'), str()]
):
return UDSAddress.from_addr(addr)
# classic network socket-address as tuple/list # classic network socket-address as tuple/list
case ( case (
(str(), int()) (str(), int())

View File

@ -104,11 +104,26 @@ class TCPAddress(
@classmethod @classmethod
def from_addr( def from_addr(
cls, cls,
addr: tuple[str, int] addr: tuple|list,
) -> TCPAddress: ) -> TCPAddress:
match addr: match addr:
case (str(), int()): case (
return TCPAddress(addr[0], addr[1]) ('tcp', str() as host, int() as port)
|
['tcp', str() as host, int() as port]
|
(str() as host, int() as port)
|
[str() as host, int() as port]
|
(
str() as host,
int() as port,
int(),
int(),
)
):
return TCPAddress(host, port)
case _: case _:
raise ValueError( raise ValueError(
f'Invalid unwrapped address for {cls}\n' f'Invalid unwrapped address for {cls}\n'

View File

@ -146,16 +146,26 @@ class UDSAddress(
def from_addr( def from_addr(
cls, cls,
addr: ( addr: (
tuple[Path|str, Path|str]|Path|str tuple|list|Path|str
), ),
) -> UDSAddress: ) -> UDSAddress:
match addr: match addr:
case tuple()|list(): case (
filedir = Path(addr[0]) (('unix' | 'uds'), str()|Path() as sockpath)
filename = Path(addr[1]) |
[('unix' | 'uds'), str()|Path() as sockpath]
):
path = Path(sockpath)
return UDSAddress(*unwrap_sockpath(path))
case (
(str()|Path() as filedir, str()|Path() as filename)
|
[str()|Path() as filedir, str()|Path() as filename]
):
return UDSAddress( return UDSAddress(
filedir=filedir, filedir=Path(filedir),
filename=filename, filename=Path(filename),
# maybe_pid=pid, # maybe_pid=pid,
) )
# NOTE, in case we ever decide to just `.unwrap()` # NOTE, in case we ever decide to just `.unwrap()`