From 8344537aa65da908272589d9de43082f567c2da0 Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 4 Feb 2026 09:10:21 -0500 Subject: [PATCH 01/31] Add `uds` to `._multiaddr`, tweak typing --- tractor/discovery/_multiaddr.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index e8713b40..b9f1c4ed 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -38,6 +38,7 @@ prots: bidict[int, str] = { 'tcp': 4, 'udp': 4, + 'uds': 4, # TODO: support the next-gen shite Bo # 'quic': 4, @@ -51,6 +52,7 @@ prot_params: dict[str, tuple[str]] = { 'tcp': ('port',), 'udp': ('port',), + 'uds': ('path',), # 'quic': ('port',), # 'ssh': ('port',), @@ -75,7 +77,7 @@ def iter_prot_layers( assert not root # there is a root '/' on LHS itokens = iter(tokens) - prot: str | None = None + prot: str|None = None params: list[str] = [] for token in itokens: # every prot path should start with a known @@ -98,7 +100,10 @@ def iter_prot_layers( def parse_maddr( multiaddr: str, -) -> dict[str, str | int | dict]: +) -> dict[ + str, + str|int|dict, +]: ''' Parse a libp2p style "multiaddress" into its distinct protocol segments where each segment is of the form: @@ -122,14 +127,17 @@ def parse_maddr( `'/wg/1.1.1.1/51820/'` ''' - layers: dict[str, str | int | dict] = {} + layers: dict[str, str|int|dict] = {} for ( prot_key, params, ) in iter_prot_layers(multiaddr): layer: int = prots[prot_key] # OSI layer used for sorting - ep: dict[str, int | str] = {'layer': layer} + ep: dict[str, int|str] = { + 'layer': layer, + 'proto': prot_key, + } layers[prot_key] = ep # TODO; validation and resolving of names: @@ -139,7 +147,7 @@ def parse_maddr( # any loaded network.resolv: dict[str, str] rparams: list = list(reversed(params)) for key in prot_params[prot_key]: - val: str | int = rparams.pop() + val: str|int = rparams.pop() # TODO: UGHH, dunno what we should do for validation # here, put it in the params spec somehow? From b4c975b48bc65e3151cd22a2ca94a3eb264e124d Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 25 Mar 2026 18:56:56 -0400 Subject: [PATCH 02/31] Add `multiaddr` usage snippet for IP4 and UDS (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- snippets/multiaddr_ex.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 snippets/multiaddr_ex.py diff --git a/snippets/multiaddr_ex.py b/snippets/multiaddr_ex.py new file mode 100644 index 00000000..ef9a9137 --- /dev/null +++ b/snippets/multiaddr_ex.py @@ -0,0 +1,11 @@ +from multiaddr import Multiaddr +# construct from a string +m1 = Multiaddr("/ip4/127.0.0.1/udp/1234") +m2 = Multiaddr("/unix/run/user/1000/sway-ipc.1000.1557.sock") +for key in m1.protocols(): + key + +uds_sock_path = Path(m2.values()[0]) +uds_sock_path +uds_sock_path.is_file() +uds_sock_path.is_socket() From d9cb38372fa5717e6256c9e50322f02cc14bdcb2 Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 25 Mar 2026 19:02:42 -0400 Subject: [PATCH 03/31] Add `multiaddr` dep to `pyproject.toml` Bump lock file to match obvi. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- pyproject.toml | 1 + uv.lock | 284 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 92e8bc21..fc82ccdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ dependencies = [ "msgspec>=0.19.0", "cffi>=1.17.1", "bidict>=0.23.1", + "multiaddr>=0.2.0", "platformdirs>=4.4.0", ] diff --git a/uv.lock b/uv.lock index f1d163a7..4cda65fd 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.12, <3.14" +[[package]] +name = "async-generator" +version = "1.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/b6/6fa6b3b598a03cba5e80f829e0dadbb49d7645f523d209b2fb7ea0bbb02a/async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144", size = 29870, upload-time = "2018-08-01T03:36:21.69Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/52/39d20e03abd0ac9159c162ec24b93fbcaa111e8400308f2465432495ca2b/async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b", size = 18857, upload-time = "2018-08-01T03:36:20.029Z" }, +] + [[package]] name = "attrs" version = "24.3.0" @@ -11,6 +20,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397, upload-time = "2024-12-16T06:59:26.977Z" }, ] +[[package]] +name = "base58" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/45/8ae61209bb9015f516102fa559a2914178da1d5868428bd86a1b4421141d/base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c", size = 6528, upload-time = "2021-10-30T22:12:17.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621, upload-time = "2021-10-30T22:12:16.658Z" }, +] + [[package]] name = "bidict" version = "0.23.1" @@ -20,6 +38,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, ] +[[package]] +name = "blake3" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/aa/abcd75e9600987a0bc6cfe9b6b2ff3f0e2cb08c170addc6e76035b5c4cb3/blake3-1.0.8.tar.gz", hash = "sha256:513cc7f0f5a7c035812604c2c852a0c1468311345573de647e310aca4ab165ba", size = 117308, upload-time = "2025-10-14T06:47:48.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/a0/b7b6dff04012cfd6e665c09ee446f749bd8ea161b00f730fe1bdecd0f033/blake3-1.0.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d8da4233984d51471bd4e4366feda1d90d781e712e0a504ea54b1f2b3577557b", size = 347983, upload-time = "2025-10-14T06:45:47.214Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a2/264091cac31d7ae913f1f296abc20b8da578b958ffb86100a7ce80e8bf5c/blake3-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1257be19f2d381c868a34cc822fc7f12f817ddc49681b6d1a2790bfbda1a9865", size = 325415, upload-time = "2025-10-14T06:45:48.482Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/85a4c0782f613de23d114a7a78fcce270f75b193b3ff3493a0de24ba104a/blake3-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:269f255b110840e52b6ce9db02217e39660ebad3e34ddd5bca8b8d378a77e4e1", size = 371296, upload-time = "2025-10-14T06:45:49.674Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/488475254976ed93fab57c67aa80d3b40df77f7d9db6528c9274bff53e08/blake3-1.0.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66ca28a673025c40db3eba21a9cac52f559f83637efa675b3f6bd8683f0415f3", size = 374516, upload-time = "2025-10-14T06:45:51.23Z" }, + { url = "https://files.pythonhosted.org/packages/7b/21/2a1c47fedb77fb396512677ec6d46caf42ac6e9a897db77edd0a2a46f7bb/blake3-1.0.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcb04966537777af56c1f399b35525aa70a1225816e121ff95071c33c0f7abca", size = 447911, upload-time = "2025-10-14T06:45:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7d/db0626df16029713e7e61b67314c4835e85c296d82bd907c21c6ea271da2/blake3-1.0.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5b5da177d62cc4b7edf0cea08fe4dec960c9ac27f916131efa890a01f747b93", size = 505420, upload-time = "2025-10-14T06:45:54.445Z" }, + { url = "https://files.pythonhosted.org/packages/5b/55/6e737850c2d58a6d9de8a76dad2ae0f75b852a23eb4ecb07a0b165e6e436/blake3-1.0.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38209b10482c97e151681ea3e91cc7141f56adbbf4820a7d701a923124b41e6a", size = 394189, upload-time = "2025-10-14T06:45:55.719Z" }, + { url = "https://files.pythonhosted.org/packages/5b/94/eafaa5cdddadc0c9c603a6a6d8339433475e1a9f60c8bb9c2eed2d8736b6/blake3-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504d1399b7fb91dfe5c25722d2807990493185faa1917456455480c36867adb5", size = 388001, upload-time = "2025-10-14T06:45:57.067Z" }, + { url = "https://files.pythonhosted.org/packages/17/81/735fa00d13de7f68b25e1b9cb36ff08c6f165e688d85d8ec2cbfcdedccc5/blake3-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c84af132aa09abeadf9a0118c8fb26f4528f3f42c10ef8be0fcf31c478774ec4", size = 550302, upload-time = "2025-10-14T06:45:58.657Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c6/d1fe8bdea4a6088bd54b5a58bc40aed89a4e784cd796af7722a06f74bae7/blake3-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a25db3d36b55f5ed6a86470155cc749fc9c5b91c949b8d14f48658f9d960d9ec", size = 554211, upload-time = "2025-10-14T06:46:00.269Z" }, + { url = "https://files.pythonhosted.org/packages/55/d1/ca74aa450cbe10e396e061f26f7a043891ffa1485537d6b30d3757e20995/blake3-1.0.8-cp312-cp312-win32.whl", hash = "sha256:e0fee93d5adcd44378b008c147e84f181f23715307a64f7b3db432394bbfce8b", size = 228343, upload-time = "2025-10-14T06:46:01.533Z" }, + { url = "https://files.pythonhosted.org/packages/4d/42/bbd02647169e3fbed27558555653ac2578c6f17ccacf7d1956c58ef1d214/blake3-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:6a6eafc29e4f478d365a87d2f25782a521870c8514bb43734ac85ae9be71caf7", size = 215704, upload-time = "2025-10-14T06:46:02.79Z" }, + { url = "https://files.pythonhosted.org/packages/55/b8/11de9528c257f7f1633f957ccaff253b706838d22c5d2908e4735798ec01/blake3-1.0.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:46dc20976bd6c235959ef0246ec73420d1063c3da2839a9c87ca395cf1fd7943", size = 347771, upload-time = "2025-10-14T06:46:04.248Z" }, + { url = "https://files.pythonhosted.org/packages/50/26/f7668be55c909678b001ecacff11ad7016cd9b4e9c7cc87b5971d638c5a9/blake3-1.0.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d17eb6382634b3a5bc0c0e0454d5265b0becaeeadb6801ed25150b39a999d0cc", size = 325431, upload-time = "2025-10-14T06:46:06.136Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/e8a85fa261894bf7ce7af928ff3408aab60287ab8d58b55d13a3f700b619/blake3-1.0.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19fc6f2b7edab8acff6895fc6e38c19bd79f4c089e21153020c75dfc7397d52d", size = 370994, upload-time = "2025-10-14T06:46:07.398Z" }, + { url = "https://files.pythonhosted.org/packages/62/cd/765b76bb48b8b294fea94c9008b0d82b4cfa0fa2f3c6008d840d01a597e4/blake3-1.0.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f54cff7f15d91dc78a63a2dd02a3dccdc932946f271e2adb4130e0b4cf608ba", size = 374372, upload-time = "2025-10-14T06:46:08.698Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/32084eadbb28592bb07298f0de316d2da586c62f31500a6b1339a7e7b29b/blake3-1.0.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7e12a777f6b798eb8d06f875d6e108e3008bd658d274d8c676dcf98e0f10537", size = 447627, upload-time = "2025-10-14T06:46:10.002Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f4/3788a1d86e17425eea147e28d7195d7053565fc279236a9fd278c2ec495e/blake3-1.0.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddfc59b0176fb31168f08d5dd536e69b1f4f13b5a0f4b0c3be1003efd47f9308", size = 507536, upload-time = "2025-10-14T06:46:11.614Z" }, + { url = "https://files.pythonhosted.org/packages/fe/01/4639cba48513b94192681b4da472cdec843d3001c5344d7051ee5eaef606/blake3-1.0.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2336d5b2a801a7256da21150348f41610a6c21dae885a3acb1ebbd7333d88d8", size = 394105, upload-time = "2025-10-14T06:46:12.808Z" }, + { url = "https://files.pythonhosted.org/packages/21/ae/6e55c19c8460fada86cd1306a390a09b0c5a2e2e424f9317d2edacea439f/blake3-1.0.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4072196547484c95a5a09adbb952e9bb501949f03f9e2a85e7249ef85faaba8", size = 386928, upload-time = "2025-10-14T06:46:16.284Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6c/05b7a5a907df1be53a8f19e7828986fc6b608a44119641ef9c0804fbef15/blake3-1.0.8-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0eab3318ec02f8e16fe549244791ace2ada2c259332f0c77ab22cf94dfff7130", size = 550003, upload-time = "2025-10-14T06:46:17.791Z" }, + { url = "https://files.pythonhosted.org/packages/b4/03/f0ea4adfedc1717623be6460b3710fcb725ca38082c14274369803f727e1/blake3-1.0.8-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a33b9a1fb6d1d559a8e0d04b041e99419a6bb771311c774f6ff57ed7119c70ed", size = 553857, upload-time = "2025-10-14T06:46:19.088Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6f/e5410d2e2a30c8aba8389ffc1c0061356916bf5ecd0a210344e7b69b62ab/blake3-1.0.8-cp313-cp313-win32.whl", hash = "sha256:e171b169cb7ea618e362a4dddb7a4d4c173bbc08b9ba41ea3086dd1265530d4f", size = 228315, upload-time = "2025-10-14T06:46:20.391Z" }, + { url = "https://files.pythonhosted.org/packages/79/ef/d9c297956dfecd893f29f59e7b22445aba5b47b7f6815d9ba5dcd73fcae6/blake3-1.0.8-cp313-cp313-win_amd64.whl", hash = "sha256:3168c457255b5d2a2fc356ba696996fcaff5d38284f968210d54376312107662", size = 215477, upload-time = "2025-10-14T06:46:21.542Z" }, + { url = "https://files.pythonhosted.org/packages/20/ba/eaa7723d66dd8ab762a3e85e139bb9c46167b751df6e950ad287adb8fb61/blake3-1.0.8-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4d672c24dc15ec617d212a338a4ca14b449829b6072d09c96c63b6e6b621aed", size = 347289, upload-time = "2025-10-14T06:46:22.772Z" }, + { url = "https://files.pythonhosted.org/packages/47/b3/6957f6ee27f0d5b8c4efdfda68a1298926a88c099f4dd89c711049d16526/blake3-1.0.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:1af0e5a29aa56d4fba904452ae784740997440afd477a15e583c38338e641f41", size = 324444, upload-time = "2025-10-14T06:46:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/13/da/722cebca11238f3b24d3cefd2361c9c9ea47cfa0ad9288eeb4d1e0b7cf93/blake3-1.0.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef153c5860d5bf1cc71aece69b28097d2a392913eb323d6b52555c875d0439fc", size = 370441, upload-time = "2025-10-14T06:46:26.29Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d5/2f7440c8e41c0af995bad3a159e042af0f4ed1994710af5b4766ca918f65/blake3-1.0.8-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8ae3689f0c7bfa6ce6ae45cab110e4c3442125c4c23b28f1f097856de26e4d1", size = 374312, upload-time = "2025-10-14T06:46:27.451Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6c/fb6a7812e60ce3e110bcbbb11f167caf3e975c589572c41e1271f35f2c41/blake3-1.0.8-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fb83532f7456ddeb68dae1b36e1f7c52f9cb72852ac01159bbcb1a12b0f8be0", size = 447007, upload-time = "2025-10-14T06:46:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/13/3b/c99b43fae5047276ea9d944077c190fc1e5f22f57528b9794e21f7adedc6/blake3-1.0.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae7754c7d96e92a70a52e07c732d594cf9924d780f49fffd3a1e9235e0f5ba7", size = 507323, upload-time = "2025-10-14T06:46:30.661Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bb/ba90eddd592f8c074a0694cb0a744b6bd76bfe67a14c2b490c8bdfca3119/blake3-1.0.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bacaae75e98dee3b7da6c5ee3b81ee21a3352dd2477d6f1d1dbfd38cdbf158a", size = 393449, upload-time = "2025-10-14T06:46:31.805Z" }, + { url = "https://files.pythonhosted.org/packages/25/ed/58a2acd0b9e14459cdaef4344db414d4a36e329b9720921b442a454dd443/blake3-1.0.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9456c829601d72852d8ba0af8dae0610f7def1d59f5942efde1e2ef93e8a8b57", size = 386844, upload-time = "2025-10-14T06:46:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/4a/04/fed09845b18d90862100c8e48308261e2f663aab25d3c71a6a0bdda6618b/blake3-1.0.8-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:497ef8096ec4ac1ffba9a66152cee3992337cebf8ea434331d8fd9ce5423d227", size = 549550, upload-time = "2025-10-14T06:46:35.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/1859fddfabc1cc72548c2269d988819aad96d854e25eae00531517925901/blake3-1.0.8-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:511133bab85ff60ed143424ce484d08c60894ff7323f685d7a6095f43f0c85c3", size = 553805, upload-time = "2025-10-14T06:46:36.532Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c7/2969352017f62378e388bb07bb2191bc9a953f818dc1cd6b9dd5c24916e1/blake3-1.0.8-cp313-cp313t-win32.whl", hash = "sha256:9c9fbdacfdeb68f7ca53bb5a7a5a593ec996eaf21155ad5b08d35e6f97e60877", size = 228068, upload-time = "2025-10-14T06:46:37.826Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fc/923e25ac9cadfff1cd20038bcc0854d0f98061eb6bc78e42c43615f5982d/blake3-1.0.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3cec94ed5676821cf371e9c9d25a41b4f3ebdb5724719b31b2749653b7cc1dfa", size = 215369, upload-time = "2025-10-14T06:46:39.054Z" }, +] + [[package]] name = "cffi" version = "1.17.1" @@ -74,6 +136,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424, upload-time = "2024-10-29T18:34:49.815Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "greenback" version = "1.2.1" @@ -130,6 +201,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -139,6 +222,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, ] +[[package]] +name = "mmh3" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" }, + { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" }, + { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" }, + { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" }, + { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" }, + { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" }, + { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" }, + { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" }, + { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" }, + { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" }, + { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" }, +] + +[[package]] +name = "morphys" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/4f/cb781d0ac5d079adabc77dc4f0bc99fc81c390029bd33c6e70552139e762/morphys-1.0-py2.py3-none-any.whl", hash = "sha256:76d6dbaa4d65f597e59d332c81da786d83e4669387b9b2a750cfec74e7beec20", size = 5618, upload-time = "2017-01-10T20:08:56.872Z" }, +] + [[package]] name = "msgspec" version = "0.19.0" @@ -161,6 +297,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/d8/f15b40611c2d5753d1abb0ca0da0c75348daf1252220e5dda2867bd81062/msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f", size = 187432, upload-time = "2024-12-27T17:40:16.256Z" }, ] +[[package]] +name = "multiaddr" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "base58" }, + { name = "dnspython" }, + { name = "idna" }, + { name = "netaddr" }, + { name = "psutil" }, + { name = "py-cid" }, + { name = "py-multibase" }, + { name = "py-multicodec" }, + { name = "py-multihash" }, + { name = "trio" }, + { name = "trio-typing" }, + { name = "varint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/10/4e26a8577cfce1c0febc8d83087e1373e93c695c6e73ad010546fb67e229/multiaddr-0.2.0.tar.gz", hash = "sha256:acb6b25c332ec1b2f1f8fef8d03a8c63385d34a87d690df0f4bba43cdf6efe8d", size = 58356, upload-time = "2026-03-17T21:51:00.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/13/56e503d01218d1ca27ea9fda862045a4b400cae5e756f47315f5aaba0eee/multiaddr-0.2.0-py3-none-any.whl", hash = "sha256:bcff7bf3d7de3d6da0b865b25423bcb411de1d20d70cc6abfacf75170d17866c", size = 40424, upload-time = "2026-03-17T21:50:58.833Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "netaddr" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/90/188b2a69654f27b221fba92fda7217778208532c962509e959a9cee5229d/netaddr-1.3.0.tar.gz", hash = "sha256:5c3c3d9895b551b763779ba7db7a03487dc1f8e3b385af819af341ae9ef6e48a", size = 2260504, upload-time = "2024-05-28T21:30:37.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cc/f4fe2c7ce68b92cbf5b2d379ca366e1edae38cccaad00f69f529b460c3ef/netaddr-1.3.0-py3-none-any.whl", hash = "sha256:c2c6a8ebe5554ce33b7d5b3a306b71bbb373e000bbbf2350dd5213cc56e3dbbe", size = 2262023, upload-time = "2024-05-28T21:30:34.191Z" }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -262,6 +439,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, ] +[[package]] +name = "py-cid" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "morphys" }, + { name = "py-multibase" }, + { name = "py-multicodec" }, + { name = "py-multihash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/68c2bd0346247570e8e01e8c170a0237884e95cdfa43989527b71adaa978/py_cid-0.5.0.tar.gz", hash = "sha256:93c62586c672353a9862f3fce13c9848ea39a00378e0980e2f0eed91631f3d28", size = 38028, upload-time = "2026-02-13T19:03:28.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/18/eaea1571ae8b4fa490793a4b78a9641c4579a884f7a26f3d1b019d7e91c2/py_cid-0.5.0-py3-none-any.whl", hash = "sha256:2fbad437384534e2a0ab0c4068aac3e510c4cb710c89c8f6bf98f4b07ed54e3e", size = 16046, upload-time = "2026-02-13T19:03:27.516Z" }, +] + +[[package]] +name = "py-multibase" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "morphys" }, + { name = "python-baseconv" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/52/5ed393ab49df7e3b03995d3c4e53bae1e8c2ca40909cf25a41b346c09a38/py_multibase-2.0.0.tar.gz", hash = "sha256:58c1a264195fa1ae29ea707c6fc8196446f4bdb92e0f9a0f131e0f280b238839", size = 26857, upload-time = "2025-12-18T02:24:49.132Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/c7/38035079d9978b32b962f996f1cccaa166ecfe38723ab4349ab32166c037/py_multibase-2.0.0-py3-none-any.whl", hash = "sha256:b29ce489b556134e73998a11712c406b70950812955df64084754e0774e40900", size = 10608, upload-time = "2025-12-18T02:24:47.827Z" }, +] + +[[package]] +name = "py-multicodec" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "varint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/26/ef24db0fbfec080b72c5ac4a1000da3a4d696a1e31862c695d683097a1b5/py_multicodec-1.0.0.tar.gz", hash = "sha256:78e4e3e47b6288cf635c3ca987152e6cb5510bdcdab307e7690c76ec3d5bbfeb", size = 44668, upload-time = "2025-12-18T20:41:37.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/da/768d07490faeae88ac361184164be9c262fececc3c6241b5fc471be4f659/py_multicodec-1.0.0-py3-none-any.whl", hash = "sha256:ae2e687bac8fdf54e3f5b3feded36b61a304d5e3c3af9438f7481f543ec15b8d", size = 26200, upload-time = "2025-12-18T20:41:37.055Z" }, +] + +[[package]] +name = "py-multihash" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "base58" }, + { name = "blake3" }, + { name = "mmh3" }, + { name = "morphys" }, + { name = "six" }, + { name = "varint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/3d/ed68b0eccd0654f7f3c163d9b3d428f903e5e3e884ab1f0d0a16ba6a4f11/py_multihash-3.0.0.tar.gz", hash = "sha256:2e848941de5ef0533ca26b81940e2ffcf7b4322a3f803e8c97f4f0eca8767aa7", size = 41630, upload-time = "2025-12-17T19:30:00.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/e2/d65606db8369916fb5a9b4fe14df7e6072970d919300f3fb1c989a1d8e7d/py_multihash-3.0.0-py3-none-any.whl", hash = "sha256:3863ec1313b4eac1e5169137c143d40bf77456e57388f839441deba089f87326", size = 21215, upload-time = "2025-12-17T19:29:59.322Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -310,6 +545,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, ] +[[package]] +name = "python-baseconv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/d0/9297d7d8dd74767b4d5560d834b30b2fff17d39987c23ed8656f476e0d9b/python-baseconv-1.2.2.tar.gz", hash = "sha256:0539f8bd0464013b05ad62e0a1673f0ac9086c76b43ebf9f833053527cd9931b", size = 4929, upload-time = "2019-04-04T19:28:57.17Z" } + [[package]] name = "ruff" version = "0.14.14" @@ -336,6 +577,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -384,6 +634,7 @@ dependencies = [ { name = "cffi" }, { name = "colorlog" }, { name = "msgspec" }, + { name = "multiaddr" }, { name = "pdbp" }, { name = "platformdirs" }, { name = "tricycle" }, @@ -428,6 +679,7 @@ requires-dist = [ { name = "cffi", specifier = ">=1.17.1" }, { name = "colorlog", specifier = ">=6.8.2,<7" }, { name = "msgspec", specifier = ">=0.19.0" }, + { name = "multiaddr", specifier = ">=0.2.0" }, { name = "pdbp", specifier = ">=1.8.2,<2" }, { name = "platformdirs", specifier = ">=4.4.0" }, { name = "tricycle", specifier = ">=0.4.1,<0.5" }, @@ -493,6 +745,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/55/c4d9bea8b3d7937901958f65124123512419ab0eb73695e5f382521abbfb/trio-0.29.0-py3-none-any.whl", hash = "sha256:d8c463f1a9cc776ff63e331aba44c125f423a5a13c684307e828d930e625ba66", size = 492920, upload-time = "2025-02-14T07:13:48.696Z" }, ] +[[package]] +name = "trio-typing" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-generator" }, + { name = "importlib-metadata" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "trio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/74/a87aafa40ec3a37089148b859892cbe2eef08d132c816d58a60459be5337/trio-typing-0.10.0.tar.gz", hash = "sha256:065ee684296d52a8ab0e2374666301aec36ee5747ac0e7a61f230250f8907ac3", size = 38747, upload-time = "2023-12-01T02:54:55.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/ff/9bd795273eb14fac7f6a59d16cc8c4d0948a619a1193d375437c7f50f3eb/trio_typing-0.10.0-py3-none-any.whl", hash = "sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264", size = 42224, upload-time = "2023-12-01T02:54:54.1Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.1" @@ -502,6 +771,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, ] +[[package]] +name = "varint" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/fe/1ea0ba0896dfa47186692655b86db3214c4b7c9e0e76c7b1dc257d101ab1/varint-1.0.2.tar.gz", hash = "sha256:a6ecc02377ac5ee9d65a6a8ad45c9ff1dac8ccee19400a5950fb51d594214ca5", size = 1886, upload-time = "2016-02-24T20:42:38.5Z" } + [[package]] name = "wcwidth" version = "0.2.13" @@ -563,3 +838,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/c2/3dd498dc28d8f89cdd52e39950c5e591499ae423f61694c0bb4d03ed1d82/xonsh-0.22.4-py312-none-any.whl", hash = "sha256:4e538fac9f4c3d866ddbdeca068f0c0515469c997ed58d3bfee963878c6df5a5", size = 654300, upload-time = "2026-02-17T07:53:35.813Z" }, { url = "https://files.pythonhosted.org/packages/82/7d/1f9c7147518e9f03f6ce081b5bfc4f1aceb6ec5caba849024d005e41d3be/xonsh-0.22.4-py313-none-any.whl", hash = "sha256:cc5fabf0ad0c56a2a11bed1e6a43c4ec6416a5b30f24f126b8e768547c3793e2", size = 654818, upload-time = "2026-02-17T07:53:33.477Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From 926e861f5269a6720c8edc6c5a3f64162cfe704a Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 25 Mar 2026 19:04:13 -0400 Subject: [PATCH 04/31] Use upstream `py-multiaddr` for `._multiaddr` Drop the NIH (notinventedhere) custom parser (`parse_maddr()`, `iter_prot_layers()`, `prots`/`prot_params` tables) which was never called anywhere in the codebase. Replace with a thin `mk_maddr()` factory that wraps the upstream `multiaddr.Multiaddr` type, dispatching on `Address.proto_key` to build spec-compliant paths. Deats, - `'tcp'` addrs detect ipv4 vs ipv6 via stdlib `ipaddress` (resolves existing TODO) - `'uds'` addrs map to `/unix/{path}` per the multiformats protocol registry (code 400) - fix UDS `.maddr` to include full sockpath (previously only used `filedir`, dropped filename) - standardize protocol names: `ipv4`->`ip4`, `uds`->`unix` - `.maddr` properties now return `Multiaddr` objs (`__str__()` gives the canonical path form so all existing f-string/log consumers work unchanged) - update `MsgTransport` protocol hint accordingly (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/discovery/_multiaddr.py | 172 ++++++++------------------------ tractor/ipc/_tcp.py | 19 +--- tractor/ipc/_transport.py | 4 +- tractor/ipc/_uds.py | 14 +-- tractor/runtime/_runtime.py | 2 +- 5 files changed, 56 insertions(+), 155 deletions(-) diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index b9f1c4ed..99dd620e 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -15,145 +15,61 @@ # along with this program. If not, see . ''' -Multiaddress parser and utils according the spec(s) defined by -`libp2p` and used in dependent project such as `ipfs`: +Multiaddress support using the upstream `py-multiaddr` lib +(a `libp2p` community standard) instead of our own NIH parser. -- https://docs.libp2p.io/concepts/fundamentals/addressing/ -- https://github.com/libp2p/specs/blob/master/addressing/README.md +- https://github.com/multiformats/multiaddr +- https://github.com/multiformats/py-multiaddr +- https://github.com/multiformats/multiaddr/blob/master/protocols.csv +- https://github.com/multiformats/multiaddr/blob/master/protocols/unix.md ''' -from typing import Iterator +import ipaddress +from pathlib import Path +from typing import TYPE_CHECKING -from bidict import bidict +from multiaddr import Multiaddr -# TODO: see if we can leverage libp2p ecosys projects instead of -# rolling our own (parser) impls of the above addressing specs: -# - https://github.com/libp2p/py-libp2p -# - https://docs.libp2p.io/concepts/nat/circuit-relay/#relay-addresses -# prots: bidict[int, str] = bidict({ -prots: bidict[int, str] = { - 'ipv4': 3, - 'ipv6': 3, - 'wg': 3, +if TYPE_CHECKING: + from tractor.discovery._addr import Address - 'tcp': 4, - 'udp': 4, - 'uds': 4, - - # TODO: support the next-gen shite Bo - # 'quic': 4, - # 'ssh': 7, # via rsyscall bootstrapping -} - -prot_params: dict[str, tuple[str]] = { - 'ipv4': ('addr',), - 'ipv6': ('addr',), - 'wg': ('addr', 'port', 'pubkey'), - - 'tcp': ('port',), - 'udp': ('port',), - 'uds': ('path',), - - # 'quic': ('port',), - # 'ssh': ('port',), +# map from tractor-internal `proto_key` identifiers +# to the standard multiaddr protocol name strings. +_tpt_proto_to_maddr: dict[str, str] = { + 'tcp': 'tcp', + 'uds': 'unix', } -def iter_prot_layers( - multiaddr: str, -) -> Iterator[ - tuple[ - int, - list[str] - ] -]: +def mk_maddr( + addr: 'Address', +) -> Multiaddr: ''' - Unpack a libp2p style "multiaddress" into multiple "segments" - for each "layer" of the protocoll stack (in OSI terms). + Construct a `Multiaddr` from a tractor `Address` instance, + dispatching on the `.proto_key` to build the correct + multiaddr-spec-compliant protocol path. ''' - tokens: list[str] = multiaddr.split('/') - root, tokens = tokens[0], tokens[1:] - assert not root # there is a root '/' on LHS - itokens = iter(tokens) + match addr.proto_key: + case 'tcp': + host, port = addr.unwrap() + ip = ipaddress.ip_address(host) + net_proto: str = ( + 'ip4' if ip.version == 4 + else 'ip6' + ) + return Multiaddr( + f'/{net_proto}/{host}/tcp/{port}' + ) - prot: str|None = None - params: list[str] = [] - for token in itokens: - # every prot path should start with a known - # key-str. - if token in prots: - if prot is None: - prot: str = token - else: - yield prot, params - prot = token + case 'uds': + filedir, filename = addr.unwrap() + filepath = Path(filedir) / filename + return Multiaddr( + f'/unix/{filepath}' + ) - params = [] - - elif token not in prots: - params.append(token) - - else: - yield prot, params - - -def parse_maddr( - multiaddr: str, -) -> dict[ - str, - str|int|dict, -]: - ''' - Parse a libp2p style "multiaddress" into its distinct protocol - segments where each segment is of the form: - - `..////../` - - and is loaded into a (order preserving) `layers: dict[str, - dict[str, Any]` which holds each protocol-layer-segment of the - original `str` path as a separate entry according to its approx - OSI "layer number". - - Any `paramN` in the path must be distinctly defined by a str-token in the - (module global) `prot_params` table. - - For eg. for wireguard which requires an address, port number and publickey - the protocol params are specified as the entry: - - 'wg': ('addr', 'port', 'pubkey'), - - and are thus parsed from a maddr in that order: - `'/wg/1.1.1.1/51820/'` - - ''' - layers: dict[str, str|int|dict] = {} - for ( - prot_key, - params, - ) in iter_prot_layers(multiaddr): - - layer: int = prots[prot_key] # OSI layer used for sorting - ep: dict[str, int|str] = { - 'layer': layer, - 'proto': prot_key, - } - layers[prot_key] = ep - - # TODO; validation and resolving of names: - # - each param via a validator provided as part of the - # prot_params def? (also see `"port"` case below..) - # - do a resolv step that will check addrs against - # any loaded network.resolv: dict[str, str] - rparams: list = list(reversed(params)) - for key in prot_params[prot_key]: - val: str|int = rparams.pop() - - # TODO: UGHH, dunno what we should do for validation - # here, put it in the params spec somehow? - if key == 'port': - val = int(val) - - ep[key] = val - - return layers + case _: + raise ValueError( + f'Unsupported proto_key: {addr.proto_key!r}' + ) diff --git a/tractor/ipc/_tcp.py b/tractor/ipc/_tcp.py index b05f2829..293ae4be 100644 --- a/tractor/ipc/_tcp.py +++ b/tractor/ipc/_tcp.py @@ -33,8 +33,10 @@ from trio import ( open_tcp_listeners, ) +from multiaddr import Multiaddr from tractor.msg import MsgCodec from tractor.log import get_logger +from tractor.discovery._multiaddr import mk_maddr from tractor.ipc._transport import ( MsgTransport, MsgpackTransport, @@ -198,21 +200,8 @@ class MsgpackTCPStream(MsgpackTransport): layer_key: int = 4 @property - def maddr(self) -> str: - host, port = self.raddr.unwrap() - return ( - # TODO, use `ipaddress` from stdlib to handle - # first detecting which of `ipv4/6` before - # choosing the routing prefix part. - f'/ipv4/{host}' - - f'/{self.address_type.proto_key}/{port}' - # f'/{self.chan.uid[0]}' - # f'/{self.cid}' - - # f'/cid={cid_head}..{cid_tail}' - # TODO: ? not use this ^ right ? - ) + def maddr(self) -> Multiaddr: + return mk_maddr(self.raddr) def connected(self) -> bool: return self.stream.socket.fileno() != -1 diff --git a/tractor/ipc/_transport.py b/tractor/ipc/_transport.py index a3f87293..0a38d14d 100644 --- a/tractor/ipc/_transport.py +++ b/tractor/ipc/_transport.py @@ -27,6 +27,8 @@ from typing import ( ClassVar, TYPE_CHECKING, ) +if TYPE_CHECKING: + from multiaddr import Multiaddr from collections.abc import ( AsyncGenerator, AsyncIterator, @@ -118,7 +120,7 @@ class MsgTransport(Protocol): ... @property - def maddr(self) -> str: + def maddr(self) -> Multiaddr|str: ... @classmethod diff --git a/tractor/ipc/_uds.py b/tractor/ipc/_uds.py index 26174d55..51f21353 100644 --- a/tractor/ipc/_uds.py +++ b/tractor/ipc/_uds.py @@ -48,8 +48,10 @@ from trio._highlevel_open_unix_stream import ( has_unix, ) +from multiaddr import Multiaddr from tractor.msg import MsgCodec from tractor.log import get_logger +from tractor.discovery._multiaddr import mk_maddr from tractor.ipc._transport import ( MsgpackTransport, ) @@ -442,19 +444,11 @@ class MsgpackUDSStream(MsgpackTransport): layer_key: int = 4 @property - def maddr(self) -> str: + def maddr(self) -> Multiaddr|str: if not self.raddr: return '' - filepath: Path = Path(self.raddr.unwrap()[0]) - return ( - f'/{self.address_type.proto_key}/{filepath}' - # f'/{self.chan.uid[0]}' - # f'/{self.cid}' - - # f'/cid={cid_head}..{cid_tail}' - # TODO: ? not use this ^ right ? - ) + return mk_maddr(self.raddr) def connected(self) -> bool: return self.stream.socket.fileno() != -1 diff --git a/tractor/runtime/_runtime.py b/tractor/runtime/_runtime.py index 477d8f9b..074c339a 100644 --- a/tractor/runtime/_runtime.py +++ b/tractor/runtime/_runtime.py @@ -1379,7 +1379,7 @@ class Actor: # - `Channel.maddr() -> str:` obvi! # - `Context.maddr() -> str:` tasks_str += ( - f' |_@ /ipv4/tcp/cid="{ctx.cid[-16:]} .."\n' + f' |_@ /ip4/tcp/cid="{ctx.cid[-16:]} .."\n' f' |>> {ctx._nsf}() -> dict:\n' ) From c72d495d68c4e7ef1423a776010cfff64e9448d9 Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 25 Mar 2026 19:38:21 -0400 Subject: [PATCH 05/31] Use `_tpt_proto_to_maddr` lookup in `mk_maddr()` Address Copilot review: the mapping table was defined but never referenced. Now `mk_maddr()` resolves `proto_key` -> maddr protocol name via the table and rejects unknown keys upfront. Also add missing `Path` import to the `multiaddr` usage snippet. Review: PR #429 (Copilot) https://github.com/goodboy/tractor/pull/429#pullrequestreview-4010456884 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- snippets/multiaddr_ex.py | 2 ++ tractor/discovery/_multiaddr.py | 18 ++++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/snippets/multiaddr_ex.py b/snippets/multiaddr_ex.py index ef9a9137..5e1bb2f8 100644 --- a/snippets/multiaddr_ex.py +++ b/snippets/multiaddr_ex.py @@ -1,4 +1,6 @@ +from pathlib import Path from multiaddr import Multiaddr + # construct from a string m1 = Multiaddr("/ip4/127.0.0.1/udp/1234") m2 = Multiaddr("/unix/run/user/1000/sway-ipc.1000.1557.sock") diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index 99dd620e..4f62d73a 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -50,7 +50,14 @@ def mk_maddr( multiaddr-spec-compliant protocol path. ''' - match addr.proto_key: + proto_key: str = addr.proto_key + maddr_proto: str|None = _tpt_proto_to_maddr.get(proto_key) + if maddr_proto is None: + raise ValueError( + f'Unsupported proto_key: {proto_key!r}' + ) + + match proto_key: case 'tcp': host, port = addr.unwrap() ip = ipaddress.ip_address(host) @@ -59,17 +66,12 @@ def mk_maddr( else 'ip6' ) return Multiaddr( - f'/{net_proto}/{host}/tcp/{port}' + f'/{net_proto}/{host}/{maddr_proto}/{port}' ) case 'uds': filedir, filename = addr.unwrap() filepath = Path(filedir) / filename return Multiaddr( - f'/unix/{filepath}' - ) - - case _: - raise ValueError( - f'Unsupported proto_key: {addr.proto_key!r}' + f'/{maddr_proto}/{filepath}' ) From 7cf3b5d00dc107e81b60e096728db8cfbaebe179 Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 25 Mar 2026 19:48:00 -0400 Subject: [PATCH 06/31] Add `test_multiaddr.py` suite for `mk_maddr()` Cover `_tpt_proto_to_maddr` mapping, TCP (ipv4/ipv6), UDS, unsupported `proto_key` error, and round-trip re-parse for both transport types. Deats, - new `tests/discovery/` subpkg w/ empty `__init__.py` - `test_tpt_proto_to_maddr_mapping`: verify `tcp` and `uds` entries - `test_mk_maddr_tcp_ipv4`: full assertion on `/ip4/127.0.0.1/tcp/1234` incl protocol iteration - `test_mk_maddr_tcp_ipv6`: verify `/ip6/::1/tcp/5678` - `test_mk_maddr_uds`: relative `filedir` bc the multiaddr parser rejects double-slash from abs paths - `test_mk_maddr_unsupported_proto_key`: `ValueError` on `proto_key='quic'` via `SimpleNamespace` mock - `test_mk_maddr_roundtrip`: parametrized over tcp + uds, re-parse `str(maddr)` back through `Multiaddr` (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/discovery/__init__.py | 0 tests/discovery/test_multiaddr.py | 136 ++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 tests/discovery/__init__.py create mode 100644 tests/discovery/test_multiaddr.py diff --git a/tests/discovery/__init__.py b/tests/discovery/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/discovery/test_multiaddr.py b/tests/discovery/test_multiaddr.py new file mode 100644 index 00000000..81eec2ce --- /dev/null +++ b/tests/discovery/test_multiaddr.py @@ -0,0 +1,136 @@ +''' +Multiaddr construction and round-trip tests for +`tractor.discovery._multiaddr.mk_maddr()`. + +''' +from pathlib import Path +from types import SimpleNamespace + +import pytest +from multiaddr import Multiaddr + +from tractor.ipc._tcp import TCPAddress +from tractor.ipc._uds import UDSAddress +from tractor.discovery._multiaddr import ( + mk_maddr, + _tpt_proto_to_maddr, +) + + +def test_tpt_proto_to_maddr_mapping(): + ''' + `_tpt_proto_to_maddr` maps all supported `proto_key` + values to their correct multiaddr protocol names. + + ''' + assert _tpt_proto_to_maddr['tcp'] == 'tcp' + assert _tpt_proto_to_maddr['uds'] == 'unix' + assert len(_tpt_proto_to_maddr) == 2 + + +def test_mk_maddr_tcp_ipv4(): + ''' + `mk_maddr()` on a `TCPAddress` with an IPv4 host + produces the correct `/ip4//tcp/` multiaddr. + + ''' + addr = TCPAddress('127.0.0.1', 1234) + result: Multiaddr = mk_maddr(addr) + + assert isinstance(result, Multiaddr) + assert str(result) == '/ip4/127.0.0.1/tcp/1234' + + protos = result.protocols() + assert protos[0].name == 'ip4' + assert protos[1].name == 'tcp' + + assert result.value_for_protocol('ip4') == '127.0.0.1' + assert result.value_for_protocol('tcp') == '1234' + + +def test_mk_maddr_tcp_ipv6(): + ''' + `mk_maddr()` on a `TCPAddress` with an IPv6 host + produces the correct `/ip6//tcp/` multiaddr. + + ''' + addr = TCPAddress('::1', 5678) + result: Multiaddr = mk_maddr(addr) + + assert str(result) == '/ip6/::1/tcp/5678' + + protos = result.protocols() + assert protos[0].name == 'ip6' + assert protos[1].name == 'tcp' + + +def test_mk_maddr_uds(): + ''' + `mk_maddr()` on a `UDSAddress` produces a `/unix/` + multiaddr containing the full socket path. + + ''' + # NOTE, use a relative `filedir` since the multiaddr + # parser rejects the double-slash from absolute paths + # (i.e. `/unix//tmp/..` -> "empty protocol path"). + filedir = 'tractor_test' + filename = 'test_sock.sock' + addr = UDSAddress( + filedir=filedir, + filename=filename, + ) + result: Multiaddr = mk_maddr(addr) + + assert isinstance(result, Multiaddr) + + result_str: str = str(result) + assert result_str.startswith('/unix/') + + sockpath: str = str(Path(filedir) / filename) + # NOTE, the multiaddr lib prepends a `/` to the + # unix protocol value when parsing back out. + unix_val: str = result.value_for_protocol('unix') + assert unix_val.endswith(sockpath) + + +def test_mk_maddr_unsupported_proto_key(): + ''' + `mk_maddr()` raises `ValueError` for an unsupported + `proto_key`. + + ''' + fake_addr = SimpleNamespace(proto_key='quic') + with pytest.raises( + ValueError, + match='Unsupported proto_key', + ): + mk_maddr(fake_addr) + + +@pytest.mark.parametrize( + 'addr', + [ + pytest.param( + TCPAddress('127.0.0.1', 9999), + id='tcp-ipv4', + ), + pytest.param( + UDSAddress( + filedir='tractor_rt', + filename='roundtrip.sock', + ), + id='uds', + ), + ], +) +def test_mk_maddr_roundtrip(addr): + ''' + `mk_maddr()` output is valid multiaddr syntax that the + library can re-parse back into an equivalent `Multiaddr`. + + ''' + maddr: Multiaddr = mk_maddr(addr) + reparsed = Multiaddr(str(maddr)) + + assert reparsed == maddr + assert str(reparsed) == str(maddr) From 1f1e09a78665f79d6debcfab41594386f14fd067 Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 25 Mar 2026 19:54:41 -0400 Subject: [PATCH 07/31] Move `test_discovery` to `tests/discovery/test_registrar` All tests are registrar-actor integration scenarios sharing intertwined helpers + `enable_modules=[__name__]` task fns, so keep as one mod but rename to reflect content. Now lives alongside `test_multiaddr.py` in the new `tests/discovery/` subpkg. Also, - update 5 refs in `/run-tests` SKILL.md to match the new path - add `discovery/` subdir to the test directory layout tree (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .claude/skills/run-tests/SKILL.md | 12 +++++++----- .../test_registrar.py} | 0 2 files changed, 7 insertions(+), 5 deletions(-) rename tests/{test_discovery.py => discovery/test_registrar.py} (100%) diff --git a/.claude/skills/run-tests/SKILL.md b/.claude/skills/run-tests/SKILL.md index 948a6fb3..1d2b284a 100644 --- a/.claude/skills/run-tests/SKILL.md +++ b/.claude/skills/run-tests/SKILL.md @@ -37,7 +37,7 @@ treat it as the test target. Examples: - `/run-tests` → full suite - `/run-tests test_local.py` → single file -- `/run-tests test_discovery -v` → file + verbose +- `/run-tests test_registrar -v` → file + verbose - `/run-tests -k cancel` → keyword filter - `/run-tests tests/ipc/ --tpt-proto uds` → subdir + UDS @@ -81,7 +81,7 @@ python -m pytest tests/test_local.py tests/test_rpc.py -x --tb=short --no-header python -m pytest tests/ -x --tb=short --no-header # specific test with debug -python -m pytest tests/test_discovery.py::test_reg_then_unreg -x -s --tpdb --ll debug +python -m pytest tests/discovery/test_registrar.py::test_reg_then_unreg -x -s --tpdb --ll debug # run with UDS transport python -m pytest tests/ -x --tb=short --no-header --tpt-proto uds @@ -173,8 +173,10 @@ tests/ ├── devx/ # debugger/tooling tests ├── ipc/ # transport protocol tests ├── msg/ # messaging layer tests +├── discovery/ # discovery subsystem tests +│ ├── test_multiaddr.py # multiaddr construction +│ └── test_registrar.py # registry/discovery protocol ├── test_local.py # registrar + local actor basics -├── test_discovery.py # registry/discovery protocol ├── test_rpc.py # RPC error handling ├── test_spawning.py # subprocess spawning ├── test_multi_program.py # multi-process tree tests @@ -193,7 +195,7 @@ test subset first for fast feedback: | Changed module(s) | Run these tests first | |---|---| | `runtime/_runtime.py`, `runtime/_state.py` | `test_local.py test_rpc.py test_spawning.py test_root_runtime.py` | -| `discovery/` (`_registry`, `_discovery`, `_addr`) | `test_discovery.py test_multi_program.py test_local.py` | +| `discovery/` (`_registry`, `_discovery`, `_addr`) | `tests/discovery/ test_multi_program.py test_local.py` | | `_context.py`, `_streaming.py` | `test_context_stream_semantics.py test_advanced_streaming.py` | | `ipc/` (`_chan`, `_server`, `_transport`) | `tests/ipc/ test_2way.py` | | `runtime/_portal.py`, `runtime/_rpc.py` | `test_rpc.py test_cancellation.py` | @@ -212,7 +214,7 @@ test subset first for fast feedback: python -c 'import tractor' && python -m pytest tests/ -x -q --co 2>&1 | tail -3 # core subset (~10s) -python -m pytest tests/test_local.py tests/test_rpc.py tests/test_spawning.py tests/test_discovery.py -x --tb=short --no-header +python -m pytest tests/test_local.py tests/test_rpc.py tests/test_spawning.py tests/discovery/test_registrar.py -x --tb=short --no-header ``` ### Re-run last failures only: diff --git a/tests/test_discovery.py b/tests/discovery/test_registrar.py similarity index 100% rename from tests/test_discovery.py rename to tests/discovery/test_registrar.py From 90ba0e3658e4f844ec7675cef23db4abef0cdd86 Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 26 Mar 2026 16:55:10 -0400 Subject: [PATCH 08/31] Add `parse_maddr()` + `str` arm in `wrap_address()` Inverse of `mk_maddr()`: parse a multiaddr string like `/ip4/127.0.0.1/tcp/1234` back into a tractor `Address`. Deats, - add `_maddr_to_tpt_proto` reverse mapping dict - add `parse_maddr()` fn dispatching on protocol combo: `[ip4|ip6, tcp]` -> `TCPAddress`, `[unix]` -> `UDSAddress` - strip leading `/` the multiaddr lib prepends to unix protocol values for correct round-trip - add `str` match case in `wrap_address()` for `/`-prefixed multiaddr strings, broaden type hint to `UnwrappedAddress|str` (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/discovery/_addr.py | 10 ++++++- tractor/discovery/_multiaddr.py | 49 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/tractor/discovery/_addr.py b/tractor/discovery/_addr.py index cb95f792..2697c5c9 100644 --- a/tractor/discovery/_addr.py +++ b/tractor/discovery/_addr.py @@ -206,7 +206,7 @@ def mk_uuid() -> str: def wrap_address( - addr: UnwrappedAddress + addr: UnwrappedAddress|str, ) -> Address: ''' Wrap an `UnwrappedAddress` as an `Address`-type based @@ -257,6 +257,14 @@ def wrap_address( cls: Type[Address] = get_address_cls(_def_tpt_proto) addr: UnwrappedAddress = cls.get_root().unwrap() + # multiaddr-format string, e.g. + # '/ip4/127.0.0.1/tcp/1616' + case str() if addr.startswith('/'): + from tractor.discovery._multiaddr import ( + parse_maddr, + ) + return parse_maddr(addr) + case _: # import pdbp; pdbp.set_trace() # from tractor.devx import mk_pdb diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index 4f62d73a..2a0ee7e6 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -40,6 +40,12 @@ _tpt_proto_to_maddr: dict[str, str] = { 'uds': 'unix', } +# reverse mapping: multiaddr protocol name -> tractor proto_key +_maddr_to_tpt_proto: dict[str, str] = { + v: k for k, v in _tpt_proto_to_maddr.items() +} +# {'tcp': 'tcp', 'unix': 'uds'} + def mk_maddr( addr: 'Address', @@ -75,3 +81,46 @@ def mk_maddr( return Multiaddr( f'/{maddr_proto}/{filepath}' ) + + +def parse_maddr( + maddr_str: str, +) -> 'Address': + ''' + Parse a multiaddr string into a tractor `Address`. + + Inverse of `mk_maddr()`. + + ''' + # lazy imports to avoid circular deps + from tractor.ipc._tcp import TCPAddress + from tractor.ipc._uds import UDSAddress + + maddr = Multiaddr(maddr_str) + proto_names: list[str] = [ + p.name for p in maddr.protocols() + ] + + match proto_names: + case [('ip4' | 'ip6') as net_proto, 'tcp']: + host: str = maddr.value_for_protocol(net_proto) + port: int = int(maddr.value_for_protocol('tcp')) + return TCPAddress(host, port) + + case ['unix']: + # NOTE, the multiaddr lib prepends a `/` to the + # unix protocol value; strip it to recover the + # original relative path. + raw: str = maddr.value_for_protocol('unix') + sockpath = Path(raw.lstrip('/')) + return UDSAddress( + filedir=str(sockpath.parent), + filename=str(sockpath.name), + ) + + case _: + raise ValueError( + f'Unsupported multiaddr protocol combo: ' + f'{proto_names!r}\n' + f'from maddr: {maddr_str!r}\n' + ) From 5c4438bacc63f01c3fe2431275d337aa69f51ea0 Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 26 Mar 2026 19:10:20 -0400 Subject: [PATCH 09/31] Add `parse_maddr()` tests + registrar maddr integ test Cover `parse_maddr()` with unit tests for tcp/ipv4, tcp/ipv6, uds, and unsupported-protocol error paths, plus full `addr -> mk_maddr -> str -> parse_maddr` roundtrip verification. Adds, - a `_maddr_to_tpt_proto` inverse-mapping assertion. - an `wrap_address()` maddr-string acceptance test. - a `test_reg_then_unreg_maddr` end-to-end suite which audits passing the registry addr as multiaddr str through the entire runtime. (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/discovery/test_multiaddr.py | 115 +++++++++++++++++++++++++++++- tests/discovery/test_registrar.py | 45 ++++++++++++ 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/tests/discovery/test_multiaddr.py b/tests/discovery/test_multiaddr.py index 81eec2ce..8329b033 100644 --- a/tests/discovery/test_multiaddr.py +++ b/tests/discovery/test_multiaddr.py @@ -1,6 +1,7 @@ ''' -Multiaddr construction and round-trip tests for -`tractor.discovery._multiaddr.mk_maddr()`. +Multiaddr construction, parsing, and round-trip tests for +`tractor.discovery._multiaddr.mk_maddr()` and +`tractor.discovery._multiaddr.parse_maddr()`. ''' from pathlib import Path @@ -13,8 +14,11 @@ from tractor.ipc._tcp import TCPAddress from tractor.ipc._uds import UDSAddress from tractor.discovery._multiaddr import ( mk_maddr, + parse_maddr, _tpt_proto_to_maddr, + _maddr_to_tpt_proto, ) +from tractor.discovery._addr import wrap_address def test_tpt_proto_to_maddr_mapping(): @@ -134,3 +138,110 @@ def test_mk_maddr_roundtrip(addr): assert reparsed == maddr assert str(reparsed) == str(maddr) + + +# ------ parse_maddr() tests ------ + +def test_maddr_to_tpt_proto_mapping(): + ''' + `_maddr_to_tpt_proto` is the exact inverse of + `_tpt_proto_to_maddr`. + + ''' + assert _maddr_to_tpt_proto == { + 'tcp': 'tcp', + 'unix': 'uds', + } + + +def test_parse_maddr_tcp_ipv4(): + ''' + `parse_maddr()` on an IPv4 TCP multiaddr string + produce a `TCPAddress` with the correct host and port. + + ''' + result = parse_maddr('/ip4/127.0.0.1/tcp/1234') + + assert isinstance(result, TCPAddress) + assert result.unwrap() == ('127.0.0.1', 1234) + + +def test_parse_maddr_tcp_ipv6(): + ''' + `parse_maddr()` on an IPv6 TCP multiaddr string + produce a `TCPAddress` with the correct host and port. + + ''' + result = parse_maddr('/ip6/::1/tcp/5678') + + assert isinstance(result, TCPAddress) + assert result.unwrap() == ('::1', 5678) + + +def test_parse_maddr_uds(): + ''' + `parse_maddr()` on a `/unix/...` multiaddr string + produce a `UDSAddress` with the correct dir and filename. + + ''' + result = parse_maddr('/unix/tractor_test/test.sock') + + assert isinstance(result, UDSAddress) + filedir, filename = result.unwrap() + assert filename == 'test.sock' + assert 'tractor_test' in str(filedir) + + +def test_parse_maddr_unsupported(): + ''' + `parse_maddr()` raise `ValueError` for an unsupported + protocol combination like UDP. + + ''' + with pytest.raises( + ValueError, + match='Unsupported multiaddr protocol combo', + ): + parse_maddr('/ip4/127.0.0.1/udp/1234') + + +@pytest.mark.parametrize( + 'addr', + [ + pytest.param( + TCPAddress('127.0.0.1', 9999), + id='tcp-ipv4', + ), + pytest.param( + UDSAddress( + filedir='tractor_rt', + filename='roundtrip.sock', + ), + id='uds', + ), + ], +) +def test_parse_maddr_roundtrip(addr): + ''' + Full round-trip: `addr -> mk_maddr -> str -> parse_maddr` + produce an `Address` whose `.unwrap()` matches the original. + + ''' + maddr: Multiaddr = mk_maddr(addr) + maddr_str: str = str(maddr) + parsed = parse_maddr(maddr_str) + + assert type(parsed) is type(addr) + assert parsed.unwrap() == addr.unwrap() + + +def test_wrap_address_maddr_str(): + ''' + `wrap_address()` accept a multiaddr-format string and + return the correct `Address` type. + + ''' + result = wrap_address('/ip4/127.0.0.1/tcp/9999') + + assert isinstance(result, TCPAddress) + assert result.unwrap() == ('127.0.0.1', 9999) diff --git a/tests/discovery/test_registrar.py b/tests/discovery/test_registrar.py index 0fbac8be..18f94c43 100644 --- a/tests/discovery/test_registrar.py +++ b/tests/discovery/test_registrar.py @@ -16,6 +16,8 @@ import subprocess import tractor from tractor.trionics import collapse_eg from tractor._testing import tractor_test +from tractor.discovery._addr import wrap_address +from tractor.discovery._multiaddr import mk_maddr import trio @@ -53,6 +55,49 @@ async def test_reg_then_unreg( assert not sockaddrs +@tractor_test +async def test_reg_then_unreg_maddr( + reg_addr: tuple, +): + ''' + Same as `test_reg_then_unreg` but pass the registry + address as a multiaddr string to verify `wrap_address()` + multiaddr parsing end-to-end through the runtime. + + ''' + # tuple -> Address -> multiaddr string + addr_obj = wrap_address(reg_addr) + maddr_str: str = str(mk_maddr(addr_obj)) + + actor = tractor.current_actor() + assert actor.is_registrar + + async with tractor.open_nursery( + registry_addrs=[maddr_str], + ) as n: + + portal = await n.start_actor( + 'actor_maddr', + enable_modules=[__name__], + ) + uid = portal.channel.aid.uid + + async with tractor.get_registry(maddr_str) as aportal: + assert actor is aportal.actor + + async with tractor.wait_for_actor('actor_maddr'): + assert uid in aportal.actor._registry + sockaddrs = actor._registry[uid] + assert sockaddrs + + await n.cancel() + + await trio.sleep(0.1) + assert uid not in aportal.actor._registry + sockaddrs = actor._registry.get(uid) + assert not sockaddrs + + the_line = 'Hi my name is {}' From 5f6e45e1d4b2cd6b90373eb189da6dd8581da6f2 Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 26 Mar 2026 21:56:33 -0400 Subject: [PATCH 10/31] Fix `mk_maddr()` crash on absolute UDS paths Strip leading `/` from `filepath` before building the `/unix/{path}` multiaddr string; OW absolute sockpaths like `/run/user/1000/tractor/foo.sock` produce `/unix//run/..` which `py-multiaddr` rejects as "empty protocol path". Woops, missed this in the initial `mk_maddr()` impl bc the unit tests only used relative `filedir` values (which was even noted in a comment..). The bug only surfaces when the `.maddr` property on `UDSTransport` is hit during logging/repr with real runtime addrs. Found-via: cross-suite `pytest tests/ipc/ tests/msg/` where `tpt_proto='uds'` leaks into msg tests (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/discovery/_multiaddr.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index 2a0ee7e6..d9046e03 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -78,8 +78,13 @@ def mk_maddr( case 'uds': filedir, filename = addr.unwrap() filepath = Path(filedir) / filename + # NOTE, strip any leading `/` to avoid + # double-slash `/unix//run/..` which the + # multiaddr parser rejects as "empty + # protocol path". + fpath_str: str = str(filepath).lstrip('/') return Multiaddr( - f'/{maddr_proto}/{filepath}' + f'/{maddr_proto}/{fpath_str}' ) From 490fac432c4cc119d49b9199f8334cd61d2378f7 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 27 Mar 2026 11:21:20 -0400 Subject: [PATCH 11/31] Preserve absolute UDS paths in `parse_maddr()` Drop the `.lstrip('/')` on the unix protocol value so the lib-prepended `/` restores the absolute-path semantics that `mk_maddr()` strips when encoding. Pass `Path` components (not `str`) to `UDSAddress`. Also, update all UDS test params to use absolute paths (`/tmp/tractor_test/...`, `/tmp/tractor_rt/...`) matching real runtime sockpath behavior; tighten `test_parse_maddr_uds` to assert exact `filedir`. Review: PR #429 (copilot-pull-request-reviewer[bot]) https://github.com/goodboy/tractor/pull/429#pullrequestreview-4018448152 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/discovery/test_multiaddr.py | 30 +++++++++++++++++------------- tractor/discovery/_multiaddr.py | 11 ++++++----- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/tests/discovery/test_multiaddr.py b/tests/discovery/test_multiaddr.py index 8329b033..83da84ea 100644 --- a/tests/discovery/test_multiaddr.py +++ b/tests/discovery/test_multiaddr.py @@ -74,10 +74,11 @@ def test_mk_maddr_uds(): multiaddr containing the full socket path. ''' - # NOTE, use a relative `filedir` since the multiaddr - # parser rejects the double-slash from absolute paths - # (i.e. `/unix//tmp/..` -> "empty protocol path"). - filedir = 'tractor_test' + # NOTE, use an absolute `filedir` to match real runtime + # UDS paths; `mk_maddr()` strips the leading `/` to avoid + # the double-slash `/unix//run/..` that py-multiaddr + # rejects as "empty protocol path". + filedir = '/tmp/tractor_test' filename = 'test_sock.sock' addr = UDSAddress( filedir=filedir, @@ -89,12 +90,14 @@ def test_mk_maddr_uds(): result_str: str = str(result) assert result_str.startswith('/unix/') + # verify the leading `/` was stripped to avoid double-slash + assert '/unix/tmp/tractor_test/' in result_str - sockpath: str = str(Path(filedir) / filename) - # NOTE, the multiaddr lib prepends a `/` to the - # unix protocol value when parsing back out. + sockpath_rel: str = str( + Path(filedir) / filename + ).lstrip('/') unix_val: str = result.value_for_protocol('unix') - assert unix_val.endswith(sockpath) + assert unix_val.endswith(sockpath_rel) def test_mk_maddr_unsupported_proto_key(): @@ -120,7 +123,7 @@ def test_mk_maddr_unsupported_proto_key(): ), pytest.param( UDSAddress( - filedir='tractor_rt', + filedir='/tmp/tractor_rt', filename='roundtrip.sock', ), id='uds', @@ -181,15 +184,16 @@ def test_parse_maddr_tcp_ipv6(): def test_parse_maddr_uds(): ''' `parse_maddr()` on a `/unix/...` multiaddr string - produce a `UDSAddress` with the correct dir and filename. + produce a `UDSAddress` with the correct dir and filename, + preserving absolute path semantics. ''' - result = parse_maddr('/unix/tractor_test/test.sock') + result = parse_maddr('/unix/tmp/tractor_test/test.sock') assert isinstance(result, UDSAddress) filedir, filename = result.unwrap() assert filename == 'test.sock' - assert 'tractor_test' in str(filedir) + assert str(filedir) == '/tmp/tractor_test' def test_parse_maddr_unsupported(): @@ -214,7 +218,7 @@ def test_parse_maddr_unsupported(): ), pytest.param( UDSAddress( - filedir='tractor_rt', + filedir='/tmp/tractor_rt', filename='roundtrip.sock', ), id='uds', diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index d9046e03..e310cf1c 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -114,13 +114,14 @@ def parse_maddr( case ['unix']: # NOTE, the multiaddr lib prepends a `/` to the - # unix protocol value; strip it to recover the - # original relative path. + # unix protocol value which effectively restores + # the absolute-path semantics that `mk_maddr()` + # strips when building the multiaddr string. raw: str = maddr.value_for_protocol('unix') - sockpath = Path(raw.lstrip('/')) + sockpath = Path(raw) return UDSAddress( - filedir=str(sockpath.parent), - filename=str(sockpath.name), + filedir=sockpath.parent, + filename=sockpath.name, ) case _: From f881683c976de2ef7ad13f83d94f7a38fcb062aa Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 13 Apr 2026 12:11:54 -0400 Subject: [PATCH 12/31] Tweak timeouts and rm `arbiter_addr` in tests Use `cpu_scaling_factor()` headroom in `test_peer_spawns_and_cancels_service_subactor`'s `fail_after` to avoid flaky timeouts on throttled CI runners. Rename `arbiter_addr=` -> `registry_addrs=[..]` throughout `test_spawning` and `test_task_broadcasting` suites to match the current `open_root_actor()` / `open_nursery()` API. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/test_inter_peer_cancellation.py | 16 +++++++++++++++- tests/test_spawning.py | 4 ++-- tests/test_task_broadcasting.py | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/test_inter_peer_cancellation.py b/tests/test_inter_peer_cancellation.py index 49854a99..b79c0393 100644 --- a/tests/test_inter_peer_cancellation.py +++ b/tests/test_inter_peer_cancellation.py @@ -24,6 +24,8 @@ from tractor._testing import ( expect_ctxc, ) +from .conftest import cpu_scaling_factor + # XXX TODO cases: # - [x] WE cancelled the peer and thus should not see any raised # `ContextCancelled` as it should be reaped silently? @@ -1030,6 +1032,7 @@ def test_peer_spawns_and_cancels_service_subactor( reg_addr: tuple[str, int], raise_sub_spawn_error_after: float|None, loglevel: str, + test_log: tractor.log.StackLevelAdapter, # ^XXX, set to 'warning' to see masked-exc warnings # that may transpire during actor-nursery teardown. ): @@ -1250,9 +1253,20 @@ def test_peer_spawns_and_cancels_service_subactor( # assert spawn_ctx.cancelled_caught + async def _main(): + headroom: float = cpu_scaling_factor() + this_fast_on_linux: float = 3 + this_fast = this_fast_on_linux * headroom + if headroom != 1.: + test_log.warning( + f'Adding latency headroom on linux bc CPU scaling,\n' + f'headroom: {headroom}\n' + f'this_fast_on_linux: {this_fast_on_linux} -> {this_fast}\n' + ) with trio.fail_after( - 3 if not debug_mode + this_fast + if not debug_mode else 999 ): await main() diff --git a/tests/test_spawning.py b/tests/test_spawning.py index 7e230085..7f3421fe 100644 --- a/tests/test_spawning.py +++ b/tests/test_spawning.py @@ -40,7 +40,7 @@ async def spawn( assert actor is None # no runtime yet async with ( tractor.open_root_actor( - arbiter_addr=reg_addr, + registry_addrs=[reg_addr], ), tractor.open_nursery() as an, ): @@ -203,7 +203,7 @@ def test_loglevel_propagated_to_subactor( async with tractor.open_nursery( name='registrar', start_method=start_method, - arbiter_addr=reg_addr, + registry_addrs=[reg_addr], ) as tn: await tn.run_in_actor( diff --git a/tests/test_task_broadcasting.py b/tests/test_task_broadcasting.py index b57d63f8..9ea251d2 100644 --- a/tests/test_task_broadcasting.py +++ b/tests/test_task_broadcasting.py @@ -75,7 +75,7 @@ async def open_sequence_streamer( ) -> tractor.MsgStream: async with tractor.open_nursery( - arbiter_addr=reg_addr, + registry_addrs=[reg_addr], start_method=start_method, ) as an: From bc60aa1ec588f72fd57730956d00a25d0be917d1 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 13 Apr 2026 13:33:09 -0400 Subject: [PATCH 13/31] Add `tpt_bind_addrs` param to `open_root_actor()` Allow callers to explicitly declare transport bind addrs instead of always auto-generating random ones from ponged registrar addresses. Deats, - new `tpt_bind_addrs` kwarg wraps each input addr via `wrap_address()` at init time. - non-registrar path only auto-generates random bind addrs when `tpt_bind_addrs` is empty. - registrar path merges user-provided bind addrs with `uw_reg_addrs` via `set()` union. - drop the deprecated `arbiter_addr` param and its `DeprecationWarning` shim entirely. Also, - expand `registry_addrs` type annotation to `Address|UnwrappedAddress`. - replace bare `assert accept_addrs` in `async_main()` with a descriptive `RuntimeError` msg. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/_root.py | 65 ++++++++++++++++++++----------------- tractor/runtime/_runtime.py | 10 +++++- 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/tractor/_root.py b/tractor/_root.py index 39a7880c..97bf7062 100644 --- a/tractor/_root.py +++ b/tractor/_root.py @@ -145,11 +145,16 @@ async def maybe_block_bp( @acm async def open_root_actor( *, - # defaults are above - registry_addrs: list[UnwrappedAddress]|None = None, + tpt_bind_addrs: list[ + Address # `Address.get_random()` case + |UnwrappedAddress # registrar case `= uw_reg_addrs` + ]|None = None, # defaults are above - arbiter_addr: tuple[UnwrappedAddress]|None = None, + registry_addrs: list[ + Address + |UnwrappedAddress + ]|None = None, enable_transports: list[ # TODO, this should eventually be the pairs as @@ -268,15 +273,7 @@ async def open_root_actor( if start_method is not None: _spawn.try_set_start_method(start_method) - if arbiter_addr is not None: - warnings.warn( - '`arbiter_addr` is now deprecated\n' - 'Use `registry_addrs: list[tuple]` instead..', - DeprecationWarning, - stacklevel=2, - ) - uw_reg_addrs = [arbiter_addr] - + # XXX expect pre-unwrapped registrar addrs. uw_reg_addrs = registry_addrs if not uw_reg_addrs: uw_reg_addrs: list[UnwrappedAddress] = default_lo_addrs( @@ -289,7 +286,6 @@ async def open_root_actor( wrap_address(uw_addr) for uw_addr in uw_reg_addrs ] - loglevel: str = ( loglevel or @@ -386,10 +382,14 @@ async def open_root_actor( addr, ) - tpt_bind_addrs: list[ - Address # `Address.get_random()` case - |UnwrappedAddress # registrar case `= uw_reg_addrs` - ] = [] + if tpt_bind_addrs is None: + tpt_bind_addrs: list[Address] = [] + else: + input_bind_addrs = list(tpt_bind_addrs) + tpt_bind_addrs: list[Address] = [] + for addr in input_bind_addrs: + addr: Address = wrap_address(addr) + tpt_bind_addrs.append(addr) # ------ NON-REGISTRAR ------ # create a new root-actor instance. @@ -417,19 +417,20 @@ async def open_root_actor( # a new NON-registrar, ROOT-actor. # # XXX INSTEAD, bind random addrs using the same tpt - # proto. - for addr in ponged_addrs: - tpt_bind_addrs.append( - # XXX, these are `Address` NOT `UnwrappedAddress`. - # - # NOTE, in the case of posix/berkley socket - # protos we allocate port=0 such that the system - # allocates a random value at bind time; this - # happens in the `.ipc.*` stack's backend. - addr.get_random( - bindspace=addr.bindspace, + # proto if not already provided. + if not tpt_bind_addrs: + for addr in ponged_addrs: + tpt_bind_addrs.append( + # XXX, these are `Address` NOT `UnwrappedAddress`. + # + # NOTE, in the case of posix/berkley socket + # protos we allocate port=0 such that the system + # allocates a random value at bind time; this + # happens in the `.ipc.*` stack's backend. + addr.get_random( + bindspace=addr.bindspace, + ) ) - ) # ------ REGISTRAR ------ # create a new "registry providing" root-actor instance. @@ -442,7 +443,11 @@ async def open_root_actor( # following init steps are taken: # - the tranport layer server is bound to each addr # pair defined in provided registry_addrs, or the default. - tpt_bind_addrs = uw_reg_addrs + tpt_bind_addrs = list(set( + tpt_bind_addrs + + + uw_reg_addrs + )) # - it is normally desirable for any registrar to stay up # indefinitely until either all registered (child/sub) diff --git a/tractor/runtime/_runtime.py b/tractor/runtime/_runtime.py index 074c339a..618d33da 100644 --- a/tractor/runtime/_runtime.py +++ b/tractor/runtime/_runtime.py @@ -1564,7 +1564,15 @@ async def async_main( addr: Address = transport_cls.get_random() accept_addrs.append(addr.unwrap()) - assert accept_addrs + # XXX, either passed in by caller or delivered + # in post spawn-spec handshake for subs. + if not accept_addrs: + RuntimeError( + f'No tpt bind addresses provided to actor!?\n' + f'parent_addr={parent_addr!r}\n' + f'accept_addrs={accept_addrs!r}\n' + f'enable_transports={enable_transports!r}\n' + ) ya_root_tn: bool = bool(actor._root_tn) ya_service_tn: bool = bool(actor._service_tn) From 7079a597c50fb134f5baf795347b68e025ffadb3 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 13 Apr 2026 16:26:36 -0400 Subject: [PATCH 14/31] 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-code --- .../20260413T192116Z_f851f28_prompt_io.md | 74 ++++ .../prompts/multiaddr_declare_eps.md_ | 76 ++++ tests/discovery/test_tpt_bind_addrs.py | 337 ++++++++++++++++++ tractor/_root.py | 2 +- 4 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 ai/prompt-io/claude/20260413T192116Z_f851f28_prompt_io.md create mode 100644 ai/prompt-io/prompts/multiaddr_declare_eps.md_ create mode 100644 tests/discovery/test_tpt_bind_addrs.py 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 From e90241baaa637c4799d62a183519ed8ec0260f6b Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 13 Apr 2026 17:36:21 -0400 Subject: [PATCH 15/31] Add `parse_endpoints()` to `_multiaddr` Provide a service-table parsing API for downstream projects (like `piker`) to declare per-actor transport bind addresses as a config map of actor-name -> multiaddr strings (e.g. from a TOML `[network]` section). Deats, - `EndpointsTable` type alias: input `dict[str, list[str|tuple]]`. - `ParsedEndpoints` type alias: output `dict[str, list[Address]]`. - `parse_endpoints()` iterates the table and delegates each entry to the existing `tractor.discovery._discovery.wrap_address()` helper, which handles maddr strings, raw `(host, port)` tuples, and pre-wrapped `Address` objs. - UDS maddrs use the multiaddr spec name `/unix/...` (not tractor's internal `/uds/` proto_key) Also add new tests, - 7 new pure unit tests (no trio runtime): TCP-only, mixed tpts, unwrapped tuples, mixed str+tuple, unsupported proto (`/udp/`), empty table, empty actor list - all 22 multiaddr tests pass rn. Prompt-IO: ai/prompt-io/claude/20260413T205048Z_269d939c_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 --- .../20260413T205048Z_269d939c_prompt_io.md | 50 +++++ ...20260413T205048Z_269d939c_prompt_io.raw.md | 207 ++++++++++++++++++ tests/discovery/test_multiaddr.py | 125 +++++++++++ tractor/discovery/_multiaddr.py | 58 +++++ 4 files changed, 440 insertions(+) create mode 100644 ai/prompt-io/claude/20260413T205048Z_269d939c_prompt_io.md create mode 100644 ai/prompt-io/claude/20260413T205048Z_269d939c_prompt_io.raw.md diff --git a/ai/prompt-io/claude/20260413T205048Z_269d939c_prompt_io.md b/ai/prompt-io/claude/20260413T205048Z_269d939c_prompt_io.md new file mode 100644 index 00000000..f6ec66d0 --- /dev/null +++ b/ai/prompt-io/claude/20260413T205048Z_269d939c_prompt_io.md @@ -0,0 +1,50 @@ +--- +model: claude-opus-4-6 +service: claude +session: 76154e65-d8e1-4b5f-9275-0ea45ba7e98a +timestamp: 2026-04-13T20:50:48Z +git_ref: 269d939c +scope: code +substantive: true +raw_file: 20260413T205048Z_269d939c_prompt_io.raw.md +--- + +## Prompt + +Implement a `parse_endpoints()` API in +`tractor.discovery._multiaddr` that lets downstream +projects declare per-actor transport bind addresses +as a service table of actor-name -> multiaddr +strings (loaded from config, e.g. TOML `[network]` +section). Add type aliases `EndpointsTable` and +`ParsedEndpoints`, plus 7 unit tests covering TCP, +UDS, tuples, mixed input, unsupported protocols, and +edge cases. + +A detailed implementation plan was provided +specifying insertion points, type signatures, test +names, and verification commands. + +## Response summary + +Add `parse_endpoints()` function with two supporting +type aliases to `_multiaddr.py`. The function +iterates a `dict[str, list[str|tuple]]` service +table and delegates each entry to the existing +`wrap_address()` helper, which already handles maddr +strings, raw tuples, and pre-wrapped `Address` +objects. Seven pure unit tests (no trio runtime) +validate all input variants and edge cases. All 22 +multiaddr tests pass. + +## Files changed + +- `tractor/discovery/_multiaddr.py` — add + `EndpointsTable`, `ParsedEndpoints` type aliases + and `parse_endpoints()` function after line 133 +- `tests/discovery/test_multiaddr.py` — add + `parse_endpoints` import and 7 new test functions + +## Human edits + +None — committed as generated. diff --git a/ai/prompt-io/claude/20260413T205048Z_269d939c_prompt_io.raw.md b/ai/prompt-io/claude/20260413T205048Z_269d939c_prompt_io.raw.md new file mode 100644 index 00000000..71aff348 --- /dev/null +++ b/ai/prompt-io/claude/20260413T205048Z_269d939c_prompt_io.raw.md @@ -0,0 +1,207 @@ +--- +model: claude-opus-4-6 +service: claude +timestamp: 2026-04-13T20:50:48Z +git_ref: 269d939c +--- + +## Implementation output + +### `tractor/discovery/_multiaddr.py` — appended after line 133 + +```python +# type aliases for service-endpoint config tables +# +# input table: actor/service name -> list of maddr strings +# or raw unwrapped-address tuples (as accepted by +# `wrap_address()`). +EndpointsTable = dict[ + str, # actor/service name + list[str|tuple], # maddr strs or UnwrappedAddress +] + +# output table: actor/service name -> list of wrapped +# `Address` instances ready for transport binding. +ParsedEndpoints = dict[ + str, # actor/service name + list['Address'], +] + + +def parse_endpoints( + service_table: EndpointsTable, +) -> ParsedEndpoints: + ''' + Parse a service-endpoint config table into wrapped + `Address` instances suitable for transport binding. + + Each key is an actor/service name and each value is + a list of addresses in any format accepted by + `wrap_address()`: + + - multiaddr strings: ``'/ip4/127.0.0.1/tcp/1616'`` + - UDS multiaddr strings using the **multiaddr spec + name** ``/unix/...`` (NOT the tractor-internal + ``/uds/`` proto_key) + - raw unwrapped tuples: ``('127.0.0.1', 1616)`` + - pre-wrapped `Address` objects (passed through) + + Returns a new `dict` with the same keys, where each + value list contains the corresponding `Address` + instances. + + Raises `ValueError` for unsupported multiaddr + protocols (e.g. ``/udp/``). + + ''' + from tractor.discovery._addr import wrap_address + + parsed: ParsedEndpoints = {} + for ( + actor_name, + addr_entries, + ) in service_table.items(): + parsed[actor_name] = [ + wrap_address(entry) + for entry in addr_entries + ] + return parsed +``` + +### `tests/discovery/test_multiaddr.py` — appended after line 252 + +Added import of `parse_endpoints` to existing import block, then +7 new test functions: + +```python +# ------ parse_endpoints() tests ------ + +def test_parse_endpoints_tcp_only(): + ''' + `parse_endpoints()` with a single TCP maddr per actor + produce the correct `TCPAddress` instances. + + ''' + table = { + 'registry': ['/ip4/127.0.0.1/tcp/1616'], + 'data_feed': ['/ip4/0.0.0.0/tcp/5555'], + } + result = parse_endpoints(table) + + assert set(result.keys()) == {'registry', 'data_feed'} + + reg_addr = result['registry'][0] + assert isinstance(reg_addr, TCPAddress) + assert reg_addr.unwrap() == ('127.0.0.1', 1616) + + feed_addr = result['data_feed'][0] + assert isinstance(feed_addr, TCPAddress) + assert feed_addr.unwrap() == ('0.0.0.0', 5555) + + +def test_parse_endpoints_mixed_tpts(): + ''' + `parse_endpoints()` with both TCP and UDS maddrs for + the same actor produce the correct mixed `Address` list. + + ''' + table = { + 'broker': [ + '/ip4/127.0.0.1/tcp/4040', + '/unix/tmp/tractor/broker.sock', + ], + } + result = parse_endpoints(table) + addrs = result['broker'] + + assert len(addrs) == 2 + assert isinstance(addrs[0], TCPAddress) + assert addrs[0].unwrap() == ('127.0.0.1', 4040) + + assert isinstance(addrs[1], UDSAddress) + filedir, filename = addrs[1].unwrap() + assert filename == 'broker.sock' + assert str(filedir) == '/tmp/tractor' + + +def test_parse_endpoints_unwrapped_tuples(): + ''' + `parse_endpoints()` accept raw `(host, port)` tuples + and wrap them as `TCPAddress`. + + ''' + table = { + 'ems': [('127.0.0.1', 6666)], + } + result = parse_endpoints(table) + + addr = result['ems'][0] + assert isinstance(addr, TCPAddress) + assert addr.unwrap() == ('127.0.0.1', 6666) + + +def test_parse_endpoints_mixed_str_and_tuple(): + ''' + `parse_endpoints()` accept a mix of maddr strings and + raw tuples in the same actor entry list. + + ''' + table = { + 'quoter': [ + '/ip4/127.0.0.1/tcp/7777', + ('127.0.0.1', 8888), + ], + } + result = parse_endpoints(table) + addrs = result['quoter'] + + assert len(addrs) == 2 + assert isinstance(addrs[0], TCPAddress) + assert addrs[0].unwrap() == ('127.0.0.1', 7777) + + assert isinstance(addrs[1], TCPAddress) + assert addrs[1].unwrap() == ('127.0.0.1', 8888) + + +def test_parse_endpoints_unsupported_proto(): + ''' + `parse_endpoints()` raise `ValueError` when a maddr + string uses an unsupported protocol like `/udp/`. + + ''' + table = { + 'bad_actor': ['/ip4/127.0.0.1/udp/9999'], + } + with pytest.raises( + ValueError, + match='Unsupported multiaddr protocol combo', + ): + parse_endpoints(table) + + +def test_parse_endpoints_empty_table(): + ''' + `parse_endpoints()` on an empty table return an empty + dict. + + ''' + assert parse_endpoints({}) == {} + + +def test_parse_endpoints_empty_actor_list(): + ''' + `parse_endpoints()` with an actor mapped to an empty + list preserve the key with an empty list value. + + ''' + result = parse_endpoints({'x': []}) + assert result == {'x': []} +``` + +### Test run output + +``` +22 passed, 1 warning in 0.05s +``` + +All 22 tests pass (15 existing + 7 new). diff --git a/tests/discovery/test_multiaddr.py b/tests/discovery/test_multiaddr.py index 83da84ea..9b1520d6 100644 --- a/tests/discovery/test_multiaddr.py +++ b/tests/discovery/test_multiaddr.py @@ -15,6 +15,7 @@ from tractor.ipc._uds import UDSAddress from tractor.discovery._multiaddr import ( mk_maddr, parse_maddr, + parse_endpoints, _tpt_proto_to_maddr, _maddr_to_tpt_proto, ) @@ -249,3 +250,127 @@ def test_wrap_address_maddr_str(): assert isinstance(result, TCPAddress) assert result.unwrap() == ('127.0.0.1', 9999) + + +# ------ parse_endpoints() tests ------ + +def test_parse_endpoints_tcp_only(): + ''' + `parse_endpoints()` with a single TCP maddr per actor + produce the correct `TCPAddress` instances. + + ''' + table = { + 'registry': ['/ip4/127.0.0.1/tcp/1616'], + 'data_feed': ['/ip4/0.0.0.0/tcp/5555'], + } + result = parse_endpoints(table) + + assert set(result.keys()) == {'registry', 'data_feed'} + + reg_addr = result['registry'][0] + assert isinstance(reg_addr, TCPAddress) + assert reg_addr.unwrap() == ('127.0.0.1', 1616) + + feed_addr = result['data_feed'][0] + assert isinstance(feed_addr, TCPAddress) + assert feed_addr.unwrap() == ('0.0.0.0', 5555) + + +def test_parse_endpoints_mixed_tpts(): + ''' + `parse_endpoints()` with both TCP and UDS maddrs for + the same actor produce the correct mixed `Address` list. + + ''' + table = { + 'broker': [ + '/ip4/127.0.0.1/tcp/4040', + '/unix/tmp/tractor/broker.sock', + ], + } + result = parse_endpoints(table) + addrs = result['broker'] + + assert len(addrs) == 2 + assert isinstance(addrs[0], TCPAddress) + assert addrs[0].unwrap() == ('127.0.0.1', 4040) + + assert isinstance(addrs[1], UDSAddress) + filedir, filename = addrs[1].unwrap() + assert filename == 'broker.sock' + assert str(filedir) == '/tmp/tractor' + + +def test_parse_endpoints_unwrapped_tuples(): + ''' + `parse_endpoints()` accept raw `(host, port)` tuples + and wrap them as `TCPAddress`. + + ''' + table = { + 'ems': [('127.0.0.1', 6666)], + } + result = parse_endpoints(table) + + addr = result['ems'][0] + assert isinstance(addr, TCPAddress) + assert addr.unwrap() == ('127.0.0.1', 6666) + + +def test_parse_endpoints_mixed_str_and_tuple(): + ''' + `parse_endpoints()` accept a mix of maddr strings and + raw tuples in the same actor entry list. + + ''' + table = { + 'quoter': [ + '/ip4/127.0.0.1/tcp/7777', + ('127.0.0.1', 8888), + ], + } + result = parse_endpoints(table) + addrs = result['quoter'] + + assert len(addrs) == 2 + assert isinstance(addrs[0], TCPAddress) + assert addrs[0].unwrap() == ('127.0.0.1', 7777) + + assert isinstance(addrs[1], TCPAddress) + assert addrs[1].unwrap() == ('127.0.0.1', 8888) + + +def test_parse_endpoints_unsupported_proto(): + ''' + `parse_endpoints()` raise `ValueError` when a maddr + string uses an unsupported protocol like `/udp/`. + + ''' + table = { + 'bad_actor': ['/ip4/127.0.0.1/udp/9999'], + } + with pytest.raises( + ValueError, + match='Unsupported multiaddr protocol combo', + ): + parse_endpoints(table) + + +def test_parse_endpoints_empty_table(): + ''' + `parse_endpoints()` on an empty table return an empty + dict. + + ''' + assert parse_endpoints({}) == {} + + +def test_parse_endpoints_empty_actor_list(): + ''' + `parse_endpoints()` with an actor mapped to an empty + list preserve the key with an empty list value. + + ''' + result = parse_endpoints({'x': []}) + assert result == {'x': []} diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index e310cf1c..da6c17d1 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -130,3 +130,61 @@ def parse_maddr( f'{proto_names!r}\n' f'from maddr: {maddr_str!r}\n' ) + + +# type aliases for service-endpoint config tables +# +# input table: actor/service name -> list of maddr strings +# or raw unwrapped-address tuples (as accepted by +# `wrap_address()`). +EndpointsTable = dict[ + str, # actor/service name + list[str|tuple], # maddr strs or UnwrappedAddress +] + +# output table: actor/service name -> list of wrapped +# `Address` instances ready for transport binding. +ParsedEndpoints = dict[ + str, # actor/service name + list['Address'], +] + + +def parse_endpoints( + service_table: EndpointsTable, +) -> ParsedEndpoints: + ''' + Parse a service-endpoint config table into wrapped + `Address` instances suitable for transport binding. + + Each key is an actor/service name and each value is + a list of addresses in any format accepted by + `wrap_address()`: + + - multiaddr strings: ``'/ip4/127.0.0.1/tcp/1616'`` + - UDS multiaddr strings using the **multiaddr spec + name** ``/unix/...`` (NOT the tractor-internal + ``/uds/`` proto_key) + - raw unwrapped tuples: ``('127.0.0.1', 1616)`` + - pre-wrapped `Address` objects (passed through) + + Returns a new `dict` with the same keys, where each + value list contains the corresponding `Address` + instances. + + Raises `ValueError` for unsupported multiaddr + protocols (e.g. ``/udp/``). + + ''' + from tractor.discovery._addr import wrap_address + + parsed: ParsedEndpoints = {} + for ( + actor_name, + addr_entries, + ) in service_table.items(): + parsed[actor_name] = [ + wrap_address(entry) + for entry in addr_entries + ] + return parsed From a891e003b235c8df21641e9e07cac9a41477c33e Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 13 Apr 2026 18:33:39 -0400 Subject: [PATCH 16/31] Expose `_multiaddr` API from `tractor.discovery` Re-export `parse_endpoints`, `parse_maddr`, and `mk_maddr` in `discovery.__init__` so downstream (piker) can import directly from the pkg ns. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/discovery/__init__.py | 5 +++++ tractor/discovery/_multiaddr.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index d87a9cc1..13e10b0a 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -24,3 +24,8 @@ module paths like ``tractor.discovery._addr`` or ``tractor.discovery._discovery`` instead. ''' +from ._multiaddr import ( + parse_endpoints as parse_endpoints, + parse_maddr as parse_maddr, + mk_maddr as mk_maddr, +) diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index da6c17d1..74076fd4 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -44,7 +44,7 @@ _tpt_proto_to_maddr: dict[str, str] = { _maddr_to_tpt_proto: dict[str, str] = { v: k for k, v in _tpt_proto_to_maddr.items() } -# {'tcp': 'tcp', 'unix': 'uds'} +# -> {'tcp': 'tcp', 'unix': 'uds'} def mk_maddr( From 06ff2dd5f2e4dfcf02e33f84d6065b231032ecc7 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 13 Apr 2026 18:35:04 -0400 Subject: [PATCH 17/31] Permit the `prompt-io` skill by default --- .claude/settings.local.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fff0dd0c..abb6bc30 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -27,7 +27,8 @@ "Write(.claude/git_commit_msg_LATEST.md)", "Skill(run-tests)", "Skill(close-wkt)", - "Skill(open-wkt)" + "Skill(open-wkt)", + "Skill(prompt-io)" ], "deny": [], "ask": [] From 23677f8a3ceb34a9641e78cd325e8316abc2c02f Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 13 Apr 2026 22:47:15 -0400 Subject: [PATCH 18/31] Use distinct startup report for registrar vs client Set `header` to "Contacting existing registry" for non-registrar actors and "Opening new registry" for registrars, so the boot log reflects the actual role. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/_root.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tractor/_root.py b/tractor/_root.py index 02757c32..9b58523d 100644 --- a/tractor/_root.py +++ b/tractor/_root.py @@ -432,6 +432,8 @@ async def open_root_actor( ) ) + header: str = '-> Contacting existing registry @ ' + # ------ REGISTRAR ------ # create a new "registry providing" root-actor instance. # @@ -469,6 +471,7 @@ async def open_root_actor( # `tractor.to_asyncio.run_as_asyncio_guest()` and NOT # `.trio.run()`. actor._infected_aio = _state._runtime_vars['_is_infected_aio'] + header: str = '-> Opening new registry @ ' # Start up main task set via core actor-runtime nurseries. try: @@ -480,7 +483,7 @@ async def open_root_actor( report: str = f'Starting actor-runtime for {actor.aid.reprol()!r}\n' if reg_addrs := actor.registry_addrs: report += ( - '-> Opening new registry @ ' + header + '\n'.join( f'{addr}' for addr in reg_addrs From cb7b76c44f3991a5f32721d05c98f19806ff105e Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 14 Apr 2026 12:05:45 -0400 Subject: [PATCH 19/31] Use multi-addr `dict` registry, drop `bidict` Replace `Registrar._registry: bidict[uid, addr]` with `dict[uid, list[UnwrappedAddress]]` to support actors binding on multiple transports simultaneously (multi-homed). Deats, - `find_actor_addr()` returns first addr from the uid's list - `get_registry()` now returns per-uid addr lists - `find_actor_addrs()` uses `.extend()` to collect all addrs for a given actor name - `register_actor_addr()` appends to the uid's list (dedup'd) and evicts stale entries where a different uid claims the same addr - `delete_actor_addr()` does a linear scan + `.remove()` instead of `bidict.inverse.pop()`; deletes the uid entry entirely when no addrs remain (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/discovery/_registry.py | 71 ++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/tractor/discovery/_registry.py b/tractor/discovery/_registry.py index cb8fc157..8d29f2be 100644 --- a/tractor/discovery/_registry.py +++ b/tractor/discovery/_registry.py @@ -27,7 +27,6 @@ name-to-address mappings so peers can discover each other. ''' from __future__ import annotations -from bidict import bidict import trio from ..runtime._runtime import Actor @@ -83,10 +82,10 @@ class Registrar(Actor): **kwargs, ) -> None: - self._registry: bidict[ + self._registry: dict[ tuple[str, str], - UnwrappedAddress, - ] = bidict({}) + list[UnwrappedAddress], + ] = {} self._waiters: dict[ str, # either an event to sync to receiving an @@ -104,16 +103,15 @@ class Registrar(Actor): ) -> UnwrappedAddress|None: - for uid, addr in self._registry.items(): + for uid, addrs in self._registry.items(): if name in uid: - return addr + return addrs[0] if addrs else None return None async def get_registry( self - - ) -> dict[str, UnwrappedAddress]: + ) -> dict[str, list[UnwrappedAddress]]: ''' Return current name registry. @@ -144,18 +142,17 @@ class Registrar(Actor): ''' addrs: list[UnwrappedAddress] = [] - addr: UnwrappedAddress mailbox_info: str = ( 'Actor registry contact infos:\n' ) - for uid, addr in self._registry.items(): + for uid, uid_addrs in self._registry.items(): mailbox_info += ( f'|_uid: {uid}\n' - f'|_addr: {addr}\n\n' + f'|_addrs: {uid_addrs}\n\n' ) if name == uid[0]: - addrs.append(addr) + addrs.extend(uid_addrs) if not addrs: waiter = trio.Event() @@ -166,7 +163,7 @@ class Registrar(Actor): for uid in self._waiters[name]: if not isinstance(uid, trio.Event): - addrs.append( + addrs.extend( self._registry[uid] ) @@ -187,13 +184,24 @@ class Registrar(Actor): # should never be 0-dynamic-os-alloc await debug.pause() - # XXX NOTE, value must also be hashable AND since - # `._registry` is a `bidict` values must be unique; - # use `.forceput()` to replace any prior (stale) - # entries that might map a different uid to the same - # addr (e.g. after an unclean shutdown or - # actor-restart reusing the same address). - self._registry.forceput(uid, tuple(addr)) + addr_tup: tuple = tuple(addr) + + # Evict stale entries: if a *different* uid claims + # this addr (e.g. after unclean shutdown or + # actor-restart reusing the same address), remove + # it from the old uid's addr list. + for other_uid, other_addrs in self._registry.items(): + if ( + other_uid != uid + and addr_tup in other_addrs + ): + other_addrs.remove(addr_tup) + break + + # Append to this uid's addr list (avoid duplicates) + entry: list = self._registry.setdefault(uid, []) + if addr_tup not in entry: + entry.append(addr_tup) # pop and signal all waiter events events = self._waiters.pop(name, []) @@ -210,7 +218,7 @@ class Registrar(Actor): ) -> None: uid = (str(uid[0]), str(uid[1])) - entry: tuple = self._registry.pop( + entry: list|None = self._registry.pop( uid, None ) if entry is None: @@ -225,13 +233,20 @@ class Registrar(Actor): ) -> tuple[str, str]|None: # NOTE: `addr` arrives as a `list` over IPC # (msgpack deserializes tuples -> lists) so - # coerce to `tuple` for the bidict hash lookup. - uid: tuple[str, str]|None = ( - self._registry.inverse.pop( - tuple(addr), - None, - ) - ) + # coerce to `tuple` for the linear scan. + addr = tuple(addr) + uid: tuple[str, str]|None = None + + for _uid, addrs in self._registry.items(): + if addr in addrs: + addrs.remove(addr) + uid = _uid + # remove the uid entry entirely when it + # has no remaining addrs. + if not addrs: + del self._registry[_uid] + break + if uid: report: str = ( 'Deleting registry-entry for,\n' From c3d6cc90075827903e53b940d62c896e1cb234dc Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 14 Apr 2026 12:27:04 -0400 Subject: [PATCH 20/31] Rename `discovery._discovery` to `._api` Adjust all imports to match. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/discovery/test_registrar.py | 2 +- tests/test_multi_program.py | 2 +- tractor/__init__.py | 2 +- tractor/devx/debug/_tty_lock.py | 2 +- tractor/discovery/__init__.py | 2 +- tractor/discovery/{_discovery.py => _api.py} | 0 tractor/ipc/_server.py | 2 +- tractor/runtime/_runtime.py | 2 +- 8 files changed, 7 insertions(+), 7 deletions(-) rename tractor/discovery/{_discovery.py => _api.py} (100%) diff --git a/tests/discovery/test_registrar.py b/tests/discovery/test_registrar.py index 18f94c43..60b2b10c 100644 --- a/tests/discovery/test_registrar.py +++ b/tests/discovery/test_registrar.py @@ -552,7 +552,7 @@ def test_stale_entry_is_deleted( registry_addrs=[reg_addr], ) as maybe_portal: # because the transitive - # `._discovery.maybe_open_portal()` call should + # `._api.maybe_open_portal()` call should # fail and implicitly call `.delete_addr()` assert maybe_portal is None registry: dict = await unpack_reg(_reg_ptl) diff --git a/tests/test_multi_program.py b/tests/test_multi_program.py index 100f01c6..5894ee70 100644 --- a/tests/test_multi_program.py +++ b/tests/test_multi_program.py @@ -122,7 +122,7 @@ async def get_root_portal( # connect back to our immediate parent which should also # be the actor-tree's root. - from tractor.discovery._discovery import get_root + from tractor.discovery._api import get_root ptl: Portal async with get_root() as ptl: root_aid: Aid = ptl.chan.aid diff --git a/tractor/__init__.py b/tractor/__init__.py index 1aafe98e..287ac940 100644 --- a/tractor/__init__.py +++ b/tractor/__init__.py @@ -30,7 +30,7 @@ from ._streaming import ( MsgStream as MsgStream, stream as stream, ) -from .discovery._discovery import ( +from .discovery._api import ( get_registry as get_registry, find_actor as find_actor, wait_for_actor as wait_for_actor, diff --git a/tractor/devx/debug/_tty_lock.py b/tractor/devx/debug/_tty_lock.py index 9f7b71e7..3d2be681 100644 --- a/tractor/devx/debug/_tty_lock.py +++ b/tractor/devx/debug/_tty_lock.py @@ -1013,7 +1013,7 @@ async def request_root_stdio_lock( DebugStatus.req_task = current_task() req_err: BaseException|None = None try: - from tractor.discovery._discovery import get_root + from tractor.discovery._api import get_root # NOTE: we need this to ensure that this task exits # BEFORE the REPl instance raises an error like # `bdb.BdbQuit` directly, OW you get a trio cs stack diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index 13e10b0a..93c19e62 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -21,7 +21,7 @@ and location management of (service) actors. NOTE: to avoid circular imports, this ``__init__`` does NOT eagerly import submodules. Use direct module paths like ``tractor.discovery._addr`` or -``tractor.discovery._discovery`` instead. +``tractor.discovery._api`` instead. ''' from ._multiaddr import ( diff --git a/tractor/discovery/_discovery.py b/tractor/discovery/_api.py similarity index 100% rename from tractor/discovery/_discovery.py rename to tractor/discovery/_api.py diff --git a/tractor/ipc/_server.py b/tractor/ipc/_server.py index 6cfbf474..3fd965c5 100644 --- a/tractor/ipc/_server.py +++ b/tractor/ipc/_server.py @@ -274,7 +274,7 @@ async def maybe_wait_on_canced_subs( # ephemeral `.register_actor()` request! # -[ ] also, that should be avoidable by # re-using any existing chan from the - # `._discovery.get_registry()` call as + # `._api.get_registry()` call as # well.. log.runtime( f'Peer IPC broke but subproc is alive?\n\n' diff --git a/tractor/runtime/_runtime.py b/tractor/runtime/_runtime.py index 618d33da..210d489c 100644 --- a/tractor/runtime/_runtime.py +++ b/tractor/runtime/_runtime.py @@ -115,7 +115,7 @@ from ..devx import ( debug, pformat as _pformat ) -from ..discovery._discovery import get_registry +from ..discovery._api import get_registry from ._portal import Portal from . import _state from ..spawn import _mp_fixup_main From ccb013a6153253648542f64f82fca851fd8e69cb Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 14 Apr 2026 12:57:13 -0400 Subject: [PATCH 21/31] 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 --- .../20260414T163300Z_befedc49_prompt_io.md | 38 ++++++ ...20260414T163300Z_befedc49_prompt_io.raw.md | 62 ++++++++++ tractor/discovery/_api.py | 116 ++++++++++++++++-- tractor/discovery/_registry.py | 4 +- 4 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 ai/prompt-io/claude/20260414T163300Z_befedc49_prompt_io.md create mode 100644 ai/prompt-io/claude/20260414T163300Z_befedc49_prompt_io.raw.md 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 From 86d4e0d3edf82ab4693c14fcc921a85daa529f83 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 14 Apr 2026 13:57:01 -0400 Subject: [PATCH 22/31] Harden `sig_prog()` retries, adjust debugger test timeouts Retry signal delivery in `sig_prog()` up to `tries` times (default 3) w/ `canc_timeout` sleep between attempts; only fall back to `_KILL_SIGNAL` after all retries exhaust. Bump default timeout 0.1 -> 0.2. Also, - `test_multi_nested_subactors_error_through_nurseries` gives the first prompt iteration a 5s timeout even on linux bc the initial crash sequence can be slow to arrive at a `pdb` prompt (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/conftest.py | 37 +++++++++++++++++++++++++++++++------ tests/devx/test_debugger.py | 27 +++++++++++++++++++-------- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 54efb945..1fad2255 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -199,15 +199,40 @@ def ci_env() -> bool: def sig_prog( proc: subprocess.Popen, sig: int, - canc_timeout: float = 0.1, + canc_timeout: float = 0.2, + tries: int = 3, ) -> int: - "Kill the actor-process with ``sig``." - proc.send_signal(sig) - time.sleep(canc_timeout) - if not proc.poll(): + ''' + Kill the actor-process with `sig`. + + Prefer to kill with the provided signal and + failing a `canc_timeout`, send a `SIKILL`-like + to ensure termination. + + ''' + for i in range(tries): + proc.send_signal(sig) + if not proc.poll(): + print( + f'WARNING, proc still alive after,\n' + f'canc_timeout={canc_timeout!r}\n' + f'sig={sig!r}\n' + f'\n' + f'{proc.args!r}\n' + ) + time.sleep(canc_timeout) + else: # TODO: why sometimes does SIGINT not work on teardown? # seems to happen only when trace logging enabled? - proc.send_signal(_KILL_SIGNAL) + if not proc.poll(): + breakpoint() + print( + f'XXX WARNING KILLING PROG WITH SIGINT XXX\n' + f'canc_timeout={canc_timeout!r}\n' + f'{proc.args!r}\n' + ) + proc.send_signal(_KILL_SIGNAL) + ret: int = proc.wait() assert ret diff --git a/tests/devx/test_debugger.py b/tests/devx/test_debugger.py index e6e40b51..d5fd759b 100644 --- a/tests/devx/test_debugger.py +++ b/tests/devx/test_debugger.py @@ -785,17 +785,28 @@ def test_multi_nested_subactors_error_through_nurseries( # timed_out_early: bool = False - for send_char in itertools.cycle(['c', 'q']): + for ( + i, + send_char, + ) in enumerate(itertools.cycle(['c', 'q'])): + + timeout: float = -1 + if ( + _non_linux + and + ci_env + ): + timeout: float = 6 + + # XXX linux but the first crash sequence + # can take longer to arrive at a prompt. + elif i == 0: + timeout = 5 + try: child.expect( PROMPT, - timeout=( - 6 if ( - _non_linux - and - ci_env - ) else -1 - ), + timeout=timeout, ) child.sendline(send_char) time.sleep(0.01) From 75b07c4b7c4bf90043e2960f02f4c003be34938c Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 14 Apr 2026 14:25:39 -0400 Subject: [PATCH 23/31] Show trailing bindspace-path-div in `repr(UDSAddress)` --- tractor/ipc/_uds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tractor/ipc/_uds.py b/tractor/ipc/_uds.py index 51f21353..45100f97 100644 --- a/tractor/ipc/_uds.py +++ b/tractor/ipc/_uds.py @@ -232,7 +232,7 @@ class UDSAddress( pid: str = '' body: str = ( - f'({self.filedir}, {self.filename}, {pid})' + f'({self.filedir}/, {self.filename}, {pid})' ) return ( f'{type(self).__name__}' From 7b04b2cdfcf592f5eea3c13f78584a5256efeea2 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 14 Apr 2026 15:08:22 -0400 Subject: [PATCH 24/31] Reuse `_parent_chan` to unregister from parent-registrar When the parent actor IS the registrar, reuse the existing parent channel for `unregister_actor` RPC instead of opening a new connection via `get_registry()`. This avoids failures when the registrar's listener socket is already closed during teardown (e.g. UDS transport unlinks the socket file rapidly). Deats, - detect `parent_is_reg` by comparing `_parent_chan.raddr` against `reg_addrs` and if matched, create a `Portal(rent_chan)` directly instead of `async with get_registry()`. - rename `failed` -> `failed_unreg` for clarity. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/runtime/_runtime.py | 50 +++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/tractor/runtime/_runtime.py b/tractor/runtime/_runtime.py index 210d489c..7cf20ec2 100644 --- a/tractor/runtime/_runtime.py +++ b/tractor/runtime/_runtime.py @@ -1845,27 +1845,61 @@ async def async_main( and not actor.is_registrar ): - failed: bool = False + failed_unreg: bool = False + rent_chan: Channel|None = actor._parent_chan + + # XXX check if the parent IS the registrar so we can + # reuse the existing channel (avoids opening a new + # connection which fails when the listener socket is + # already closed, e.g. UDS transport unlinks the socket + # file during teardown). + parent_is_reg: bool = False + if ( + rent_chan is not None + and + rent_chan.connected() + ): + pchan_raddr: Address|None = rent_chan.raddr + if pchan_raddr is not None: + reg_addr: Address + for reg_addr in actor.reg_addrs: + if ( + pchan_raddr.unwrap() + == + tuple(reg_addr) + ): + parent_is_reg = True + break + for addr in actor.reg_addrs: waddr = wrap_address(addr) assert waddr.is_valid with trio.move_on_after(0.5) as cs: cs.shield = True try: - async with get_registry( - addr, - ) as reg_portal: + if parent_is_reg: + reg_portal = Portal(rent_chan) await reg_portal.run_from_ns( 'self', 'unregister_actor', uid=actor.aid.uid, ) + else: + async with get_registry( + addr, + ) as reg_portal: + await reg_portal.run_from_ns( + 'self', + 'unregister_actor', + uid=actor.aid.uid, + ) except OSError: - failed = True - if cs.cancelled_caught: - failed = True + failed_unreg = True - if failed: + if cs.cancelled_caught: + failed_unreg = True + + if failed_unreg: teardown_report += ( f'-> Failed to unregister {actor.name} from ' f'registar @ {addr}\n' From cd287c7e93380558ab67e9b516ff7533e17893b0 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 14 Apr 2026 15:16:57 -0400 Subject: [PATCH 25/31] Fix `test_registrar_merge_binds_union` for UDS collision `get_random()` can produce the same UDS filename for a given pid+actor-state, so the "disjoint addrs" premise doesn't always hold. Gate the `len(bound) >= 2` assertion on whether the registry and bind addrs actually differ via `expect_disjoint`. Also, - drop unused `partial` import (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/discovery/test_tpt_bind_addrs.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/discovery/test_tpt_bind_addrs.py b/tests/discovery/test_tpt_bind_addrs.py index 61baecda..ae3b3437 100644 --- a/tests/discovery/test_tpt_bind_addrs.py +++ b/tests/discovery/test_tpt_bind_addrs.py @@ -9,8 +9,6 @@ bind-address selection in `_root.py`: 3. Explicit bind given -> wraps via `wrap_address()` and uses them ''' -from functools import partial - import pytest import trio import tractor @@ -283,6 +281,15 @@ def test_registrar_merge_binds_union( ) bind_addrs = [rando.unwrap()] + # NOTE: for UDS, `get_random()` produces the same + # filename for the same pid+actor-state, so the + # "disjoint" premise only holds when the addrs + # actually differ (always true for TCP, may + # collide for UDS). + expect_disjoint: bool = ( + tuple(reg_addr) != rando.unwrap() + ) + async def _main(): async with tractor.open_root_actor( registry_addrs=[reg_addr], @@ -295,8 +302,9 @@ def test_registrar_merge_binds_union( bound = actor.accept_addrs bound_w = _bound_wrapped(actor) - # must have at least 2 (registry + bind) - assert len(bound) >= 2 + if expect_disjoint: + # must have at least 2 (registry + bind) + assert len(bound) >= 2 # registry addr must appear in bound set assert reg_wrapped in bound_w From 70dc60a199ff5bbd476ffa1d6b9e7fe6251efe39 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 14 Apr 2026 15:32:04 -0400 Subject: [PATCH 26/31] Bump UDS `listen()` backlog 1 -> 128 for multi-actor unreg A backlog of 1 caused `ECONNREFUSED` when multiple sub-actors simultaneously connect to deregister from a remote-daemon registrar. Now matches the TCP transport's default backlog (~128). Also, - add cross-ref comments between `_uds.close_listener()` and `async_main()`'s `parent_is_reg` deregistration path explaining the UDS socket-file lifecycle (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/ipc/_uds.py | 28 +++++++++++++++++++++++++++- tractor/runtime/_runtime.py | 16 +++++++++++----- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/tractor/ipc/_uds.py b/tractor/ipc/_uds.py index 45100f97..3b214f6a 100644 --- a/tractor/ipc/_uds.py +++ b/tractor/ipc/_uds.py @@ -300,7 +300,23 @@ async def start_listener( ): await sock.bind(str(bindpath)) - sock.listen(1) + # NOTE, the backlog must be large enough to handle + # concurrent connection attempts during actor teardown. + # Previously this was `listen(1)` which caused + # deregistration failures in the remote-daemon registrar + # case: when multiple sub-actors simultaneously try to + # connect to deregister, a backlog of 1 overflows and + # connections get ECONNREFUSED. This matches the TCP + # transport which uses `trio.open_tcp_listeners()` with + # a default backlog of ~128. + # + # For details see the `close_listener()` below which + # `os.unlink()`s the socket file on teardown — meaning + # any NEW connection attempts after that point will fail + # with `FileNotFoundError` regardless of backlog size. + # The backlog only matters while the listener is alive + # and accepting. + sock.listen(128) log.info( f'Listening on UDS socket\n' f'[>\n' @@ -316,6 +332,16 @@ def close_listener( ''' Close and remove the listening unix socket's path. + NOTE, the `os.unlink()` here removes the socket file from + the filesystem immediately, which means any subsequent + connection attempts (e.g. sub-actors trying to deregister + with a registrar whose listener is tearing down) will fail + with `FileNotFoundError`. For the local-registrar case + (parent IS the registrar), `_runtime.async_main()` works + around this by reusing the existing `_parent_chan` instead + of opening a new connection; see the `parent_is_reg` logic + in the deregistration path. + ''' lstnr.socket.close() os.unlink(addr.sockpath) diff --git a/tractor/runtime/_runtime.py b/tractor/runtime/_runtime.py index 7cf20ec2..6381c840 100644 --- a/tractor/runtime/_runtime.py +++ b/tractor/runtime/_runtime.py @@ -1848,11 +1848,17 @@ async def async_main( failed_unreg: bool = False rent_chan: Channel|None = actor._parent_chan - # XXX check if the parent IS the registrar so we can - # reuse the existing channel (avoids opening a new - # connection which fails when the listener socket is - # already closed, e.g. UDS transport unlinks the socket - # file during teardown). + # XXX check if the parent IS the registrar so we + # can reuse the existing `_parent_chan` (avoids + # opening a new connection which fails when the + # listener socket is already closed, e.g. UDS + # transport `os.unlink()`s the socket file during + # teardown). + # + # See `ipc._uds.close_listener()` for details on + # the UDS socket-file lifecycle and why this + # optimization is necessary for the local-registrar + # case. parent_is_reg: bool = False if ( rent_chan is not None From 8817032c9099a43d0d9cfa41da6822a121c53a43 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 14 Apr 2026 16:51:48 -0400 Subject: [PATCH 27/31] Prefer fresh conn for unreg, fallback to `_parent_chan` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior approach eagerly reused `_parent_chan` when parent IS the registrar, but that channel may still carry ctx/stream teardown protocol traffic — concurrent `unregister_actor` RPC causes protocol conflicts. Now try a fresh `get_registry()` conn first; only fall back to the parent channel on `OSError` (listener already closed/unlinked). Deats, - fresh `get_registry()` is the primary path for all addrs regardless of `parent_is_reg` - `OSError` handler checks `parent_is_reg` + `rent_chan.connected()` before fallback - fallback catches `OSError` and `trio.ClosedResourceError` separately - drop unused `reg_addr: Address` annotation (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/runtime/_runtime.py | 54 ++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/tractor/runtime/_runtime.py b/tractor/runtime/_runtime.py index 6381c840..6ff669a7 100644 --- a/tractor/runtime/_runtime.py +++ b/tractor/runtime/_runtime.py @@ -1848,17 +1848,21 @@ async def async_main( failed_unreg: bool = False rent_chan: Channel|None = actor._parent_chan - # XXX check if the parent IS the registrar so we - # can reuse the existing `_parent_chan` (avoids - # opening a new connection which fails when the - # listener socket is already closed, e.g. UDS - # transport `os.unlink()`s the socket file during - # teardown). + # XXX, detect whether the parent IS the registrar + # so we can FALL BACK to `_parent_chan` when a new + # connection attempt fails (e.g. UDS transport + # `os.unlink()`s the socket file during teardown). + # + # IMPORTANT: we do NOT eagerly reuse `_parent_chan` + # because it may still be carrying context/stream + # teardown protocol traffic — sending an + # `unregister_actor` RPC over it concurrently + # causes protocol-level conflicts. Instead we try + # a fresh `get_registry()` connection first and + # only fall back to the parent channel on failure. # # See `ipc._uds.close_listener()` for details on - # the UDS socket-file lifecycle and why this - # optimization is necessary for the local-registrar - # case. + # the UDS socket-file lifecycle. parent_is_reg: bool = False if ( rent_chan is not None @@ -1867,7 +1871,6 @@ async def async_main( ): pchan_raddr: Address|None = rent_chan.raddr if pchan_raddr is not None: - reg_addr: Address for reg_addr in actor.reg_addrs: if ( pchan_raddr.unwrap() @@ -1883,24 +1886,39 @@ async def async_main( with trio.move_on_after(0.5) as cs: cs.shield = True try: - if parent_is_reg: - reg_portal = Portal(rent_chan) + async with get_registry( + addr, + ) as reg_portal: await reg_portal.run_from_ns( 'self', 'unregister_actor', uid=actor.aid.uid, ) - else: - async with get_registry( - addr, - ) as reg_portal: + except OSError: + # Connection to registrar failed + # (listener socket likely already + # closed/unlinked). Fall back to + # parent channel if parent IS the + # registrar. + if ( + parent_is_reg + and + rent_chan.connected() + ): + try: + reg_portal = Portal(rent_chan) await reg_portal.run_from_ns( 'self', 'unregister_actor', uid=actor.aid.uid, ) - except OSError: - failed_unreg = True + except ( + OSError, + trio.ClosedResourceError, + ): + failed_unreg = True + else: + failed_unreg = True if cs.cancelled_caught: failed_unreg = True From ed65301d32dabcec8a18e55825ec008b21b7eda2 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 14 Apr 2026 18:32:55 -0400 Subject: [PATCH 28/31] Fix misc bugs caught by Copilot review Deats, - use `proc.poll() is None` in `sig_prog()` to distinguish "still running" from exit code 0; drop stale `breakpoint()` from fallback kill path (would hang CI). - add missing `raise` on the `RuntimeError` in `async_main()` when no tpt bind addrs given. - clean up stale uid entries from the registrar `_registry` when addr eviction empties the addr list. - update `discovery.__init__` docstring to match the new eager `._multiaddr` import. - fix `registar` -> `registrar` typo in teardown report log msg. Review: PR #429 (Copilot) https://github.com/goodboy/tractor/pull/429 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/conftest.py | 5 ++--- tractor/discovery/__init__.py | 9 +++++---- tractor/discovery/_registry.py | 2 ++ tractor/runtime/_runtime.py | 4 ++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 1fad2255..c7b20531 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -212,7 +212,7 @@ def sig_prog( ''' for i in range(tries): proc.send_signal(sig) - if not proc.poll(): + if proc.poll() is None: print( f'WARNING, proc still alive after,\n' f'canc_timeout={canc_timeout!r}\n' @@ -224,8 +224,7 @@ def sig_prog( else: # TODO: why sometimes does SIGINT not work on teardown? # seems to happen only when trace logging enabled? - if not proc.poll(): - breakpoint() + if proc.poll() is None: print( f'XXX WARNING KILLING PROG WITH SIGINT XXX\n' f'canc_timeout={canc_timeout!r}\n' diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index 93c19e62..1ac8415d 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -18,10 +18,11 @@ Discovery (protocols) API for automatic addressing and location management of (service) actors. -NOTE: to avoid circular imports, this ``__init__`` -does NOT eagerly import submodules. Use direct -module paths like ``tractor.discovery._addr`` or -``tractor.discovery._api`` instead. +NOTE: this ``__init__`` only eagerly imports the +``._multiaddr`` submodule (for public re-exports). +Heavier submodules like ``._addr`` and ``._api`` +are NOT imported here to avoid circular imports; +use direct module paths for those. ''' from ._multiaddr import ( diff --git a/tractor/discovery/_registry.py b/tractor/discovery/_registry.py index dd235753..7cedb391 100644 --- a/tractor/discovery/_registry.py +++ b/tractor/discovery/_registry.py @@ -196,6 +196,8 @@ class Registrar(Actor): and addr_tup in other_addrs ): other_addrs.remove(addr_tup) + if not other_addrs: + del self._registry[other_uid] break # Append to this uid's addr list (avoid duplicates) diff --git a/tractor/runtime/_runtime.py b/tractor/runtime/_runtime.py index 6ff669a7..bee9e20d 100644 --- a/tractor/runtime/_runtime.py +++ b/tractor/runtime/_runtime.py @@ -1567,7 +1567,7 @@ async def async_main( # XXX, either passed in by caller or delivered # in post spawn-spec handshake for subs. if not accept_addrs: - RuntimeError( + raise RuntimeError( f'No tpt bind addresses provided to actor!?\n' f'parent_addr={parent_addr!r}\n' f'accept_addrs={accept_addrs!r}\n' @@ -1926,7 +1926,7 @@ async def async_main( if failed_unreg: teardown_report += ( f'-> Failed to unregister {actor.name} from ' - f'registar @ {addr}\n' + f'registrar @ {addr}\n' ) # Ensure all peers (actors connected to us as clients) are finished From 3152f423d8c65964825d874b505820ced00b0b0f Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 16 Apr 2026 17:44:14 -0400 Subject: [PATCH 29/31] Condense `.raw.md` prompt-IO logs, add `diff_cmd` refs Replace verbose inline code dumps in `.raw.md` entries with terse summaries and `git diff` cmd references. Add `diff_cmd` metadata to each entry's YAML frontmatter so readers can reproduce the actual output diff. Also, - rename `multiaddr_declare_eps.md_` -> `.md` (drop trailing `_` suffix) (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .../20260406T222648Z_f086222_prompt_io.raw.md | 1 + ...20260413T205048Z_269d939c_prompt_io.raw.md | 195 +----------------- ...20260414T163300Z_befedc49_prompt_io.raw.md | 32 +-- ...clare_eps.md_ => multiaddr_declare_eps.md} | 0 4 files changed, 27 insertions(+), 201 deletions(-) rename ai/prompt-io/prompts/{multiaddr_declare_eps.md_ => multiaddr_declare_eps.md} (100%) diff --git a/ai/prompt-io/claude/20260406T222648Z_f086222_prompt_io.raw.md b/ai/prompt-io/claude/20260406T222648Z_f086222_prompt_io.raw.md index 95edeedf..9a973ee8 100644 --- a/ai/prompt-io/claude/20260406T222648Z_f086222_prompt_io.raw.md +++ b/ai/prompt-io/claude/20260406T222648Z_f086222_prompt_io.raw.md @@ -3,6 +3,7 @@ model: claude-opus-4-6 service: claude timestamp: 2026-04-06T22:26:48Z git_ref: f086222 +diff_cmd: git diff f086222~1..f086222 --- Here's the problem. The per-key locking doesn't fix this race because the **lock lifetime is decoupled from `run_ctx`'s lifetime**. Trace through what happens: diff --git a/ai/prompt-io/claude/20260413T205048Z_269d939c_prompt_io.raw.md b/ai/prompt-io/claude/20260413T205048Z_269d939c_prompt_io.raw.md index 71aff348..19cd7837 100644 --- a/ai/prompt-io/claude/20260413T205048Z_269d939c_prompt_io.raw.md +++ b/ai/prompt-io/claude/20260413T205048Z_269d939c_prompt_io.raw.md @@ -3,200 +3,25 @@ model: claude-opus-4-6 service: claude timestamp: 2026-04-13T20:50:48Z git_ref: 269d939c +diff_cmd: git diff 269d939c~1..269d939c --- ## Implementation output -### `tractor/discovery/_multiaddr.py` — appended after line 133 +### `tractor/discovery/_multiaddr.py` -```python -# type aliases for service-endpoint config tables -# -# input table: actor/service name -> list of maddr strings -# or raw unwrapped-address tuples (as accepted by -# `wrap_address()`). -EndpointsTable = dict[ - str, # actor/service name - list[str|tuple], # maddr strs or UnwrappedAddress -] +> `git diff 269d939c~1..269d939c -- tractor/discovery/_multiaddr.py` -# output table: actor/service name -> list of wrapped -# `Address` instances ready for transport binding. -ParsedEndpoints = dict[ - str, # actor/service name - list['Address'], -] +Type aliases `EndpointsTable` and `ParsedEndpoints` +plus `parse_endpoints()` helper that wraps each +entry via `wrap_address()`. +### `tests/discovery/test_multiaddr.py` -def parse_endpoints( - service_table: EndpointsTable, -) -> ParsedEndpoints: - ''' - Parse a service-endpoint config table into wrapped - `Address` instances suitable for transport binding. +> `git diff 269d939c~1..269d939c -- tests/discovery/test_multiaddr.py` - Each key is an actor/service name and each value is - a list of addresses in any format accepted by - `wrap_address()`: - - - multiaddr strings: ``'/ip4/127.0.0.1/tcp/1616'`` - - UDS multiaddr strings using the **multiaddr spec - name** ``/unix/...`` (NOT the tractor-internal - ``/uds/`` proto_key) - - raw unwrapped tuples: ``('127.0.0.1', 1616)`` - - pre-wrapped `Address` objects (passed through) - - Returns a new `dict` with the same keys, where each - value list contains the corresponding `Address` - instances. - - Raises `ValueError` for unsupported multiaddr - protocols (e.g. ``/udp/``). - - ''' - from tractor.discovery._addr import wrap_address - - parsed: ParsedEndpoints = {} - for ( - actor_name, - addr_entries, - ) in service_table.items(): - parsed[actor_name] = [ - wrap_address(entry) - for entry in addr_entries - ] - return parsed -``` - -### `tests/discovery/test_multiaddr.py` — appended after line 252 - -Added import of `parse_endpoints` to existing import block, then -7 new test functions: - -```python -# ------ parse_endpoints() tests ------ - -def test_parse_endpoints_tcp_only(): - ''' - `parse_endpoints()` with a single TCP maddr per actor - produce the correct `TCPAddress` instances. - - ''' - table = { - 'registry': ['/ip4/127.0.0.1/tcp/1616'], - 'data_feed': ['/ip4/0.0.0.0/tcp/5555'], - } - result = parse_endpoints(table) - - assert set(result.keys()) == {'registry', 'data_feed'} - - reg_addr = result['registry'][0] - assert isinstance(reg_addr, TCPAddress) - assert reg_addr.unwrap() == ('127.0.0.1', 1616) - - feed_addr = result['data_feed'][0] - assert isinstance(feed_addr, TCPAddress) - assert feed_addr.unwrap() == ('0.0.0.0', 5555) - - -def test_parse_endpoints_mixed_tpts(): - ''' - `parse_endpoints()` with both TCP and UDS maddrs for - the same actor produce the correct mixed `Address` list. - - ''' - table = { - 'broker': [ - '/ip4/127.0.0.1/tcp/4040', - '/unix/tmp/tractor/broker.sock', - ], - } - result = parse_endpoints(table) - addrs = result['broker'] - - assert len(addrs) == 2 - assert isinstance(addrs[0], TCPAddress) - assert addrs[0].unwrap() == ('127.0.0.1', 4040) - - assert isinstance(addrs[1], UDSAddress) - filedir, filename = addrs[1].unwrap() - assert filename == 'broker.sock' - assert str(filedir) == '/tmp/tractor' - - -def test_parse_endpoints_unwrapped_tuples(): - ''' - `parse_endpoints()` accept raw `(host, port)` tuples - and wrap them as `TCPAddress`. - - ''' - table = { - 'ems': [('127.0.0.1', 6666)], - } - result = parse_endpoints(table) - - addr = result['ems'][0] - assert isinstance(addr, TCPAddress) - assert addr.unwrap() == ('127.0.0.1', 6666) - - -def test_parse_endpoints_mixed_str_and_tuple(): - ''' - `parse_endpoints()` accept a mix of maddr strings and - raw tuples in the same actor entry list. - - ''' - table = { - 'quoter': [ - '/ip4/127.0.0.1/tcp/7777', - ('127.0.0.1', 8888), - ], - } - result = parse_endpoints(table) - addrs = result['quoter'] - - assert len(addrs) == 2 - assert isinstance(addrs[0], TCPAddress) - assert addrs[0].unwrap() == ('127.0.0.1', 7777) - - assert isinstance(addrs[1], TCPAddress) - assert addrs[1].unwrap() == ('127.0.0.1', 8888) - - -def test_parse_endpoints_unsupported_proto(): - ''' - `parse_endpoints()` raise `ValueError` when a maddr - string uses an unsupported protocol like `/udp/`. - - ''' - table = { - 'bad_actor': ['/ip4/127.0.0.1/udp/9999'], - } - with pytest.raises( - ValueError, - match='Unsupported multiaddr protocol combo', - ): - parse_endpoints(table) - - -def test_parse_endpoints_empty_table(): - ''' - `parse_endpoints()` on an empty table return an empty - dict. - - ''' - assert parse_endpoints({}) == {} - - -def test_parse_endpoints_empty_actor_list(): - ''' - `parse_endpoints()` with an actor mapped to an empty - list preserve the key with an empty list value. - - ''' - result = parse_endpoints({'x': []}) - assert result == {'x': []} -``` +Add import of `parse_endpoints` to existing import +block, then 7 new test functions. ### Test run output diff --git a/ai/prompt-io/claude/20260414T163300Z_befedc49_prompt_io.raw.md b/ai/prompt-io/claude/20260414T163300Z_befedc49_prompt_io.raw.md index a879ab28..b732e250 100644 --- a/ai/prompt-io/claude/20260414T163300Z_befedc49_prompt_io.raw.md +++ b/ai/prompt-io/claude/20260414T163300Z_befedc49_prompt_io.raw.md @@ -3,12 +3,15 @@ model: claude-opus-4-6 service: claude timestamp: 2026-04-14T16:33:00Z git_ref: befedc49 +diff_cmd: git diff befedc49~1..befedc49 --- -### Add a `prefer_addr()` helper +### `tractor/discovery/_api.py` -Added transport preference selection to -`tractor/discovery/_api.py` with two new functions: +> `git diff befedc49~1..befedc49 -- tractor/discovery/_api.py` + +Add `_is_local_addr()` and `prefer_addr()` transport +preference helpers. #### `_is_local_addr(addr: Address) -> bool` @@ -38,21 +41,18 @@ Within each tier, the last-registered (latest) entry is preferred. Falls back to `addrs[-1]` if no heuristic matches. +### `tractor/discovery/_registry.py` + +> `git diff befedc49~1..befedc49 -- tractor/discovery/_registry.py` + +`Registrar.find_actor()` return type broadened from +single addr to `list[UnwrappedAddress]|None` — full +addr list lets callers apply transport preference. + #### 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. +`query_actor()` and `wait_for_actor()` now call +`prefer_addr(addrs)` instead of `addrs[-1]`. ### Verification diff --git a/ai/prompt-io/prompts/multiaddr_declare_eps.md_ b/ai/prompt-io/prompts/multiaddr_declare_eps.md similarity index 100% rename from ai/prompt-io/prompts/multiaddr_declare_eps.md_ rename to ai/prompt-io/prompts/multiaddr_declare_eps.md From 7c8e5a67328f951f629f9ba0dc31e19692b206fb Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 16 Apr 2026 17:45:38 -0400 Subject: [PATCH 30/31] Drop `snippets/multiaddr_ex.py` scratch script Since we no longer need the example after integrating `multiaddr` into the `.discovery` subsys. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- snippets/multiaddr_ex.py | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 snippets/multiaddr_ex.py diff --git a/snippets/multiaddr_ex.py b/snippets/multiaddr_ex.py deleted file mode 100644 index 5e1bb2f8..00000000 --- a/snippets/multiaddr_ex.py +++ /dev/null @@ -1,13 +0,0 @@ -from pathlib import Path -from multiaddr import Multiaddr - -# construct from a string -m1 = Multiaddr("/ip4/127.0.0.1/udp/1234") -m2 = Multiaddr("/unix/run/user/1000/sway-ipc.1000.1557.sock") -for key in m1.protocols(): - key - -uds_sock_path = Path(m2.values()[0]) -uds_sock_path -uds_sock_path.is_file() -uds_sock_path.is_socket() From 3867403fabb4b1e85f4c477d377f037b32df45cd Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 16 Apr 2026 20:03:10 -0400 Subject: [PATCH 31/31] Scale `test_open_local_sub_to_stream` timeout by CPU factor Import and apply `cpu_scaling_factor()` from `conftest`; bump base from 3.6 -> 4 and multiply through so CI boxes with slow CPUs don't flake. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/test_resource_cache.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_resource_cache.py b/tests/test_resource_cache.py index 29876539..4902fb7a 100644 --- a/tests/test_resource_cache.py +++ b/tests/test_resource_cache.py @@ -213,9 +213,12 @@ def test_open_local_sub_to_stream( N local tasks using `trionics.maybe_open_context()`. ''' - timeout: float = 3.6 - if platform.system() == "Windows": - timeout: float = 10 + from .conftest import cpu_scaling_factor + timeout: float = ( + 4 + if not platform.system() == "Windows" + else 10 + ) * cpu_scaling_factor() if debug_mode: timeout = 999