Skip to content

sdk/js: the probe's JS programs hang silently in-cluster for 120s, intermittently, on both testnets #732

Closed

Description

@aojea

The JS SDK probe (agentmesh-sdk-probe-* cronjob, every 15 min) fails intermittently on both testnets since the night of 2026-10-15/16, always the same way: the JS program prints nothing for 120 s and is killed by the probe's timeout. The Python SDK in the same pod, the same minute, against the same control plane and routers, passes. The Go cold-path probe (agentmesh-probe-*) passed every run in the same windows.

Observed

testnet build failures (job start, UTC) passes in between
bananas (main, 03a374c) 00:37, 01:07, 01:37, 03:07, 03:22, 04:37, 05:07 03:52, 04:07, 04:22, 04:52
hub (v0.1.0-rc.10) 23:52 (10-15), 01:37, 02:22, 03:37, 04:22 04:37, 04:52, 05:07

Two shapes, both a silent hang of the JS process:

  • {"probe":"agentmesh-sdk-js","ok":false,"stage":"node-mcp","error":"no answer with 'Echo: agentmesh-sdk-probe' in 120s (1 attempts; last: (hung for 120s))"} — node build/examples/call.js mcp://everything echo … produced no output at all, so it never reached the point where it prints mcp://everything is served by <peer> (enroll → join → directory lookup all happen before that line). In the 04:37 and 05:07 bananas runs the js-agent sidecar in the same pod did join (accepting a2a://agent as …), so joining itself worked for one JS process while the other hung.
  • {"probe":"agentmesh-sdk-python","ok":false,"stage":"agent","error":"no agent announced itself in /var/run/agentmesh/js-agent.log within 120s"} — the a2a-agent.js sidecar printed nothing in 120 s (its first line is after enroll and join), so the Python half of the probe had no agent to call; the Python SDK's own enrollment and join were fine (python-agent announced).

Ruled out

  • Keys: both testnets rotated their signing key hours before (bananas 21:05Z by the new scheduler, hub 22:09Z by the 24 h ticker). No control plane 401 and no router no valid key for any peer other than one stale developer node whose credential predates both keys; the Python SDK verifies with the same /keys. Hub has rotated daily for a week with this probe passing.
  • Routers and the provider: no WARN/ERROR on any router or on the everything nodes in the failure windows; connected ≈ authenticated; no refusals.
  • The SDK build: call.js built from 03a374c, run from a workstation against https://bananas.sam-mesh.dev with a bootstrap token: 3/3 passes in ~10 s each. In-cluster debug runs of the same cronjob (see below): 3/3 passes so far.
  • The GKE node: every probe pod, pass or fail, on both testnets, ran on the same node (gke-cluster-sam-mesh-default-pool-21713d03-130d).
  • Control plane load: p99 ≤ 25 ms, no scrape failures, two replicas healthy.

What is different in the failing path

In-cluster only (workstation runs pass), JS only (Python passes in the same pod), intermittent (~40–60 % in the windows), and silent: the hung process never prints, so neither the probe nor the SDK says which await it is in. Candidates, none confirmed: a dial from inside the cluster to a router's public address that neither completes nor fails and a step in the JS enroll/join/lookup path without a timeout (the control plane client has a 30 s per-request timeout; the libp2p dial has its default; a directory lookup stream may not); the enrollment retry budget (3 min) on an unexpected 429; something in the k8s-SA-JWT enrollment path that the bootstrap-token path from the workstation does not exercise.

To continue

  • /tmp/mj/sdk-debug-loop.sh on the operator workstation runs the bananas cronjob's pod spec as a Job with DEBUG=libp2p:*,-libp2p:*:trace,agentmesh* and NODE_DEBUG=http,net,tls on the js and js-agent containers, keeps every container's log under /tmp/mj/sdkdbg-<run>-<container>.log, and reports when a run fails; the manifest is /tmp/mj/sdk-probe-debug.json. A caught failure says which await hangs.
  • Instrumentation to add regardless of the cause: the examples the probe runs should print each stage to stderr (enrolled <peer>, joined via <router>, provider <peer>, dialing), so a 120 s hang is readable from the probe's own output; and the SDK's join and directory lookup should have a bounded timeout with an error naming the router.
  • The two Testnet Health runs during the windows reported success, because the health workflow reads the newest Go probe and not the SDK probe's job history; the SDK probe's failures should fail the health check too.

Not a release blocker in itself: the Go node and the Python SDK are unaffected, the failures are confined to the JS SDK's in-cluster path and each one is a transient of one run. Filed so the investigation can be resumed from here.

Activity

  1. saurav-shakya commented on Oct 17, 2026

    @saurav-shakya
    Contributor

    Hi @aojea, I'd like to take the "instrumentation to add regardless of the cause" part of this. I'm leaving the root-cause investigation alone, since that needs access to the cluster.

    Here's what I found while reading sdk/js. Every step of a join already has its own timeout: the libp2p dial (10s), authenticateWithPeer (10s), the relay reservation (5s), a directory request (10s), and each control plane request (30s). The problem is that joinMesh runs them one after another. syncControlPlane() can make up to four control plane requests before a single router is dialed, and then topUpRouters goes through the candidate routers one by one. So nothing puts a limit on the join as a whole. JoinOptions.signal is documented as "Bounds the whole join", but in practice it only reached the node.dial calls inside topUpRouters.

    The fix is in #739:

    1. The whole join now runs under one deadline: the caller's signal, or a default JOIN_TIMEOUT_MS of 60s. Besides the dials, that covers the control plane sync, the auth handshake, the relay reservation and the announce. When a join gives up, the error tells you which router it was on and what step it was in, for example <router>: auth handshake: join timed out after 60000ms, or which control plane it was waiting for.
    2. request() in discovery.ts (used by discover(), which already had a limit) now lists every router it asked and what each one said, instead of just the last error.
    3. call.js, a2a-call.js and a2a-agent.js print each stage to stderr as they reach it: enrolling with <url>, enrolled <peer>, joined via <routers>, provider <peer> and dialing <peer>. stdout doesn't change, so the probe's is served by and accepting ... as checks keep working, and when a run hangs, the probe's last: field will show the last stage it got to.

    Two questions for you:

    • Does 60s sound right as the default? The Go node gives up after four 5s attempts (about 35s), and the probe allows 120s including enrollment.
    • If the deadline fires after one router is already held, I currently fail the join instead of returning a session with one router, since that's how an aborted signal usually behaves. Happy to flip that if you'd rather keep the session.

    I'm leaving the health-check change (failing on the SDK probe's history) to you, unless you'd like it in the same PR.

  2. aojea commented on Oct 19, 2026

    @aojea
    CollaboratorAuthor

    Only one failure https://github.com/google/agentmesh/actions/workflows/testnet-health.yaml?query=is%3Afailure since this merged and unrelated to the JS so I think we can close and reopen if it happens again

    Thanks @saurav-shakya

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions