diff --git a/ai/prompt-io/claude/20260414T163300Z_befedc49_prompt_io.md b/ai/prompt-io/claude/20260414T163300Z_befedc49_prompt_io.md new file mode 100644 index 00000000..54659ec9 --- /dev/null +++ b/ai/prompt-io/claude/20260414T163300Z_befedc49_prompt_io.md @@ -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` diff --git a/ai/prompt-io/claude/20260414T163300Z_befedc49_prompt_io.raw.md b/ai/prompt-io/claude/20260414T163300Z_befedc49_prompt_io.raw.md new file mode 100644 index 00000000..a879ab28 --- /dev/null +++ b/ai/prompt-io/claude/20260414T163300Z_befedc49_prompt_io.raw.md @@ -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). diff --git a/tractor/discovery/_api.py b/tractor/discovery/_api.py index c3f4a98f..1d7108f0 100644 --- a/tractor/discovery/_api.py +++ b/tractor/discovery/_api.py @@ -20,6 +20,8 @@ management of (service) actors. """ from __future__ import annotations +import ipaddress +import socket from typing import ( AsyncGenerator, AsyncContextManager, @@ -33,10 +35,12 @@ from ..trionics import ( collapse_eg, ) from ..ipc import _connect_chan, Channel +from ..ipc._tcp import TCPAddress +from ..ipc._uds import UDSAddress from ._addr import ( UnwrappedAddress, Address, - wrap_address + wrap_address, ) from ..runtime._portal import ( Portal, @@ -56,6 +60,94 @@ if TYPE_CHECKING: 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 async def get_registry( addr: UnwrappedAddress|None = None, @@ -187,13 +279,17 @@ async def query_actor( reg_portal: Portal|LocalPortal regaddr: Address = wrap_address(regaddr) or actor.reg_addrs[0] async with get_registry(regaddr) as reg_portal: - # TODO: return portals to all available actors - for now - # just the last one that registered - addr: UnwrappedAddress = await reg_portal.run_from_ns( - 'self', - 'find_actor', - name=name, + addrs: list[UnwrappedAddress]|None = ( + await reg_portal.run_from_ns( + 'self', + 'find_actor', + name=name, + ) ) + if addrs: + addr: UnwrappedAddress = prefer_addr(addrs) + else: + addr = None yield addr, reg_portal @acm @@ -370,9 +466,9 @@ async def wait_for_actor( name=name, ) - # get latest registered addr by default? - # TODO: offer multi-portal yields in multi-homed case? - addr: UnwrappedAddress = addrs[-1] + # select the best transport addr from + # the (possibly multihomed) addr list. + addr: UnwrappedAddress = prefer_addr(addrs) async with _connect_chan(addr) as chan: async with open_portal(chan) as portal: diff --git a/tractor/discovery/_registry.py b/tractor/discovery/_registry.py index 8d29f2be..dd235753 100644 --- a/tractor/discovery/_registry.py +++ b/tractor/discovery/_registry.py @@ -101,11 +101,11 @@ class Registrar(Actor): self, name: str, - ) -> UnwrappedAddress|None: + ) -> list[UnwrappedAddress]|None: for uid, addrs in self._registry.items(): if name in uid: - return addrs[0] if addrs else None + return addrs if addrs else None return None