Skip to content

sdk: a member follows a router that came back on another address - #554

Merged
aojea merged 1 commit into
google:mainfrom
aojea:fix/sdk-python-stale-router-addrs
Sep 30, 2026
Merged

aojea merged 1 commit into
google:mainfrom
aojea:fix/sdk-python-stale-router-addrs

Conversation

@aojea

@aojea aojea commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Root cause of the hub Testnet Health failure (run 36719875195, Canaries: python-agent-canary-hub), and the gap next to it. Not the dedup case discussed on #553.

GKE replaced every node of the cluster on 2026-09-30 08:27–08:53; the router pods were rescheduled with their keys and new IPs. The Python agent (rc.7) in the canary pod has logged this every 30s since:

relay reservation on router 12D3KooWSQcM… not renewed, retrying in 30s: no connection to 12D3KooWSQcM… within 15s
TransportManager.transport_for_dialing: no transport found for /ip4/10.84.4.91/udp/4501/quic-v1

10.84.4.91 is the router's old pod IP. With no reservation the agent's advertised circuit addresses are dead, which is what the JS caller in the same pod reports (All multiaddr dials failed, PERMISSION_DENIED).

Two commits:

  1. sdk/python: dial a peer at the addresses given, whatever the peerstore holds. _reserve_again re-resolves /dnsaddr/bootstrap.hub.sam-mesh.dev/p2p/… and gets the new address, but host.connect only appends it to the peerstore behind the addresses identify learned from the old pod (and resets their 120s TTL on every call), and py-libp2p 0.8 Swarm.dial_peer selects one address per transport class, the first in that list. The stale pod IP black-holes for DIAL_TIMEOUT, the loop retries, same address, forever. A refusal instead of a black hole would additionally put the peer in the swarm's negative cache (keyed by peer, 60s). dial() now clears the peerstore's addresses for the peer and takes it out of the negative cache before host.connect; every dial the SDK makes goes through it. The same path affects any peer that keeps its key across a move, so a sam-node pod with a --keys-path volume too.

  2. sdk: reserve again on a router where the control plane lists it now. A mesh that hands out literal addresses (sam-one, a kind cluster, the integration harness) has no name to re-resolve: a router that came back elsewhere is known only through the control plane's list, which every pull refreshes, and both SDKs' relay upkeep dialed the address admitted at join. The Python reservation loop and the JS keepRelay now dial the router at the addresses the credential lists for its peer ID (the admitted address only when the list no longer names it) and keep the address they reached it on, so connect()'s circuits through that router and the Python member's advertised relay address follow.

Tests, each failing on main and each half of the dial() fix needed:

  • test_a_peer_that_came_back_on_another_address_is_dialed_there (real py-libp2p hosts): the peerstore holds a port nobody listens on and the swarm a failed dial to it; dial() at the current address connects. Fails on main with recently failed all addresses (negative cache), and with no addresses established a successful connection when only the cache is cleared.
  • test_a_router_that_came_back_on_another_address_is_reserved_on_again (Python): two routers with one key, member joined by /dnsaddr name, the name re-pointed and the connection dropped. Fails on main with dialed the old pod's address again.
  • test_a_router_the_control_plane_lists_elsewhere_is_reserved_on_there (Python) and a router the control plane lists elsewhere is reserved on there (JS): literal addresses, the member pulls the list naming the second router, connection dropped. Fail with dialed the address admitted at join again / an empty relay address list.
  • TestNativeSDKsFollowAMovedRouter (Go integration, ~8s after the fixture): restarts a sam-router on another port with its key under a Python and a JS member, has each pull the list, waits for its relayed address to name the new port, then reaches each member through it from a Go peer. Fails on the previous SDKs with js still advertises [], want the router at …. SAM_SDK_RELAY_CHECK_SECONDS lets the conformance runners check their reservation often enough.

Verified: Python 100 passed; JS 89 passed, build and examples; TestNativeSDK*, TestSDKCanaryScript, TestStandaloneSDKAgents* pass with both runners rebuilt; golangci-lint on tests/integration clean; gen-sdk-docs -check clean.

…e holds

Seen on hub after GKE replaced every node on 2026-09-30: the router pods
were rescheduled with their keys and new IPs, and the Python agent in the
canary pod logged "relay reservation on router ... not renewed, retrying
in 30s: no connection ... within 15s" every 30s for five hours. The JS
caller beside it failed every call with "All multiaddr dials failed" and
PERMISSION_DENIED, since the agent advertised a relay it was no longer
connected to.

The reservation loop did resolve the router's /dnsaddr name again and got
the new address. host.connect appends that address to the peerstore,
behind the ones identify learned from the old pod, and py-libp2p's
dial_peer selects one address per transport, the first in that list: the
old pod IP, a black hole, on every retry, for as long as the record lived,
and every connect renewed its TTL. A refusal instead of a black hole
would have put the peer in the swarm's negative cache, keyed by peer, for
a minute more.

dial() now drops what the peerstore holds for the peer and takes it out
of the negative cache before connecting, so the addresses a caller just
resolved or was handed by a record are the ones dialed. Every dial the
SDK makes goes through it: admission at join, re-reservation, a
provider's direct addresses, a router met through the DHT, and the DHT
walk itself.

Two tests: dial() against a real host whose peer was last seen on a port
nobody answers on and whose swarm holds the failed dial, and a session
whose router comes back with the same key on another address, the name
now resolving there; on the previous code the member reserved on the old
address again.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the dial function in host.py to clear a peer's cached addresses and unblock them in the swarm before attempting a connection, resolving issues where rescheduled pods with new IPs are dialed at stale addresses. It also adds corresponding unit and integration tests to verify this behavior. The review feedback suggests adding a defensive None check for host.get_peerstore() to prevent potential AttributeErrors, and bounding the wait_for helper in tests with a timeout to avoid indefinite hangs.

negative cache is keyed by peer, not by address, and would refuse the new
address for a minute after the old one failed, so the peer is taken out
of it as well."""
host.get_peerstore().clear_addrs(info.peer_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential AttributeError or NoneType errors if get_peerstore() returns None (for instance, in custom mock hosts or uninitialized states), we should add a defensive None check before calling clear_addrs. This aligns with defensive programming practices for nullable references.

Suggested change
host.get_peerstore().clear_addrs(info.peer_id)
peerstore = host.get_peerstore()
if peerstore is not None:
peerstore.clear_addrs(info.peer_id)

Comment on lines +562 to +564
async def wait_for(predicate):
while not predicate():
await trio.sleep(0.1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The helper function wait_for currently loops indefinitely if the predicate never becomes true, causing the test to hang until the outer 60-second timeout cancels it. Bounding the wait with a native Trio timeout (e.g., 5 seconds) allows the test to fail fast and provides a clearer failure context.

Suggested change
async def wait_for(predicate):
while not predicate():
await trio.sleep(0.1)
async def wait_for(predicate, timeout=5.0):
with trio.fail_after(timeout):
while not predicate():
await trio.sleep(0.1)

@aojea
aojea merged commit b4bdd4a into google:main Sep 30, 2026
19 checks passed
@aojea aojea changed the title sdk/python: dial a peer at the addresses given, whatever the peerstore holds sdk: a member follows a router that came back on another address Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant