Add `test_tpt_bind_addrs.py` + fix type-mixing bug
Add 9 test variants (6 fns) covering all three `tpt_bind_addrs` code paths in `open_root_actor()`: - registrar w/ explicit bind (eq, subset, disjoint) - non-registrar w/ explicit bind (same/diff bindspace) using `daemon` fixture - non-registrar default random bind (baseline) - maddr string input parsing - registrar merge produces union - `open_nursery()` forwards `tpt_bind_addrs` Fix type-mixing bug at `_root.py:446` where the registrar merge path did `set(Address + tuple)`, preventing dedup and causing double-bind `OSError`. Wrap `uw_reg_addrs` before the set union so both sides are `Address` objs. Also, - add prompt-io output log for this session - stage original prompt input for tracking Prompt-IO: ai/prompt-io/claude/20260413T192116Z_f851f28_prompt_io.md (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-codesubint_spawner_backend
parent
bc60aa1ec5
commit
7079a597c5
|
|
@ -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.
|
||||||
|
|
@ -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!
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -446,7 +446,7 @@ async def open_root_actor(
|
||||||
tpt_bind_addrs = list(set(
|
tpt_bind_addrs = list(set(
|
||||||
tpt_bind_addrs
|
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
|
# - it is normally desirable for any registrar to stay up
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue