Add `prefer_addr()` transport selection to `_api`

New locality-aware addr preference for multihomed
actors: UDS > local TCP > remote TCP. Uses
`ipaddress` + `socket.getaddrinfo()` to detect
whether a `TCPAddress` is on the local host.

Deats,
- `_is_local_addr()` checks loopback or
  same-host IPs via interface enumeration
- `prefer_addr()` classifies an addr list into
  three tiers and picks the latest entry from
  the highest-priority non-empty tier
- `query_actor()` and `wait_for_actor()` now
  call `prefer_addr()` instead of grabbing
  `addrs[-1]` or a single pre-selected addr

Also,
- `Registrar.find_actor()` returns full
  `list[UnwrappedAddress]|None` so callers can
  apply transport preference

Prompt-IO: ai/prompt-io/claude/20260414T163300Z_befedc49_prompt_io.md

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
subint_spawner_backend
Gud Boi 2026-04-14 12:57:13 -04:00
parent c3d6cc9007
commit ccb013a615
4 changed files with 208 additions and 12 deletions

View File

@ -0,0 +1,38 @@
---
model: claude-opus-4-6
service: claude
session: multiaddr-support-rename-prefer
timestamp: 2026-04-14T16:33:00Z
git_ref: befedc49
scope: code
substantive: true
raw_file: 20260414T163300Z_befedc49_prompt_io.raw.md
---
## Prompt
Create a helper function that determines the best transport given
actor locality (distributed vs same host). Use PID/hostname
comparison for locality detection, apply at registry addr selection
only (not spawn-time).
## Response summary
New `prefer_addr()` + `_is_local_addr()` helpers
in `_api.py` using `socket.getaddrinfo()` and
`ipaddress` for PID/hostname locality detection.
Preference: UDS > local TCP > remote TCP.
Integrated into `query_actor()` and
`wait_for_actor()`. Also changed
`Registrar.find_actor()` to return full addr list
so callers can apply preference.
## Files changed
- `tractor/discovery/_discovery.py``_api.py`
— renamed + added `prefer_addr()`,
`_is_local_addr()`; updated `query_actor()` and
`wait_for_actor()` call sites
- `tractor/discovery/_registry.py`
`Registrar.find_actor()` returns
`list[UnwrappedAddress]|None`

View File

@ -0,0 +1,62 @@
---
model: claude-opus-4-6
service: claude
timestamp: 2026-04-14T16:33:00Z
git_ref: befedc49
---
### Add a `prefer_addr()` helper
Added transport preference selection to
`tractor/discovery/_api.py` with two new functions:
#### `_is_local_addr(addr: Address) -> bool`
Determines whether an `Address` is reachable on the
local host:
- `UDSAddress`: always returns `True`
(filesystem-bound, inherently local)
- `TCPAddress`: checks if `._host` is a loopback IP
via `ipaddress.ip_address().is_loopback`, then
falls back to comparing against the machine's own
interface IPs via
`socket.getaddrinfo(socket.gethostname(), None)`
#### `prefer_addr(addrs: list[UnwrappedAddress]) -> UnwrappedAddress`
Selects the "best" transport address from a
multihomed actor's address list. Wraps each
candidate via `wrap_address()` to get typed
`Address` objects, then classifies into three tiers:
1. **UDS** (same-host guaranteed, lowest overhead)
2. **TCP loopback / same-host IP** (local network)
3. **TCP remote** (only option for distributed)
Within each tier, the last-registered (latest) entry
is preferred. Falls back to `addrs[-1]` if no
heuristic matches.
#### Integration
- `Registrar.find_actor()` in `_registry.py`: changed
return type from `UnwrappedAddress|None` to
`list[UnwrappedAddress]|None` — returns the full
addr list so callers can apply transport preference.
- `query_actor()` in `_api.py`: now calls
`prefer_addr(addrs)` on the list returned by
`Registrar.find_actor()` instead of receiving a
single pre-selected addr.
- `wait_for_actor()` in `_api.py`: replaced
`addrs[-1]` with `prefer_addr(addrs)` for
consistent transport selection.
### Verification
All discovery tests pass (13/13 non-daemon).
`test_local.py` and `test_multi_program.py` also
pass (daemon fixture teardown failures are
pre-existing and unrelated).

View File

@ -20,6 +20,8 @@ management of (service) actors.
""" """
from __future__ import annotations from __future__ import annotations
import ipaddress
import socket
from typing import ( from typing import (
AsyncGenerator, AsyncGenerator,
AsyncContextManager, AsyncContextManager,
@ -33,10 +35,12 @@ from ..trionics import (
collapse_eg, collapse_eg,
) )
from ..ipc import _connect_chan, Channel from ..ipc import _connect_chan, Channel
from ..ipc._tcp import TCPAddress
from ..ipc._uds import UDSAddress
from ._addr import ( from ._addr import (
UnwrappedAddress, UnwrappedAddress,
Address, Address,
wrap_address wrap_address,
) )
from ..runtime._portal import ( from ..runtime._portal import (
Portal, Portal,
@ -56,6 +60,94 @@ if TYPE_CHECKING:
log = get_logger() log = get_logger()
def _is_local_addr(addr: Address) -> bool:
'''
Determine whether `addr` is reachable on the
local host by inspecting address type and
comparing hostnames/PIDs.
- `UDSAddress` is always local (filesystem-bound)
- `TCPAddress` is local when its host is a
loopback IP or matches one of the machine's
own interface addresses.
'''
if isinstance(addr, UDSAddress):
return True
if isinstance(addr, TCPAddress):
try:
ip = ipaddress.ip_address(addr._host)
except ValueError:
return False
if ip.is_loopback:
return True
# check if this IP belongs to any of our
# local network interfaces.
try:
local_ips: set[str] = {
info[4][0]
for info in socket.getaddrinfo(
socket.gethostname(),
None,
)
}
return addr._host in local_ips
except socket.gaierror:
return False
return False
def prefer_addr(
addrs: list[UnwrappedAddress],
) -> UnwrappedAddress:
'''
Select the "best" transport address from a
multihomed actor's address list based on
locality heuristics.
Preference order (highest -> lowest):
1. UDS (same-host guaranteed, lowest overhead)
2. TCP loopback / same-host IP
3. TCP remote (only option for distributed)
When multiple addrs share the same priority
tier, the last-registered (latest) entry is
preferred.
'''
if len(addrs) == 1:
return addrs[0]
local_uds: list[UnwrappedAddress] = []
local_tcp: list[UnwrappedAddress] = []
remote: list[UnwrappedAddress] = []
for unwrapped in addrs:
wrapped: Address = wrap_address(unwrapped)
if isinstance(wrapped, UDSAddress):
local_uds.append(unwrapped)
elif _is_local_addr(wrapped):
local_tcp.append(unwrapped)
else:
remote.append(unwrapped)
# prefer UDS > local TCP > remote TCP;
# within each tier take the latest entry.
if local_uds:
return local_uds[-1]
if local_tcp:
return local_tcp[-1]
if remote:
return remote[-1]
# fallback: last registered addr
return addrs[-1]
@acm @acm
async def get_registry( async def get_registry(
addr: UnwrappedAddress|None = None, addr: UnwrappedAddress|None = None,
@ -187,13 +279,17 @@ async def query_actor(
reg_portal: Portal|LocalPortal reg_portal: Portal|LocalPortal
regaddr: Address = wrap_address(regaddr) or actor.reg_addrs[0] regaddr: Address = wrap_address(regaddr) or actor.reg_addrs[0]
async with get_registry(regaddr) as reg_portal: async with get_registry(regaddr) as reg_portal:
# TODO: return portals to all available actors - for now addrs: list[UnwrappedAddress]|None = (
# just the last one that registered await reg_portal.run_from_ns(
addr: UnwrappedAddress = await reg_portal.run_from_ns(
'self', 'self',
'find_actor', 'find_actor',
name=name, name=name,
) )
)
if addrs:
addr: UnwrappedAddress = prefer_addr(addrs)
else:
addr = None
yield addr, reg_portal yield addr, reg_portal
@acm @acm
@ -370,9 +466,9 @@ async def wait_for_actor(
name=name, name=name,
) )
# get latest registered addr by default? # select the best transport addr from
# TODO: offer multi-portal yields in multi-homed case? # the (possibly multihomed) addr list.
addr: UnwrappedAddress = addrs[-1] addr: UnwrappedAddress = prefer_addr(addrs)
async with _connect_chan(addr) as chan: async with _connect_chan(addr) as chan:
async with open_portal(chan) as portal: async with open_portal(chan) as portal:

View File

@ -101,11 +101,11 @@ class Registrar(Actor):
self, self,
name: str, name: str,
) -> UnwrappedAddress|None: ) -> list[UnwrappedAddress]|None:
for uid, addrs in self._registry.items(): for uid, addrs in self._registry.items():
if name in uid: if name in uid:
return addrs[0] if addrs else None return addrs if addrs else None
return None return None