Skip to content

Commit e920f31

Browse files
saurav-shakyaaojea
authored andcommitted
sdk/js: cancel an abandoned join's work instead of waiting past it
From review on #739. The examples go back to what they were: they are embedded in the user guides, and printing the control plane URL could leak credentials or query parameters in it. A join that stops already says where in the error Node prints, so the probe's output shows it without them. The error no longer names the control plane URL either. untilAborted only stopped waiting: a control plane sync the join gave up on went on in the background, could still call /info and refresh(), and write credential.json. The signal now goes into the requests themselves. ControlPlaneClient.info() and keys() and authenticateWithPeer take one and combine it with their own timeout, as discovery does, and syncControlPlane stops before its next request and before saving once it aborts. A /refresh already sent is left to finish, since the control plane spends the old biscuit when it answers. The sync before a join is best effort, so it gets JOIN_SYNC_TIMEOUT_MS (5s) rather than the whole join: an unreachable control plane leaves the join to go on with the stored credential. A join that holds a router succeeds even if its own timeout fires while a second router is still being tried; only the caller's signal turns that into a failure. A router cut off by the signal is no longer shunned. Refs #732
1 parent 147baf9 commit e920f31

10 files changed

Lines changed: 184 additions & 201 deletions

File tree

‎sdk/js/README.md‎

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,43 +61,32 @@ if (argv[0] !== undefined && !argv[0].includes("://")) {
6161
}
6262
const [service = peerId !== undefined ? "a2a://agent" : "mcp://everything", toolOrPath = peerId !== undefined ? "/card" : "echo", args = '{"message": "hi"}'] = argv;
6363

64-
// Each stage goes to stderr as it is reached, so a run that hangs shows the
65-
// last one it got to; stdout carries only what callers parse.
66-
const controlPlaneUrl = process.env.AGENTMESH_CONTROL_PLANE_URL ?? "https://mesh.example.com";
67-
console.error(`enrolling with ${controlPlaneUrl}`);
6864
const mesh = await AgentMesh.enroll({
69-
controlPlaneUrl,
65+
controlPlaneUrl: process.env.AGENTMESH_CONTROL_PLANE_URL ?? "https://mesh.example.com",
7066
bootstrapTokenPath: process.env.AGENTMESH_BOOTSTRAP_TOKEN_PATH,
7167
jwtPath: process.env.AGENTMESH_JWT_PATH,
7268
stateDir: process.env.AGENTMESH_STATE_DIR ?? `${homedir()}/.config/agentmesh/caller`,
7369
// A plaintext http:// control plane is otherwise accepted only on loopback.
7470
allowInsecure: process.env.AGENTMESH_INSECURE_CONTROL_PLANE === "true",
7571
});
76-
console.error(`enrolled ${mesh.peerId}`);
7772
const session = await mesh.join();
78-
console.error(`joined via ${session.routers.map((r) => r.peerId).join(", ")}`);
7973
console.log(`on the mesh as ${session.peerId}`);
8074

8175
// The peer to call: an agent named by ID, or a provider of a published service.
8276
let provider: DiscoveredProvider = { peerId: peerId ?? "", addrs: [] };
8377
if (peerId !== undefined) {
8478
// An agent is not in the discovery table; the SDK finds the path to its
8579
// peer ID through the routers.
86-
console.error(`dialing ${peerId}`);
8780
await session.connect(peerId);
8881
} else {
8982
const providers = await session.discover(service);
90-
for (const p of providers) {
91-
console.error(`provider ${p.peerId}`);
92-
}
9383
if (providers.length === 0) {
9484
throw new Error(`no member of the mesh serves ${service}`);
9585
}
9686
// A provider record can outlive its member; the first that answers is used.
9787
let reached = false;
9888
for (const candidate of providers) {
9989
try {
100-
console.error(`dialing ${candidate.peerId}`);
10190
await session.connect(candidate);
10291
provider = candidate;
10392
reached = true;

‎sdk/js/examples/a2a-agent.ts‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,15 @@ import { AgentEvent, DefaultRequestHandler, InMemoryTaskStore, type AgentExecuto
2020
import { UserBuilder, agentCardHandler, jsonRpcHandler } from "@a2a-js/sdk/server/express";
2121
import { AgentMesh, MeshSession } from "@agentmesh-p2p/sdk";
2222

23-
// Each stage goes to stderr as it is reached, so a run that hangs shows the
24-
// last one it got to; stdout carries only what callers parse.
25-
const controlPlaneUrl = process.env.AGENTMESH_CONTROL_PLANE_URL ?? "https://mesh.example.com";
26-
console.error(`enrolling with ${controlPlaneUrl}`);
2723
const mesh = await AgentMesh.enroll({
28-
controlPlaneUrl,
24+
controlPlaneUrl: process.env.AGENTMESH_CONTROL_PLANE_URL ?? "https://mesh.example.com",
2925
bootstrapTokenPath: process.env.AGENTMESH_BOOTSTRAP_TOKEN_PATH,
3026
jwtPath: process.env.AGENTMESH_JWT_PATH,
3127
stateDir: process.env.AGENTMESH_STATE_DIR ?? `${homedir()}/.config/agentmesh/a2a-agent`,
3228
// A plaintext http:// control plane is otherwise accepted only on loopback.
3329
allowInsecure: process.env.AGENTMESH_INSECURE_CONTROL_PLANE === "true",
3430
});
35-
console.error(`enrolled ${mesh.peerId}`);
3631
const session = await mesh.join();
37-
console.error(`joined via ${session.routers.map((r) => r.peerId).join(", ")}`);
3832

3933
// The card names the agent as the mesh reaches it: the URL an A2A client on
4034
// the mesh gives its fetch, http://mesh/mesh/<peer-id>/a2a/agent.

‎sdk/js/examples/a2a-call.ts‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,15 @@ if (peerId === undefined) {
2121
throw new Error("usage: a2a-call.js <peer-id> [text]");
2222
}
2323

24-
// Each stage goes to stderr as it is reached, so a run that hangs shows the
25-
// last one it got to; stdout carries only what callers parse.
26-
const controlPlaneUrl = process.env.AGENTMESH_CONTROL_PLANE_URL ?? "https://mesh.example.com";
27-
console.error(`enrolling with ${controlPlaneUrl}`);
2824
const mesh = await AgentMesh.enroll({
29-
controlPlaneUrl,
25+
controlPlaneUrl: process.env.AGENTMESH_CONTROL_PLANE_URL ?? "https://mesh.example.com",
3026
bootstrapTokenPath: process.env.AGENTMESH_BOOTSTRAP_TOKEN_PATH,
3127
jwtPath: process.env.AGENTMESH_JWT_PATH,
3228
stateDir: process.env.AGENTMESH_STATE_DIR ?? `${homedir()}/.config/agentmesh/a2a-caller`,
3329
// A plaintext http:// control plane is otherwise accepted only on loopback.
3430
allowInsecure: process.env.AGENTMESH_INSECURE_CONTROL_PLANE === "true",
3531
});
36-
console.error(`enrolled ${mesh.peerId}`);
3732
const session = await mesh.join();
38-
console.error(`joined via ${session.routers.map((r) => r.peerId).join(", ")}`);
3933
console.log(`on the mesh as ${session.peerId}`);
4034

4135
// The agent's URL on the mesh, and a fetch that carries requests to it.
@@ -46,7 +40,6 @@ const factory = new ClientFactory({
4640
cardResolver: new DefaultAgentCardResolver({ fetchImpl }),
4741
});
4842
// The card lives under the agent's URL; the trailing slash keeps the last segment when the resolver appends the well-known path.
49-
console.error(`dialing ${peerId}`);
5043
const client = await factory.createFromUrl(agentURL + "/");
5144
const card = await client.getAgentCard();
5245
console.log(`agent: ${card.name}, ${card.description}`);

‎sdk/js/examples/call.ts‎

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,43 +22,32 @@ if (argv[0] !== undefined && !argv[0].includes("://")) {
2222
}
2323
const [service = peerId !== undefined ? "a2a://agent" : "mcp://everything", toolOrPath = peerId !== undefined ? "/card" : "echo", args = '{"message": "hi"}'] = argv;
2424

25-
// Each stage goes to stderr as it is reached, so a run that hangs shows the
26-
// last one it got to; stdout carries only what callers parse.
27-
const controlPlaneUrl = process.env.AGENTMESH_CONTROL_PLANE_URL ?? "https://mesh.example.com";
28-
console.error(`enrolling with ${controlPlaneUrl}`);
2925
const mesh = await AgentMesh.enroll({
30-
controlPlaneUrl,
26+
controlPlaneUrl: process.env.AGENTMESH_CONTROL_PLANE_URL ?? "https://mesh.example.com",
3127
bootstrapTokenPath: process.env.AGENTMESH_BOOTSTRAP_TOKEN_PATH,
3228
jwtPath: process.env.AGENTMESH_JWT_PATH,
3329
stateDir: process.env.AGENTMESH_STATE_DIR ?? `${homedir()}/.config/agentmesh/caller`,
3430
// A plaintext http:// control plane is otherwise accepted only on loopback.
3531
allowInsecure: process.env.AGENTMESH_INSECURE_CONTROL_PLANE === "true",
3632
});
37-
console.error(`enrolled ${mesh.peerId}`);
3833
const session = await mesh.join();
39-
console.error(`joined via ${session.routers.map((r) => r.peerId).join(", ")}`);
4034
console.log(`on the mesh as ${session.peerId}`);
4135

4236
// The peer to call: an agent named by ID, or a provider of a published service.
4337
let provider: DiscoveredProvider = { peerId: peerId ?? "", addrs: [] };
4438
if (peerId !== undefined) {
4539
// An agent is not in the discovery table; the SDK finds the path to its
4640
// peer ID through the routers.
47-
console.error(`dialing ${peerId}`);
4841
await session.connect(peerId);
4942
} else {
5043
const providers = await session.discover(service);
51-
for (const p of providers) {
52-
console.error(`provider ${p.peerId}`);
53-
}
5444
if (providers.length === 0) {
5545
throw new Error(`no member of the mesh serves ${service}`);
5646
}
5747
// A provider record can outlive its member; the first that answers is used.
5848
let reached = false;
5949
for (const candidate of providers) {
6050
try {
61-
console.error(`dialing ${candidate.peerId}`);
6251
await session.connect(candidate);
6352
provider = candidate;
6453
reached = true;

‎sdk/js/src/auth.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,10 @@ function framed(stream: Stream) {
6060
/**
6161
* Client side: presents frame on a new /mesh/auth/1.0.0 stream and returns
6262
* the peer's verified credential. trustedKeys are the control plane keys.
63+
* The handshake has a timeout of its own; outer, when given, can end it sooner.
6364
*/
64-
export async function authenticateWithPeer(conn: Connection, frame: Uint8Array, trustedKeys: Uint8Array[]): Promise<VerifiedBiscuit> {
65-
const signal = AbortSignal.timeout(AUTH_HANDSHAKE_TIMEOUT_MS);
65+
export async function authenticateWithPeer(conn: Connection, frame: Uint8Array, trustedKeys: Uint8Array[], outer?: AbortSignal): Promise<VerifiedBiscuit> {
66+
const signal = outer === undefined ? AbortSignal.timeout(AUTH_HANDSHAKE_TIMEOUT_MS) : AbortSignal.any([outer, AbortSignal.timeout(AUTH_HANDSHAKE_TIMEOUT_MS)]);
6667
// A peer behind a router is reached over a relayed, limited connection.
6768
const stream = await conn.newStream(AUTH_PROTOCOL, { signal, runOnLimitedConnection: true });
6869
try {

‎sdk/js/src/controlplane.ts‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -257,17 +257,17 @@ export class ControlPlaneClient {
257257
}
258258

259259
/** GET /info: OIDC settings, router addresses and the ban list. Unauthenticated. */
260-
async info(): Promise<ControlPlaneInfoResponse> {
261-
const body = await this.#request("GET", "/info");
260+
async info(signal?: AbortSignal): Promise<ControlPlaneInfoResponse> {
261+
const body = await this.#request("GET", "/info", undefined, {}, signal);
262262
return fromBinary(ControlPlaneInfoResponseSchema, body);
263263
}
264264

265265
/**
266266
* GET /keys: every signing key the control plane currently trusts,
267267
* verified against a key the caller already trusts (the enrollment key).
268268
*/
269-
async keys(trusted: Uint8Array[]): Promise<Uint8Array[]> {
270-
const body = await this.#request("GET", "/keys");
269+
async keys(trusted: Uint8Array[], signal?: AbortSignal): Promise<Uint8Array[]> {
270+
const body = await this.#request("GET", "/keys", undefined, {}, signal);
271271
return verifyKeysResponse(fromBinary(KeysResponseSchema, body), trusted, this.#now());
272272
}
273273

@@ -426,14 +426,15 @@ export class ControlPlaneClient {
426426
path: string,
427427
body?: Uint8Array,
428428
headers: Record<string, string> = {},
429+
signal?: AbortSignal,
429430
): Promise<{ ok: boolean; status: number; buf: Uint8Array; dateHeader: string | null }> {
430-
const controller = new AbortController();
431-
const timer = setTimeout(() => controller.abort(new Error(`control plane ${path}: timed out after ${this.#timeoutMs}ms`)), this.#timeoutMs);
431+
// The caller's signal cancels the request itself, not only the wait for it.
432+
const timeout = AbortSignal.timeout(this.#timeoutMs);
432433
try {
433434
const init: RequestInit = {
434435
method,
435436
headers: { Accept: PROTOBUF_CONTENT_TYPE, ...headers },
436-
signal: controller.signal,
437+
signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
437438
};
438439
if (body !== undefined) {
439440
init.body = new Uint8Array(body);
@@ -445,13 +446,22 @@ export class ControlPlaneClient {
445446
throw new Error(`control plane ${path}: response of ${buf.length} bytes exceeds the ${MAX_RESPONSE_BYTES} byte limit`);
446447
}
447448
return { ok: resp.ok, status: resp.status, buf, dateHeader: resp.headers.get("date") };
448-
} finally {
449-
clearTimeout(timer);
449+
} catch (err) {
450+
if (timeout.aborted && signal?.aborted !== true) {
451+
throw new Error(`control plane ${path}: timed out after ${this.#timeoutMs}ms`);
452+
}
453+
throw err;
450454
}
451455
}
452456

453-
async #request(method: "GET" | "POST", path: string, body?: Uint8Array, headers: Record<string, string> = {}): Promise<Uint8Array> {
454-
const outcome = await this.#fetchOnce(method, path, body, headers);
457+
async #request(
458+
method: "GET" | "POST",
459+
path: string,
460+
body?: Uint8Array,
461+
headers: Record<string, string> = {},
462+
signal?: AbortSignal,
463+
): Promise<Uint8Array> {
464+
const outcome = await this.#fetchOnce(method, path, body, headers, signal);
455465
if (!outcome.ok) {
456466
throw new ControlPlaneError(path, outcome.status, new TextDecoder().decode(outcome.buf));
457467
}

‎sdk/js/src/mesh.ts‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -310,12 +310,13 @@ export class AgentMesh {
310310
* Trades the current biscuit for a fresh one and persists it. The control
311311
* plane redeems only the last biscuit it issued, so a lost refresh result
312312
* means re-enrolling; persisting before returning keeps that rare.
313-
* Concurrent calls wait for each other.
313+
* Concurrent calls wait for each other. A refresh whose signal has
314+
* aborted by the time its turn comes does not start.
314315
*/
315-
async refresh(): Promise<MeshCredential> {
316+
async refresh(signal?: AbortSignal): Promise<MeshCredential> {
316317
const run = this.#refreshChain.then(
317-
() => this.#refreshOnce(),
318-
() => this.#refreshOnce(),
318+
() => this.#refreshOnce(signal),
319+
() => this.#refreshOnce(signal),
319320
);
320321
this.#refreshChain = run.then(
321322
() => undefined,
@@ -324,7 +325,8 @@ export class AgentMesh {
324325
return run;
325326
}
326327

327-
async #refreshOnce(): Promise<MeshCredential> {
328+
async #refreshOnce(signal?: AbortSignal): Promise<MeshCredential> {
329+
signal?.throwIfAborted();
328330
let jwt: string | undefined;
329331
if (this.#jwtSource !== undefined) {
330332
try {
@@ -336,6 +338,10 @@ export class AgentMesh {
336338
// Best effort: fall back to session-only refresh if the token source is temporarily unavailable.
337339
}
338340
}
341+
// The last point to stop. The /refresh request itself is not cut short:
342+
// the control plane spends the old biscuit when it answers, so only a
343+
// refresh that runs to the end can save the new one.
344+
signal?.throwIfAborted();
339345
const result = await this.controlPlane.refresh({
340346
identity: this.identity,
341347
biscuit: this.#credential.biscuit,
@@ -379,33 +385,37 @@ export class AgentMesh {
379385
* addresses and the ban set. Each part is attempted even when another
380386
* fails; the errors are reported together. Gossip events only bring this
381387
* forward; they are never the only way state arrives.
388+
*
389+
* An aborted signal cancels the request in flight and throws its reason:
390+
* nothing more is sent and nothing is written to the state directory.
382391
*/
383-
async syncControlPlane(): Promise<ControlPlaneSync> {
392+
async syncControlPlane(signal?: AbortSignal): Promise<ControlPlaneSync> {
384393
const errors: string[] = [];
385394
let keysChanged = false;
386395
let refreshed = false;
387396
try {
388-
const keys = await this.controlPlane.keys(this.#credential.controlPlaneKeys);
397+
const keys = await this.controlPlane.keys(this.#credential.controlPlaneKeys, signal);
389398
if (keys.length === 0) {
390399
throw new Error("/keys returned no keys");
391400
}
392401
keysChanged = !sameKeySet(keys, this.#credential.controlPlaneKeys);
393402
this.#credential = withCredentialMethods({ ...this.#credential, controlPlaneKeys: keys });
394403
if (credentialPredatesRotation(this.#credential)) {
395-
await this.refresh();
404+
await this.refresh(signal);
396405
refreshed = true;
397406
}
398407
} catch (err) {
399408
errors.push(`keys: ${err instanceof Error ? err.message : String(err)}`);
400409
}
410+
signal?.throwIfAborted();
401411

402412
let bannedPeerIds: string[] | undefined;
403413
let routers: RouterInfo[] | undefined;
404414
// Taken before the request: a ban recorded after this instant cannot be
405415
// in the answer, so its absence must not be read as an unban.
406416
const fetchedAt = new Date();
407417
try {
408-
const info = await this.controlPlane.info();
418+
const info = await this.controlPlane.info(signal);
409419
routers = info.routers;
410420
this.#routers = info.routers;
411421
const discovered =
@@ -419,6 +429,7 @@ export class AgentMesh {
419429
} catch (err) {
420430
errors.push(`info: ${err instanceof Error ? err.message : String(err)}`);
421431
}
432+
signal?.throwIfAborted();
422433
if (keysChanged || bannedPeerIds !== undefined) {
423434
await this.save();
424435
}

0 commit comments

Comments
 (0)