diff --git a/ai/prompt-io/claude/20260413T192116Z_f851f28_prompt_io.md b/ai/prompt-io/claude/20260413T192116Z_f851f28_prompt_io.md new file mode 100644 index 00000000..8c1e1519 --- /dev/null +++ b/ai/prompt-io/claude/20260413T192116Z_f851f28_prompt_io.md @@ -0,0 +1,74 @@ +--- +model: claude-opus-4-6 +service: claude +session: (ad-hoc, not tracked via conf.toml) +timestamp: 2026-04-13T19:21:16Z +git_ref: f851f28 +scope: code +substantive: true +--- + +## Prompt + +User provided a detailed implementation plan for +a new `test_tpt_bind_addrs.py` test suite covering +`open_root_actor(tpt_bind_addrs=...)` — the three +runtime code paths in `_root.py:385-450`: + +1. Non-registrar, no explicit bind -> random addrs +2. Registrar, no explicit bind -> registry_addrs +3. Explicit bind given -> `wrap_address()` + merge + +Plan specified 6 test functions (~10 parametrized +variants), predicted a type-mixing bug at line 446, +and asked for an in-flight fix if confirmed. + +## Response summary + +Created `tests/discovery/test_tpt_bind_addrs.py` +with 9 collected test variants across 6 functions: + +- `test_registrar_root_tpt_bind_addrs` (3 variants: + `bind-eq-reg`, `bind-subset-reg`, + `bind-disjoint-reg`) +- `test_non_registrar_root_tpt_bind_addrs` + (2 variants: `bind-same-bindspace`, + `bind-disjoint`) +- `test_non_registrar_default_random_bind` + (baseline, no explicit bind) +- `test_tpt_bind_addrs_as_maddr_str` + (multiaddr string input) +- `test_registrar_merge_binds_union` + (registrar + disjoint bind -> union) +- `test_open_nursery_forwards_tpt_bind_addrs` + (`open_nursery(**kwargs)` forwarding) + +Confirmed and fixed the predicted bug at +`_root.py:446`: the registrar merge path mixed +`Address` objects (`tpt_bind_addrs`) with raw tuples +(`uw_reg_addrs`) inside `set()`, preventing +deduplication and causing double-bind `OSError`. + +Fix: wrap `uw_reg_addrs` before the set union: +```python +# before (broken) +tpt_bind_addrs = list(set( + tpt_bind_addrs + uw_reg_addrs +)) +# after (fixed) +tpt_bind_addrs = list(set( + tpt_bind_addrs + + [wrap_address(a) for a in uw_reg_addrs] +)) +``` + +All 9 tests pass after the fix. + +## Files changed + +- `tests/discovery/test_tpt_bind_addrs.py` (new) +- `tractor/_root.py:446` (bug fix, 1 line) + +## Human edits + +N/A — pending review. diff --git a/ai/prompt-io/prompts/multiaddr_declare_eps.md_ b/ai/prompt-io/prompts/multiaddr_declare_eps.md_ new file mode 100644 index 00000000..dcd07cb8 --- /dev/null +++ b/ai/prompt-io/prompts/multiaddr_declare_eps.md_ @@ -0,0 +1,76 @@ +ok now i want you to take a look at the most recent commit adding +a `tpt_bind_addrs` to `open_root_actor()` and extend the existing +tests/discovery/test_multiaddr* and friends to use this new param in +at least one suite with parametrizations over, + +- `registry_addrs == tpt_bind_addrs`, as in both inputs are the same. +- `set(registry_addrs) >= set(tpt_bind_addrs)`, as in the registry + addrs include the bind set. +- `registry_addrs != tpt_bind_addrs`, where the reg set is disjoint from + the bind set in all possible combos you can imagine. + +All of the ^above cases should further be parametrized over, +- the root being the registrar, +- a non-registrar root using our bg `daemon` fixture. + +once we have a fairly thorough test suite and have flushed out all +bugs and edge cases we want to design a wrapping API which allows +declaring full tree's of actors tpt endpoints using multiaddrs such +that a `dict[str, list[str]]` of actor-name -> multiaddr can be used +to configure a tree of actors-as-services given such an input +"endpoints-table" can be matched with the number of appropriately +named subactore spawns in a `tractor` user-app. + +Here is a small example from piker, + +- in piker's root conf.toml we define a `[network]` section which can + define various actor-service-daemon names set to a maddr + (multiaddress str). + +- each actor whether part of the `pikerd` tree (as a sub) or spawned + in other non-registrar rooted trees (such as `piker chart`) should + configurable in terms of its `tractor` tpt bind addresses via + a simple service lookup table, + + ```toml + [network] + pikerd = [ + '/ip4/127.0.0.1/tcp/6116', # std localhost daemon-actor tree + '/uds/run/user/1000/piker/pikerd@6116.sock', # same but serving UDS + ] + chart = [ + '/ip4/127.0.0.1/tcp/3333', # std localhost daemon-actor tree + '/uds/run/user/1000/piker/chart@3333.sock', + ] + ``` + +We should take whatever common API is needed to support this and +distill it into a +```python +tractor.discovery.parse_endpoints( +) -> dict[ + str, + list[Address] + |dict[str, list[Address]] + # ^recursive case, see below +]: +``` + +style API which can, + +- be re-used easily across dependent projects. +- correctly raise tpt-backend support errors when a maddr specifying + a unsupport proto is passed. +- be used to handle "tunnelled" maddrs per + https://github.com/multiformats/py-multiaddr/#tunneling such that + for any such tunneled maddr-`str`-entry we deliver a data-structure + which can easily be passed to nested `@acm`s which consecutively + setup nested net bindspaces for binding the endpoint addrs using + a combo of our `.ipc.*` machinery and, say for example something like + https://github.com/svinota/pyroute2, more precisely say for + managing tunnelled wireguard eps within network-namespaces, + * https://docs.pyroute2.org/ + * https://docs.pyroute2.org/netns.html + +remember to include use of all default `.claude/skills` throughout +this work! diff --git a/tests/discovery/test_tpt_bind_addrs.py b/tests/discovery/test_tpt_bind_addrs.py new file mode 100644 index 00000000..61baecda --- /dev/null +++ b/tests/discovery/test_tpt_bind_addrs.py @@ -0,0 +1,337 @@ +''' +`open_root_actor(tpt_bind_addrs=...)` test suite. + +Verify all three runtime code paths for explicit IPC-server +bind-address selection in `_root.py`: + +1. Non-registrar, no explicit bind -> random addrs from registry proto +2. Registrar, no explicit bind -> binds to registry_addrs +3. Explicit bind given -> wraps via `wrap_address()` and uses them + +''' +from functools import partial + +import pytest +import trio +import tractor +from tractor.discovery._addr import ( + wrap_address, +) +from tractor.discovery._multiaddr import mk_maddr +from tractor._testing.addr import get_rando_addr + + +# ------------------------------------------------------------------ +# helpers +# ------------------------------------------------------------------ +def _bound_bindspaces( + actor: tractor.Actor, +) -> set[str]: + ''' + Collect the set of bindspace strings from the actor's + currently bound IPC-server accept addresses. + + ''' + return { + wrap_address(a).bindspace + for a in actor.accept_addrs + } + + +def _bound_wrapped( + actor: tractor.Actor, +) -> list: + ''' + Return the actor's accept addrs as wrapped `Address` objects. + + ''' + return [ + wrap_address(a) + for a in actor.accept_addrs + ] + + +# ------------------------------------------------------------------ +# 1) Registrar + explicit tpt_bind_addrs +# ------------------------------------------------------------------ +@pytest.mark.parametrize( + 'addr_combo', + [ + 'bind-eq-reg', + 'bind-subset-reg', + 'bind-disjoint-reg', + ], + ids=lambda v: v, +) +def test_registrar_root_tpt_bind_addrs( + reg_addr: tuple, + tpt_proto: str, + debug_mode: bool, + addr_combo: str, +): + ''' + Registrar root-actor with explicit `tpt_bind_addrs`: + bound set must include all registry + all bind addr bindspaces + (merge behavior). + + ''' + reg_wrapped = wrap_address(reg_addr) + + if addr_combo == 'bind-eq-reg': + bind_addrs = [reg_addr] + # extra secondary reg addr for subset test + extra_reg = [] + + elif addr_combo == 'bind-subset-reg': + second_reg = get_rando_addr(tpt_proto) + bind_addrs = [reg_addr] + extra_reg = [second_reg] + + elif addr_combo == 'bind-disjoint-reg': + # port=0 on same host -> completely different addr + rando = wrap_address(reg_addr).get_random( + bindspace=reg_wrapped.bindspace, + ) + bind_addrs = [rando.unwrap()] + extra_reg = [] + + all_reg = [reg_addr] + extra_reg + + async def _main(): + async with tractor.open_root_actor( + registry_addrs=all_reg, + tpt_bind_addrs=bind_addrs, + debug_mode=debug_mode, + ): + actor = tractor.current_actor() + assert actor.is_registrar + + bound = actor.accept_addrs + bound_bs = _bound_bindspaces(actor) + + # all registry bindspaces must appear in bound set + for ra in all_reg: + assert wrap_address(ra).bindspace in bound_bs + + # all bind-addr bindspaces must appear + for ba in bind_addrs: + assert wrap_address(ba).bindspace in bound_bs + + # registry addr must appear verbatim in bound + # (after wrapping both sides for comparison) + bound_w = _bound_wrapped(actor) + assert reg_wrapped in bound_w + + if addr_combo == 'bind-disjoint-reg': + assert len(bound) >= 2 + + trio.run(_main) + + +@pytest.mark.parametrize( + 'addr_combo', + [ + 'bind-same-bindspace', + 'bind-disjoint', + ], + ids=lambda v: v, +) +def test_non_registrar_root_tpt_bind_addrs( + daemon, + reg_addr: tuple, + tpt_proto: str, + debug_mode: bool, + addr_combo: str, +): + ''' + Non-registrar root with explicit `tpt_bind_addrs`: + bound set must exactly match the requested bind addrs + (no merge with registry). + + ''' + reg_wrapped = wrap_address(reg_addr) + + if addr_combo == 'bind-same-bindspace': + # same bindspace as reg but port=0 so we get a random port + rando = reg_wrapped.get_random( + bindspace=reg_wrapped.bindspace, + ) + bind_addrs = [rando.unwrap()] + + elif addr_combo == 'bind-disjoint': + rando = reg_wrapped.get_random( + bindspace=reg_wrapped.bindspace, + ) + bind_addrs = [rando.unwrap()] + + async def _main(): + async with tractor.open_root_actor( + registry_addrs=[reg_addr], + tpt_bind_addrs=bind_addrs, + debug_mode=debug_mode, + ): + actor = tractor.current_actor() + assert not actor.is_registrar + + bound = actor.accept_addrs + assert len(bound) == len(bind_addrs) + + # bindspaces must match + bound_bs = _bound_bindspaces(actor) + for ba in bind_addrs: + assert wrap_address(ba).bindspace in bound_bs + + # TCP port=0 should resolve to a real port + for uw_addr in bound: + w = wrap_address(uw_addr) + if w.proto_key == 'tcp': + _host, port = uw_addr + assert port > 0 + + trio.run(_main) + + +# ------------------------------------------------------------------ +# 3) Non-registrar, default random bind (baseline) +# ------------------------------------------------------------------ +def test_non_registrar_default_random_bind( + daemon, + reg_addr: tuple, + debug_mode: bool, +): + ''' + Baseline: no `tpt_bind_addrs`, daemon running. + Bound bindspace matches registry bindspace, + but bound addr differs from reg_addr (random). + + ''' + reg_wrapped = wrap_address(reg_addr) + + async def _main(): + async with tractor.open_root_actor( + registry_addrs=[reg_addr], + debug_mode=debug_mode, + ): + actor = tractor.current_actor() + assert not actor.is_registrar + + bound_bs = _bound_bindspaces(actor) + assert reg_wrapped.bindspace in bound_bs + + # bound addr should differ from the registry addr + # (the runtime picks a random port/path) + bound_w = _bound_wrapped(actor) + assert reg_wrapped not in bound_w + + trio.run(_main) + + +# ------------------------------------------------------------------ +# 4) Multiaddr string input +# ------------------------------------------------------------------ +def test_tpt_bind_addrs_as_maddr_str( + reg_addr: tuple, + debug_mode: bool, +): + ''' + Pass multiaddr strings as `tpt_bind_addrs`. + Runtime should parse and bind successfully. + + ''' + reg_wrapped = wrap_address(reg_addr) + # build a port-0 / random maddr string for binding + rando = reg_wrapped.get_random( + bindspace=reg_wrapped.bindspace, + ) + maddr_str: str = str(mk_maddr(rando)) + + async def _main(): + async with tractor.open_root_actor( + registry_addrs=[reg_addr], + tpt_bind_addrs=[maddr_str], + debug_mode=debug_mode, + ): + actor = tractor.current_actor() + assert actor.is_registrar + + for uw_addr in actor.accept_addrs: + w = wrap_address(uw_addr) + if w.proto_key == 'tcp': + _host, port = uw_addr + assert port > 0 + + trio.run(_main) + + +# ------------------------------------------------------------------ +# 5) Registrar merge produces union of binds +# ------------------------------------------------------------------ +def test_registrar_merge_binds_union( + tpt_proto: str, + debug_mode: bool, +): + ''' + Registrar + disjoint bind addr: bound set must include + both registry and explicit bind addresses. + + ''' + reg_addr = get_rando_addr(tpt_proto) + reg_wrapped = wrap_address(reg_addr) + + rando = reg_wrapped.get_random( + bindspace=reg_wrapped.bindspace, + ) + bind_addrs = [rando.unwrap()] + + async def _main(): + async with tractor.open_root_actor( + registry_addrs=[reg_addr], + tpt_bind_addrs=bind_addrs, + debug_mode=debug_mode, + ): + actor = tractor.current_actor() + assert actor.is_registrar + + bound = actor.accept_addrs + bound_w = _bound_wrapped(actor) + + # must have at least 2 (registry + bind) + assert len(bound) >= 2 + + # registry addr must appear in bound set + assert reg_wrapped in bound_w + + trio.run(_main) + + +# ------------------------------------------------------------------ +# 6) open_nursery forwards tpt_bind_addrs +# ------------------------------------------------------------------ +def test_open_nursery_forwards_tpt_bind_addrs( + reg_addr: tuple, + debug_mode: bool, +): + ''' + `open_nursery(tpt_bind_addrs=...)` forwards through + `**kwargs` to `open_root_actor()`. + + ''' + reg_wrapped = wrap_address(reg_addr) + rando = reg_wrapped.get_random( + bindspace=reg_wrapped.bindspace, + ) + bind_addrs = [rando.unwrap()] + + async def _main(): + async with tractor.open_nursery( + registry_addrs=[reg_addr], + tpt_bind_addrs=bind_addrs, + debug_mode=debug_mode, + ): + actor = tractor.current_actor() + bound_bs = _bound_bindspaces(actor) + + for ba in bind_addrs: + assert wrap_address(ba).bindspace in bound_bs + + trio.run(_main) diff --git a/tractor/_root.py b/tractor/_root.py index 97bf7062..02757c32 100644 --- a/tractor/_root.py +++ b/tractor/_root.py @@ -446,7 +446,7 @@ async def open_root_actor( tpt_bind_addrs = list(set( tpt_bind_addrs + - uw_reg_addrs + [wrap_address(a) for a in uw_reg_addrs] )) # - it is normally desirable for any registrar to stay up