@cortexkit/subc-client 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -13
- package/dist/client.d.ts +49 -47
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +316 -247
- package/dist/client.js.map +1 -1
- package/dist/envelope.d.ts +24 -17
- package/dist/envelope.d.ts.map +1 -1
- package/dist/envelope.js +91 -72
- package/dist/envelope.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/provider.d.ts +35 -8
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +221 -71
- package/dist/provider.js.map +1 -1
- package/dist/route-handle.d.ts +25 -0
- package/dist/route-handle.d.ts.map +1 -0
- package/dist/route-handle.js +52 -0
- package/dist/route-handle.js.map +1 -0
- package/dist/socket.d.ts +7 -0
- package/dist/socket.d.ts.map +1 -1
- package/dist/socket.js +23 -0
- package/dist/socket.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +388 -262
- package/src/envelope.ts +136 -74
- package/src/index.ts +7 -0
- package/src/provider.ts +361 -94
- package/src/route-handle.ts +60 -0
- package/src/socket.ts +37 -0
package/src/client.ts
CHANGED
|
@@ -15,15 +15,22 @@ import { debuglog } from "node:util";
|
|
|
15
15
|
import { AuthError, authenticateClient } from "./auth.js";
|
|
16
16
|
import { ConnectionFileError, readConnectionFile, type ConnectionInfo } from "./connection-file.js";
|
|
17
17
|
import {
|
|
18
|
+
AdmissionClass,
|
|
18
19
|
buildFrame,
|
|
19
20
|
buildFlags,
|
|
20
|
-
decodeHeader,
|
|
21
21
|
encodeFrame,
|
|
22
22
|
FrameType,
|
|
23
|
-
HEADER_LEN,
|
|
24
23
|
Priority,
|
|
25
24
|
type Frame,
|
|
26
25
|
} from "./envelope.js";
|
|
26
|
+
import {
|
|
27
|
+
belongsToConnection,
|
|
28
|
+
createRouteHandle,
|
|
29
|
+
newConnectionToken,
|
|
30
|
+
RouteHandle,
|
|
31
|
+
sameRouteHandle,
|
|
32
|
+
StaleRouteHandleError,
|
|
33
|
+
} from "./route-handle.js";
|
|
27
34
|
import {
|
|
28
35
|
SocketClosedError,
|
|
29
36
|
SocketTimeoutError,
|
|
@@ -98,6 +105,8 @@ export interface ConsumerIdentity {
|
|
|
98
105
|
export interface RouteOpenOptions {
|
|
99
106
|
/** Optional override for the consumer identity; by default the SUBC_MODULE_ID and SUBC_LAUNCH_NONCE environment variables are used when both are non-empty. Set null to send route.open without consumer_identity. */
|
|
100
107
|
consumerIdentity?: ConsumerIdentity | null;
|
|
108
|
+
/** Optional consumer-declared reverse-request capabilities for this route.open. This is a declaration, not a verified privilege; providers must treat an omitted field as no reverse-request capability. Known MCP method-family values today are "elicitation", "sampling", and "roots". */
|
|
109
|
+
consumerCapabilities?: string[];
|
|
101
110
|
}
|
|
102
111
|
|
|
103
112
|
export interface CatalogEntry {
|
|
@@ -108,6 +117,7 @@ export interface CatalogEntry {
|
|
|
108
117
|
|
|
109
118
|
export interface RequestOptions {
|
|
110
119
|
priority?: Priority;
|
|
120
|
+
admissionClass?: AdmissionClass;
|
|
111
121
|
timeoutMs?: number;
|
|
112
122
|
/** Called for each interim PUSH / StreamData frame before the terminal reply. */
|
|
113
123
|
onProgress?: (body: Uint8Array) => void;
|
|
@@ -124,6 +134,16 @@ export interface ManagedCallOptions extends RequestOptions {
|
|
|
124
134
|
|
|
125
135
|
export interface SubscribeOptions {
|
|
126
136
|
priority?: Priority;
|
|
137
|
+
admissionClass?: AdmissionClass;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type RoutePollKind = "status" | "liveness";
|
|
141
|
+
|
|
142
|
+
export interface RoutePollResult {
|
|
143
|
+
route_channel: number;
|
|
144
|
+
route_epoch: number;
|
|
145
|
+
status: string | null;
|
|
146
|
+
live: boolean | null;
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
export interface CloseRouteOptions {
|
|
@@ -133,7 +153,10 @@ export interface CloseRouteOptions {
|
|
|
133
153
|
* Defaults to false: close immediately, aborting everything in flight.
|
|
134
154
|
*/
|
|
135
155
|
drain?: boolean;
|
|
136
|
-
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface ManagedCloseRouteOptions extends CloseRouteOptions {
|
|
159
|
+
/** Consumer identity used by the cached managed route. */
|
|
137
160
|
consumerIdentity?: ConsumerIdentity | null;
|
|
138
161
|
}
|
|
139
162
|
|
|
@@ -207,7 +230,7 @@ export class SubcError extends Error {
|
|
|
207
230
|
}
|
|
208
231
|
|
|
209
232
|
interface Pending {
|
|
210
|
-
|
|
233
|
+
handle: RouteHandle | null;
|
|
211
234
|
resolve: (frame: Frame) => void;
|
|
212
235
|
reject: (err: Error) => void;
|
|
213
236
|
onProgress?: (body: Uint8Array) => void;
|
|
@@ -217,6 +240,10 @@ interface Pending {
|
|
|
217
240
|
subscription?: boolean;
|
|
218
241
|
/** Invoked when this pending settles (resolve or reject); used to await drain. */
|
|
219
242
|
onSettle?: () => void;
|
|
243
|
+
/** Runs synchronously in dispatch before the response can settle its caller. */
|
|
244
|
+
acceptFrame?: (frame: Frame) => boolean;
|
|
245
|
+
/** Retained only when a local route.open deadline wins. */
|
|
246
|
+
onLateResponse?: (frame: Frame) => void;
|
|
220
247
|
}
|
|
221
248
|
|
|
222
249
|
export interface ConnectOptions {
|
|
@@ -261,12 +288,11 @@ interface CachedRoute {
|
|
|
261
288
|
target: Extract<RouteTarget, { kind: ManagedRouteKind }>;
|
|
262
289
|
identity: BindIdentity;
|
|
263
290
|
consumerIdentity?: ConsumerIdentity;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
opening: Promise<number> | null;
|
|
291
|
+
handle: RouteHandle | null;
|
|
292
|
+
opening: Promise<RouteHandle> | null;
|
|
267
293
|
/**
|
|
268
|
-
* Tombstone set by
|
|
269
|
-
* object across its routeOpen await; if
|
|
294
|
+
* Tombstone set by closeManagedRoute. An in-flight openCachedRoute holds this exact
|
|
295
|
+
* object across its routeOpen await; if closeManagedRoute flips this while the open is in
|
|
270
296
|
* flight, the open must NOT install its channel (it GOODBYEs the channel it opened
|
|
271
297
|
* and yields RouteClosed) — so a close can never be resurrected by a racing reopen.
|
|
272
298
|
* Not a permanent tombstone: the map entry is deleted, so a later call for the same
|
|
@@ -278,7 +304,11 @@ interface CachedRoute {
|
|
|
278
304
|
export class SubcClient {
|
|
279
305
|
private nextCorr = 1n;
|
|
280
306
|
private readonly pending = new Map<string, Pending>();
|
|
307
|
+
private readonly lateResponses = new Map<string, (frame: Frame) => void>();
|
|
281
308
|
private readonly routes = new Map<string, CachedRoute>();
|
|
309
|
+
private readonly liveRoutes = new Map<number, RouteHandle>();
|
|
310
|
+
private connectionToken = newConnectionToken();
|
|
311
|
+
private ingressEpochDropCount = 0;
|
|
282
312
|
private closedErr: Error | null = null;
|
|
283
313
|
private closeStarted = false;
|
|
284
314
|
private reconnecting: Promise<void> | null = null;
|
|
@@ -318,28 +348,54 @@ export class SubcClient {
|
|
|
318
348
|
return parsed.modules ?? [];
|
|
319
349
|
}
|
|
320
350
|
|
|
321
|
-
|
|
322
|
-
async routeOpen(target: RouteTarget, identity: BindIdentity, opts: RouteOpenOptions = {}): Promise<
|
|
351
|
+
/** Open a route and return its connection-bound immutable handle. */
|
|
352
|
+
async routeOpen(target: RouteTarget, identity: BindIdentity, opts: RouteOpenOptions = {}): Promise<RouteHandle> {
|
|
323
353
|
const consumerIdentity = routeOpenConsumerIdentity(opts);
|
|
354
|
+
const consumerCapabilities = opts.consumerCapabilities;
|
|
324
355
|
const body = this.encode({
|
|
325
356
|
op: "route.open",
|
|
326
357
|
target,
|
|
327
358
|
identity,
|
|
328
359
|
...(consumerIdentity ? { consumer_identity: consumerIdentity } : {}),
|
|
360
|
+
...(consumerCapabilities !== undefined ? { consumer_capabilities: consumerCapabilities } : {}),
|
|
329
361
|
});
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
362
|
+
|
|
363
|
+
let installed: RouteHandle | null = null;
|
|
364
|
+
const install = (frame: Frame): boolean => {
|
|
365
|
+
if (frame.header.ty !== FrameType.Response) return true;
|
|
366
|
+
const parsed = this.parseJson(frame) as { route_channel?: number; route_epoch?: number };
|
|
367
|
+
if (typeof parsed.route_channel !== "number" || typeof parsed.route_epoch !== "number") {
|
|
368
|
+
throw new SubcError(`route.open returned no route handle: ${JSON.stringify(parsed)}`);
|
|
369
|
+
}
|
|
370
|
+
installed = this.installRoute(parsed.route_channel, parsed.route_epoch);
|
|
371
|
+
return true;
|
|
372
|
+
};
|
|
373
|
+
const closeLateRoute = (frame: Frame): void => {
|
|
374
|
+
if (frame.header.ty !== FrameType.Response) return;
|
|
375
|
+
try {
|
|
376
|
+
const parsed = this.parseJson(frame) as { route_channel?: number; route_epoch?: number };
|
|
377
|
+
if (typeof parsed.route_channel !== "number" || typeof parsed.route_epoch !== "number") return;
|
|
378
|
+
const lateHandle = this.installRoute(parsed.route_channel, parsed.route_epoch);
|
|
379
|
+
this.failHandle(lateHandle, new SubcError("late route.open was closed", "route_closed"));
|
|
380
|
+
this.liveRoutes.delete(lateHandle.channel);
|
|
381
|
+
this.sendRouteGoodbye(lateHandle, true);
|
|
382
|
+
} catch {
|
|
383
|
+
this.closeConnectionAfterCleanupFailure();
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
await this.controlRpc(body, install, closeLateRoute);
|
|
388
|
+
if (!installed) throw new SubcError("route.open response was not installed");
|
|
389
|
+
return installed;
|
|
336
390
|
}
|
|
337
391
|
|
|
338
|
-
|
|
339
|
-
async request(
|
|
392
|
+
/** Send a data-plane request on exactly the supplied route generation. */
|
|
393
|
+
async request(handle: RouteHandle, body: unknown, opts: RequestOptions = {}): Promise<unknown> {
|
|
394
|
+
this.assertLiveHandle(handle);
|
|
340
395
|
const bytes = body instanceof Uint8Array ? body : this.encode(body);
|
|
341
396
|
const priority = opts.priority ?? Priority.Interactive;
|
|
342
|
-
const
|
|
397
|
+
const admission = opts.admissionClass ?? AdmissionClass.Normal;
|
|
398
|
+
const reply = await this.send(handle, bytes, priority, admission, opts.timeoutMs, opts.onProgress);
|
|
343
399
|
return this.parseJson(reply);
|
|
344
400
|
}
|
|
345
401
|
|
|
@@ -355,12 +411,23 @@ export class SubcClient {
|
|
|
355
411
|
): Promise<Response> {
|
|
356
412
|
const body = params === undefined ? { method } : { method, params };
|
|
357
413
|
|
|
414
|
+
let retriedUnknownChannel = false;
|
|
358
415
|
for (;;) {
|
|
359
|
-
const
|
|
416
|
+
const routeHandle = await this.cachedRouteHandle(moduleId, opts);
|
|
360
417
|
try {
|
|
361
|
-
return (await this.managedRequest(
|
|
418
|
+
return (await this.managedRequest(routeHandle, body, opts)) as Response;
|
|
362
419
|
} catch (err) {
|
|
363
420
|
if (!(err instanceof SubcCallError)) throw this.terminalCallError("managed call failed", err);
|
|
421
|
+
// unknown_channel is the daemon ROUTER refusing an unrouted channel — the
|
|
422
|
+
// request provably never reached a module, so one in-place retry cannot
|
|
423
|
+
// double-execute anything. The cached bind is dead (module restarted and
|
|
424
|
+
// its route-gone GOODBYE raced or was missed); evict it so the retry
|
|
425
|
+
// re-opens the route instead of resending into the same dead channel.
|
|
426
|
+
if (err.code === "unknown_channel" && !retriedUnknownChannel && !this.closeStarted) {
|
|
427
|
+
retriedUnknownChannel = true;
|
|
428
|
+
this.evictRouteHandle(routeHandle);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
364
431
|
if (err.kind === "not_sent") {
|
|
365
432
|
try {
|
|
366
433
|
await this.reconnectAfterDrop(err);
|
|
@@ -381,43 +448,44 @@ export class SubcClient {
|
|
|
381
448
|
}
|
|
382
449
|
}
|
|
383
450
|
|
|
384
|
-
|
|
385
|
-
* Open a held-open event subscription on a route channel. Sends one Request the
|
|
386
|
-
* provider keeps open, delivering each interim StreamData frame to `onEvent`; the
|
|
387
|
-
* returned `closed` settles on the StreamEnd terminal (resolve) or an Error / route
|
|
388
|
-
* GOODBYE (reject). Events ride this held-open request's correlation id — they are
|
|
389
|
-
* never unsolicited, so they are not dropped. Call `unsubscribe()` to cancel.
|
|
390
|
-
*/
|
|
451
|
+
/** Open a held request on exactly one route generation. */
|
|
391
452
|
subscribe(
|
|
392
|
-
|
|
453
|
+
handle: RouteHandle,
|
|
393
454
|
body: unknown,
|
|
394
455
|
onEvent: (event: Uint8Array) => void,
|
|
395
456
|
opts: SubscribeOptions = {},
|
|
396
457
|
): Subscription {
|
|
458
|
+
this.assertLiveHandle(handle);
|
|
397
459
|
const bytes = body instanceof Uint8Array ? body : this.encode(body);
|
|
398
460
|
const priority = opts.priority ?? Priority.Interactive;
|
|
399
|
-
const
|
|
400
|
-
const
|
|
461
|
+
const admission = opts.admissionClass ?? AdmissionClass.Normal;
|
|
462
|
+
const corr = this.allocateCorr();
|
|
463
|
+
const key = pendingKey(handle, corr);
|
|
401
464
|
|
|
402
465
|
const closed = new Promise<void>((resolve, reject) => {
|
|
403
466
|
if (this.closedErr) {
|
|
404
467
|
reject(this.closedErr);
|
|
405
468
|
return;
|
|
406
469
|
}
|
|
407
|
-
// No timeout: a subscription stays open indefinitely until StreamEnd, Error,
|
|
408
|
-
// route GOODBYE, or unsubscribe.
|
|
409
470
|
this.pending.set(key, {
|
|
410
|
-
|
|
471
|
+
handle,
|
|
411
472
|
resolve: () => resolve(),
|
|
412
473
|
reject,
|
|
413
474
|
onProgress: onEvent,
|
|
414
475
|
timer: null,
|
|
415
476
|
subscription: true,
|
|
416
477
|
});
|
|
417
|
-
const frame = buildFrame(
|
|
478
|
+
const frame = buildFrame(
|
|
479
|
+
FrameType.Request,
|
|
480
|
+
buildFlags(false, priority, false, admission),
|
|
481
|
+
handle.channel,
|
|
482
|
+
handle.epoch,
|
|
483
|
+
corr,
|
|
484
|
+
bytes,
|
|
485
|
+
);
|
|
418
486
|
this.sock.write(encodeFrame(frame), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch((err) => {
|
|
419
|
-
const
|
|
420
|
-
if (
|
|
487
|
+
const pending = this.pending.get(key);
|
|
488
|
+
if (pending) this.rejectPending(key, pending, err instanceof Error ? err : new SubcError(String(err)));
|
|
421
489
|
});
|
|
422
490
|
});
|
|
423
491
|
|
|
@@ -425,77 +493,77 @@ export class SubcClient {
|
|
|
425
493
|
const unsubscribe = (): void => {
|
|
426
494
|
if (cancelled) return;
|
|
427
495
|
cancelled = true;
|
|
428
|
-
|
|
429
|
-
// handler and ends with StreamEnd, which settles `closed`.
|
|
430
|
-
const cancel = buildFrame(FrameType.Cancel, buildFlags(false, priority, false), routeChannel, corr, EMPTY_BODY);
|
|
431
|
-
this.sock.write(encodeFrame(cancel), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => {
|
|
432
|
-
// Best-effort: if the socket is already gone, the read loop fails the
|
|
433
|
-
// pending waiter and `closed` rejects on its own.
|
|
434
|
-
});
|
|
496
|
+
this.cancel(handle, corr, priority);
|
|
435
497
|
};
|
|
436
|
-
|
|
437
498
|
return { unsubscribe, closed };
|
|
438
499
|
}
|
|
439
500
|
|
|
440
|
-
/**
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
async
|
|
501
|
+
/** Send a pure-header cancellation for an in-flight request. */
|
|
502
|
+
cancel(handle: RouteHandle, corr: bigint, priority: Priority = Priority.Interactive): void {
|
|
503
|
+
this.assertLiveHandle(handle);
|
|
504
|
+
const cancel = buildFrame(
|
|
505
|
+
FrameType.Cancel,
|
|
506
|
+
buildFlags(false, priority, false),
|
|
507
|
+
handle.channel,
|
|
508
|
+
handle.epoch,
|
|
509
|
+
corr,
|
|
510
|
+
EMPTY_BODY,
|
|
511
|
+
);
|
|
512
|
+
this.sock.write(encodeFrame(cancel), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => undefined);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Poll status or liveness for exactly the supplied route generation. */
|
|
516
|
+
async routePoll(handle: RouteHandle, kind: RoutePollKind): Promise<RoutePollResult> {
|
|
517
|
+
this.assertLiveHandle(handle);
|
|
518
|
+
const body = this.encode({
|
|
519
|
+
op: "route.poll",
|
|
520
|
+
route_channel: handle.channel,
|
|
521
|
+
route_epoch: handle.epoch,
|
|
522
|
+
kind,
|
|
523
|
+
});
|
|
524
|
+
const reply = await this.controlRpc(body, (frame) => {
|
|
525
|
+
if (frame.header.ty !== FrameType.Response) return true;
|
|
526
|
+
const parsed = this.parseJson(frame) as Partial<RoutePollResult>;
|
|
527
|
+
return parsed.route_channel === handle.channel && parsed.route_epoch === handle.epoch;
|
|
528
|
+
});
|
|
529
|
+
return this.parseJson(reply) as RoutePollResult;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Tear down exactly the supplied route generation. */
|
|
533
|
+
async closeRoute(handle: RouteHandle, opts: CloseRouteOptions = {}): Promise<void> {
|
|
534
|
+
this.assertLiveHandle(handle);
|
|
535
|
+
for (const [key, cached] of this.routes) {
|
|
536
|
+
if (cached.handle && sameRouteHandle(cached.handle, handle)) {
|
|
537
|
+
cached.closed = true;
|
|
538
|
+
cached.handle = null;
|
|
539
|
+
this.routes.delete(key);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (opts.drain) await this.drainUnaryOnHandle(handle);
|
|
543
|
+
this.failHandle(handle, new SubcError("route closed by closeRoute", "route_closed"));
|
|
544
|
+
if (this.liveRoutes.get(handle.channel) === handle) this.liveRoutes.delete(handle.channel);
|
|
545
|
+
this.sendRouteGoodbye(handle);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Close a cached managed route by its route-open identity tuple. */
|
|
549
|
+
async closeManagedRoute(
|
|
456
550
|
target: Extract<RouteTarget, { kind: ManagedRouteKind }>,
|
|
457
551
|
identity: BindIdentity,
|
|
458
|
-
opts:
|
|
552
|
+
opts: ManagedCloseRouteOptions = {},
|
|
459
553
|
): Promise<void> {
|
|
460
554
|
const key = routeCacheKey(target, identity, routeOpenConsumerIdentity(opts));
|
|
461
555
|
const cached = this.routes.get(key);
|
|
462
|
-
if (!cached) return;
|
|
463
|
-
// Generation guard: an in-flight openCachedRoute holds this same object and
|
|
464
|
-
// re-checks `closed` before installing its channel, so flipping it here makes
|
|
465
|
-
// close win over a racing reopen. Removing the map entry lets a later call()
|
|
466
|
-
// create a fresh route for the key (not a permanent tombstone).
|
|
556
|
+
if (!cached) return;
|
|
467
557
|
cached.closed = true;
|
|
468
558
|
this.routes.delete(key);
|
|
469
|
-
const
|
|
470
|
-
cached.
|
|
471
|
-
|
|
472
|
-
// the racing open will see closed=true and GOODBYE whatever it opens, so there is
|
|
473
|
-
// nothing local to tear down here.
|
|
474
|
-
if (channel !== null) await this.closeRouteChannel(channel, opts);
|
|
559
|
+
const handle = cached.handle;
|
|
560
|
+
cached.handle = null;
|
|
561
|
+
if (handle) await this.closeRoute(handle, opts);
|
|
475
562
|
}
|
|
476
563
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
* {name, arguments}) and hold the channel themselves. Idempotent, never throws.
|
|
481
|
-
* Settles in-flight requests on the channel as RouteClosed and sends a best-effort
|
|
482
|
-
* route GOODBYE. `opts.drain` awaits in-flight UNARY requests first; subscriptions
|
|
483
|
-
* are always aborted (a held-open stream cannot be drained).
|
|
484
|
-
*/
|
|
485
|
-
async closeRouteChannel(channel: number, opts: CloseRouteOptions = {}): Promise<void> {
|
|
486
|
-
if (channel === 0) return; // channel 0 is the control plane, never a route.
|
|
487
|
-
if (opts.drain) {
|
|
488
|
-
// Wait only for in-flight UNARY requests on this channel; subscriptions are
|
|
489
|
-
// aborted below (a held-open stream has no natural completion to drain to).
|
|
490
|
-
await this.drainUnaryOnChannel(channel);
|
|
491
|
-
}
|
|
492
|
-
// Settle anything still in flight on the channel (all of it in abort mode; only
|
|
493
|
-
// subscriptions + late stragglers after a drain). Managed requests are classified
|
|
494
|
-
// at-most-once via their classifyFailure; raw requests/subscriptions get a plain
|
|
495
|
-
// RouteClosed error.
|
|
496
|
-
this.failChannel(channel, new SubcError("route closed by closeRoute", "route_closed"));
|
|
497
|
-
// Best-effort GOODBYE: releases the route on the daemon and notifies the module.
|
|
498
|
-
this.sendRouteGoodbye(channel);
|
|
564
|
+
/** Alias retained for callers that name the operation by protocol channel; it still requires a full handle. */
|
|
565
|
+
async closeRouteChannel(handle: RouteHandle, opts: CloseRouteOptions = {}): Promise<void> {
|
|
566
|
+
await this.closeRoute(handle, opts);
|
|
499
567
|
}
|
|
500
568
|
|
|
501
569
|
close(): void {
|
|
@@ -504,17 +572,15 @@ export class SubcClient {
|
|
|
504
572
|
this.sock.close();
|
|
505
573
|
}
|
|
506
574
|
|
|
507
|
-
|
|
508
|
-
* time) has settled. Subscriptions are excluded — they are aborted, not drained. */
|
|
509
|
-
private drainUnaryOnChannel(channel: number): Promise<void> {
|
|
575
|
+
private drainUnaryOnHandle(handle: RouteHandle): Promise<void> {
|
|
510
576
|
const waiters: Promise<void>[] = [];
|
|
511
577
|
for (const pending of this.pending.values()) {
|
|
512
|
-
if (pending.
|
|
578
|
+
if (pending.handle === handle && !pending.subscription) {
|
|
513
579
|
waiters.push(
|
|
514
580
|
new Promise<void>((resolve) => {
|
|
515
|
-
const
|
|
581
|
+
const previous = pending.onSettle;
|
|
516
582
|
pending.onSettle = () => {
|
|
517
|
-
|
|
583
|
+
previous?.();
|
|
518
584
|
resolve();
|
|
519
585
|
};
|
|
520
586
|
}),
|
|
@@ -524,13 +590,24 @@ export class SubcClient {
|
|
|
524
590
|
return Promise.all(waiters).then(() => undefined);
|
|
525
591
|
}
|
|
526
592
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
593
|
+
private sendRouteGoodbye(handle: RouteHandle, closeOnQueueFailure = false): void {
|
|
594
|
+
this.assertLiveConnection(handle);
|
|
595
|
+
if (this.closedErr) {
|
|
596
|
+
if (closeOnQueueFailure) this.closeConnectionAfterCleanupFailure();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const goodbye = buildFrame(
|
|
600
|
+
FrameType.Goodbye,
|
|
601
|
+
buildFlags(false, Priority.Interactive, false),
|
|
602
|
+
handle.channel,
|
|
603
|
+
handle.epoch,
|
|
604
|
+
0n,
|
|
605
|
+
EMPTY_BODY,
|
|
606
|
+
);
|
|
607
|
+
const write = this.sock.writeTracked(encodeFrame(goodbye), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS);
|
|
608
|
+
if (!write.queued && closeOnQueueFailure) this.closeConnectionAfterCleanupFailure();
|
|
609
|
+
write.completed.catch(() => {
|
|
610
|
+
if (closeOnQueueFailure && !write.queued) this.closeConnectionAfterCleanupFailure();
|
|
534
611
|
});
|
|
535
612
|
}
|
|
536
613
|
|
|
@@ -548,39 +625,60 @@ export class SubcClient {
|
|
|
548
625
|
return { sock, conn };
|
|
549
626
|
}
|
|
550
627
|
|
|
551
|
-
private async controlRpc(
|
|
552
|
-
|
|
553
|
-
|
|
628
|
+
private async controlRpc(
|
|
629
|
+
body: Uint8Array,
|
|
630
|
+
acceptFrame?: (frame: Frame) => boolean,
|
|
631
|
+
onLateResponse?: (frame: Frame) => void,
|
|
632
|
+
): Promise<Frame> {
|
|
633
|
+
return this.send(null, body, Priority.Interactive, AdmissionClass.Normal, undefined, undefined, acceptFrame, onLateResponse);
|
|
554
634
|
}
|
|
555
635
|
|
|
556
636
|
private send(
|
|
557
|
-
|
|
637
|
+
handle: RouteHandle | null,
|
|
558
638
|
body: Uint8Array,
|
|
559
639
|
priority: Priority,
|
|
640
|
+
admission: AdmissionClass,
|
|
560
641
|
timeoutMs: number | undefined,
|
|
561
642
|
onProgress: ((body: Uint8Array) => void) | undefined,
|
|
643
|
+
acceptFrame?: (frame: Frame) => boolean,
|
|
644
|
+
onLateResponse?: (frame: Frame) => void,
|
|
562
645
|
): Promise<Frame> {
|
|
646
|
+
if (handle) this.assertLiveHandle(handle);
|
|
563
647
|
if (this.closedErr) return Promise.reject(this.closedErr);
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
648
|
+
let corr: bigint;
|
|
649
|
+
try {
|
|
650
|
+
corr = this.allocateCorr();
|
|
651
|
+
} catch (error) {
|
|
652
|
+
return Promise.reject(error);
|
|
653
|
+
}
|
|
654
|
+
const key = pendingKey(handle, corr);
|
|
655
|
+
const channel = handle?.channel ?? 0;
|
|
656
|
+
const epoch = handle?.epoch ?? 0;
|
|
657
|
+
const frame = buildFrame(
|
|
658
|
+
FrameType.Request,
|
|
659
|
+
buildFlags(false, priority, false, admission),
|
|
660
|
+
channel,
|
|
661
|
+
epoch,
|
|
662
|
+
corr,
|
|
663
|
+
body,
|
|
664
|
+
);
|
|
567
665
|
|
|
568
666
|
return new Promise<Frame>((resolve, reject) => {
|
|
569
667
|
const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
570
668
|
const pending: Pending = {
|
|
571
|
-
|
|
669
|
+
handle,
|
|
572
670
|
resolve,
|
|
573
671
|
reject,
|
|
574
672
|
onProgress,
|
|
575
673
|
timer: null,
|
|
674
|
+
acceptFrame,
|
|
675
|
+
onLateResponse,
|
|
576
676
|
};
|
|
577
|
-
pending.timer = setTimeout(() =>
|
|
578
|
-
this.arbitrateTimeout(key, pending, channel, corr, ms);
|
|
579
|
-
}, ms);
|
|
677
|
+
pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, channel, corr, ms), ms);
|
|
580
678
|
this.pending.set(key, pending);
|
|
581
|
-
this.sock.write(encodeFrame(frame), Date.now() + ms).catch((
|
|
582
|
-
const
|
|
583
|
-
if (
|
|
679
|
+
this.sock.write(encodeFrame(frame), Date.now() + ms).catch((error) => {
|
|
680
|
+
const current = this.pending.get(key);
|
|
681
|
+
if (current) this.rejectPending(key, current, error instanceof Error ? error : new SubcError(String(error)));
|
|
584
682
|
});
|
|
585
683
|
});
|
|
586
684
|
}
|
|
@@ -597,6 +695,7 @@ export class SubcClient {
|
|
|
597
695
|
*/
|
|
598
696
|
private arbitrateTimeout(key: string, pending: Pending, channel: number, corr: bigint, ms: number): void {
|
|
599
697
|
const settleAsTimeout = (): void => {
|
|
698
|
+
if (pending.onLateResponse) this.lateResponses.set(key, pending.onLateResponse);
|
|
600
699
|
this.rejectPending(
|
|
601
700
|
key,
|
|
602
701
|
pending,
|
|
@@ -621,87 +720,90 @@ export class SubcClient {
|
|
|
621
720
|
setImmediate(arbitrate);
|
|
622
721
|
}
|
|
623
722
|
|
|
624
|
-
private async managedRequest(
|
|
625
|
-
routeChannel: number,
|
|
626
|
-
body: unknown,
|
|
627
|
-
opts: ManagedCallOptions,
|
|
628
|
-
): Promise<unknown> {
|
|
723
|
+
private async managedRequest(handle: RouteHandle, body: unknown, opts: ManagedCallOptions): Promise<unknown> {
|
|
629
724
|
const bytes = body instanceof Uint8Array ? body : this.encode(body);
|
|
630
725
|
const priority = opts.priority ?? Priority.Interactive;
|
|
726
|
+
const admission = opts.admissionClass ?? AdmissionClass.Normal;
|
|
631
727
|
try {
|
|
632
|
-
const reply = await this.sendManaged(
|
|
728
|
+
const reply = await this.sendManaged(handle, bytes, priority, admission, opts.timeoutMs, opts.onProgress);
|
|
633
729
|
return this.parseJson(reply);
|
|
634
|
-
} catch (
|
|
635
|
-
if (
|
|
636
|
-
throw this.terminalCallError("managed call failed",
|
|
730
|
+
} catch (error) {
|
|
731
|
+
if (error instanceof SubcCallError) throw error;
|
|
732
|
+
throw this.terminalCallError("managed call failed", error);
|
|
637
733
|
}
|
|
638
734
|
}
|
|
639
735
|
|
|
640
736
|
private sendManaged(
|
|
641
|
-
|
|
737
|
+
handle: RouteHandle,
|
|
642
738
|
body: Uint8Array,
|
|
643
739
|
priority: Priority,
|
|
740
|
+
admission: AdmissionClass,
|
|
644
741
|
timeoutMs: number | undefined,
|
|
645
742
|
onProgress: ((body: Uint8Array) => void) | undefined,
|
|
646
743
|
): Promise<Frame> {
|
|
744
|
+
try {
|
|
745
|
+
this.assertLiveHandle(handle);
|
|
746
|
+
} catch (error) {
|
|
747
|
+
return Promise.reject(this.notSentCallError("request used a stale route handle", error));
|
|
748
|
+
}
|
|
647
749
|
if (this.closedErr) {
|
|
648
|
-
return Promise.reject(
|
|
750
|
+
return Promise.reject(
|
|
751
|
+
this.notSentCallError("request was not sent because the subc connection was already closed", this.closedErr),
|
|
752
|
+
);
|
|
649
753
|
}
|
|
650
754
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
755
|
+
let corr: bigint;
|
|
756
|
+
try {
|
|
757
|
+
corr = this.allocateCorr();
|
|
758
|
+
} catch (error) {
|
|
759
|
+
return Promise.reject(this.notSentCallError("request correlation allocator was exhausted", error));
|
|
760
|
+
}
|
|
761
|
+
const key = pendingKey(handle, corr);
|
|
762
|
+
const frame = buildFrame(
|
|
763
|
+
FrameType.Request,
|
|
764
|
+
buildFlags(false, priority, false, admission),
|
|
765
|
+
handle.channel,
|
|
766
|
+
handle.epoch,
|
|
767
|
+
corr,
|
|
768
|
+
body,
|
|
769
|
+
);
|
|
654
770
|
let handedToSocket = false;
|
|
655
771
|
|
|
656
|
-
const classifyFailure = (
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
// those bytes may already be in the OS buffer or at the daemon. Any later
|
|
660
|
-
// close, write callback error, route GOODBYE, or timeout before a response is
|
|
661
|
-
// therefore OutcomeUnknown to avoid an unsafe double-mutation retry.
|
|
662
|
-
if (!handedToSocket) {
|
|
663
|
-
return this.notSentCallError("request bytes were not queued to the subc socket", err);
|
|
664
|
-
}
|
|
665
|
-
// A request-deadline timeout (arbitration expired without observing a drop)
|
|
666
|
-
// is refined from a real connection drop: the socket was NOT seen to fail, so
|
|
667
|
-
// the caller can skip a was-it-even-sent recovery path. Still outcome_unknown
|
|
668
|
-
// — queued-to-local-socket is not proof the daemon received or ran it.
|
|
669
|
-
if (err instanceof SubcError && err.code === REQUEST_DEADLINE_MARKER) {
|
|
772
|
+
const classifyFailure = (error: Error): SubcCallError => {
|
|
773
|
+
if (!handedToSocket) return this.notSentCallError("request bytes were not queued to the subc socket", error);
|
|
774
|
+
if (error instanceof SubcError && error.code === REQUEST_DEADLINE_MARKER) {
|
|
670
775
|
return new SubcCallError(
|
|
671
776
|
"outcome_unknown",
|
|
672
|
-
`managed call deadline exceeded after request bytes were queued to the local socket; no terminal response was observed; outcome unknown${causeMessage(
|
|
777
|
+
`managed call deadline exceeded after request bytes were queued to the local socket; no terminal response was observed; outcome unknown${causeMessage(error)}`,
|
|
673
778
|
DEADLINE_NO_DROP_CODE,
|
|
674
|
-
|
|
779
|
+
error,
|
|
675
780
|
);
|
|
676
781
|
}
|
|
677
|
-
return this.outcomeUnknownCallError("connection dropped before the managed call returned a response",
|
|
782
|
+
return this.outcomeUnknownCallError("connection dropped before the managed call returned a response", error);
|
|
678
783
|
};
|
|
679
784
|
|
|
680
785
|
return new Promise<Frame>((resolve, reject) => {
|
|
681
786
|
const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
682
787
|
const pending: Pending = {
|
|
683
|
-
|
|
788
|
+
handle,
|
|
684
789
|
resolve,
|
|
685
790
|
reject,
|
|
686
791
|
onProgress,
|
|
687
792
|
timer: null,
|
|
688
793
|
classifyFailure,
|
|
689
794
|
};
|
|
690
|
-
pending.timer = setTimeout(() =>
|
|
691
|
-
this.arbitrateTimeout(key, pending, channel, corr, ms);
|
|
692
|
-
}, ms);
|
|
795
|
+
pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, handle.channel, corr, ms), ms);
|
|
693
796
|
this.pending.set(key, pending);
|
|
694
|
-
|
|
695
797
|
const write = this.sock.writeTracked(encodeFrame(frame), Date.now() + ms);
|
|
696
798
|
handedToSocket = write.queued;
|
|
697
|
-
write.completed.catch((
|
|
698
|
-
const
|
|
699
|
-
if (
|
|
799
|
+
write.completed.catch((error) => {
|
|
800
|
+
const current = this.pending.get(key);
|
|
801
|
+
if (current) this.rejectPending(key, current, error instanceof Error ? error : new SubcError(String(error)));
|
|
700
802
|
});
|
|
701
803
|
});
|
|
702
804
|
}
|
|
703
805
|
|
|
704
|
-
private async
|
|
806
|
+
private async cachedRouteHandle(moduleId: string, opts: ManagedCallOptions): Promise<RouteHandle> {
|
|
705
807
|
const identity = opts.identity ?? this.opts.identity;
|
|
706
808
|
if (!identity) {
|
|
707
809
|
throw new SubcCallError(
|
|
@@ -710,7 +812,6 @@ export class SubcClient {
|
|
|
710
812
|
"missing_identity",
|
|
711
813
|
);
|
|
712
814
|
}
|
|
713
|
-
|
|
714
815
|
const target = { kind: opts.targetKind ?? this.opts.targetKind, module_id: moduleId } as Extract<
|
|
715
816
|
RouteTarget,
|
|
716
817
|
{ kind: ManagedRouteKind }
|
|
@@ -725,16 +826,12 @@ export class SubcClient {
|
|
|
725
826
|
target,
|
|
726
827
|
identity,
|
|
727
828
|
consumerIdentity,
|
|
728
|
-
|
|
729
|
-
generation: 0,
|
|
829
|
+
handle: null,
|
|
730
830
|
opening: null,
|
|
731
831
|
};
|
|
732
832
|
this.routes.set(key, cached);
|
|
733
833
|
}
|
|
734
|
-
|
|
735
|
-
if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
|
|
736
|
-
return cached.channel;
|
|
737
|
-
}
|
|
834
|
+
if (cached.handle && this.isLiveHandle(cached.handle)) return cached.handle;
|
|
738
835
|
if (!cached.opening) {
|
|
739
836
|
cached.opening = this.openCachedRoute(cached).finally(() => {
|
|
740
837
|
cached.opening = null;
|
|
@@ -743,7 +840,7 @@ export class SubcClient {
|
|
|
743
840
|
return cached.opening;
|
|
744
841
|
}
|
|
745
842
|
|
|
746
|
-
private async openCachedRoute(cached: CachedRoute): Promise<
|
|
843
|
+
private async openCachedRoute(cached: CachedRoute): Promise<RouteHandle> {
|
|
747
844
|
const routeRetryDeadline = Date.now() + ROUTE_OPEN_RETRY_DEADLINE_MS;
|
|
748
845
|
let routeRetryDelay = this.opts.reconnectBackoff.baseMs;
|
|
749
846
|
let routeRetryAttempt = 0;
|
|
@@ -751,47 +848,33 @@ export class SubcClient {
|
|
|
751
848
|
if (cached.closed) throw this.routeClosedDuringOpen();
|
|
752
849
|
try {
|
|
753
850
|
await this.ensureConnectedForManaged();
|
|
754
|
-
} catch (
|
|
755
|
-
throw this.notSentRecoveryError("route.open could not run because reconnect failed",
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
|
|
759
|
-
return cached.channel;
|
|
851
|
+
} catch (error) {
|
|
852
|
+
throw this.notSentRecoveryError("route.open could not run because reconnect failed", error);
|
|
760
853
|
}
|
|
854
|
+
if (cached.handle && this.isLiveHandle(cached.handle)) return cached.handle;
|
|
761
855
|
|
|
762
856
|
try {
|
|
763
|
-
const
|
|
857
|
+
const handle = await this.routeOpen(cached.target, cached.identity, {
|
|
764
858
|
consumerIdentity: cached.consumerIdentity ?? null,
|
|
765
859
|
});
|
|
766
|
-
// Generation guard: a closeRoute may have flipped the tombstone WHILE this
|
|
767
|
-
// route.open was in flight. If so, close wins — do NOT install the channel
|
|
768
|
-
// into the (already-removed) cache entry; GOODBYE the channel we just opened
|
|
769
|
-
// so the daemon/module don't leak it, and fail as RouteClosed.
|
|
770
860
|
if (cached.closed) {
|
|
771
|
-
this.
|
|
861
|
+
this.liveRoutes.delete(handle.channel);
|
|
862
|
+
this.sendRouteGoodbye(handle);
|
|
772
863
|
throw this.routeClosedDuringOpen();
|
|
773
864
|
}
|
|
774
|
-
cached.
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
if (
|
|
779
|
-
if (!this.closeStarted && isConsumerReconnectTransient(err)) {
|
|
865
|
+
cached.handle = handle;
|
|
866
|
+
return handle;
|
|
867
|
+
} catch (error) {
|
|
868
|
+
if (error instanceof SubcCallError && error.code === "route_closed") throw error;
|
|
869
|
+
if (!this.closeStarted && isConsumerReconnectTransient(error)) {
|
|
780
870
|
try {
|
|
781
|
-
await this.reconnectAfterDrop(
|
|
782
|
-
} catch (
|
|
783
|
-
throw this.notSentRecoveryError("route.open was not sent and reconnect failed",
|
|
871
|
+
await this.reconnectAfterDrop(error);
|
|
872
|
+
} catch (reconnectError) {
|
|
873
|
+
throw this.notSentRecoveryError("route.open was not sent and reconnect failed", reconnectError);
|
|
784
874
|
}
|
|
785
875
|
continue;
|
|
786
876
|
}
|
|
787
|
-
|
|
788
|
-
// reloading / momentarily absent) is retried IN-PLACE against the same live
|
|
789
|
-
// connection — never a socket reconnect, which would needlessly disrupt this
|
|
790
|
-
// connection's other routes — until the route-retry deadline. Past the
|
|
791
|
-
// deadline it surfaces as not_sent: provably pre-send (no data frame ever
|
|
792
|
-
// left the client) AND still transient, so the caller's own retry policy may
|
|
793
|
-
// safely re-attempt later. Reason and set kept in parity with subc-client-rs.
|
|
794
|
-
if (!this.closeStarted && err instanceof SubcError && isRetryableRouteOpenCode(err.code)) {
|
|
877
|
+
if (!this.closeStarted && error instanceof SubcError && isRetryableRouteOpenCode(error.code)) {
|
|
795
878
|
routeRetryAttempt += 1;
|
|
796
879
|
if (routeRetryAttempt < this.opts.reconnectBackoff.maxAttempts && Date.now() < routeRetryDeadline) {
|
|
797
880
|
await this.opts.sleep(routeRetryDelay);
|
|
@@ -799,15 +882,11 @@ export class SubcClient {
|
|
|
799
882
|
continue;
|
|
800
883
|
}
|
|
801
884
|
throw this.notSentCallError(
|
|
802
|
-
`route.open failed for module ${cached.moduleId}: ${
|
|
803
|
-
|
|
885
|
+
`route.open failed for module ${cached.moduleId}: ${error.code} (retry budget exhausted)`,
|
|
886
|
+
error,
|
|
804
887
|
);
|
|
805
888
|
}
|
|
806
|
-
|
|
807
|
-
// unknown_target, ...) is pre-send but would never succeed on retry, so it
|
|
808
|
-
// stays terminal — a not_sent class here would invite a retry storm against a
|
|
809
|
-
// request the daemon will always reject.
|
|
810
|
-
throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, err);
|
|
889
|
+
throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, error);
|
|
811
890
|
}
|
|
812
891
|
}
|
|
813
892
|
}
|
|
@@ -882,33 +961,26 @@ export class SubcClient {
|
|
|
882
961
|
this.currentConn = opened.conn;
|
|
883
962
|
this.closedErr = null;
|
|
884
963
|
this.generation += 1;
|
|
964
|
+
this.connectionToken = newConnectionToken();
|
|
965
|
+
this.liveRoutes.clear();
|
|
966
|
+
this.lateResponses.clear();
|
|
967
|
+
this.nextCorr = 1n;
|
|
885
968
|
void this.readLoop(opened.sock, this.generation);
|
|
886
969
|
}
|
|
887
970
|
|
|
888
971
|
private async reopenCachedRoutes(): Promise<void> {
|
|
972
|
+
for (const cached of this.routes.values()) cached.handle = null;
|
|
889
973
|
for (const cached of this.routes.values()) {
|
|
890
|
-
cached.
|
|
891
|
-
cached.
|
|
892
|
-
}
|
|
893
|
-
for (const cached of this.routes.values()) {
|
|
894
|
-
if (cached.closed) continue; // closed concurrently with reconnect — don't reopen.
|
|
895
|
-
// Thread the route's consumer identity through the reopen, exactly as the
|
|
896
|
-
// lazy per-call path (openCachedRoute) does. Dropping it here would make a
|
|
897
|
-
// route reopened after a reconnect send route.open with no consumer_identity,
|
|
898
|
-
// so the daemon would re-stamp it with a different (weaker) principal than the
|
|
899
|
-
// one it was originally bound under — a silent post-reconnect trust downgrade.
|
|
900
|
-
const channel = await this.routeOpen(cached.target, cached.identity, {
|
|
974
|
+
if (cached.closed) continue;
|
|
975
|
+
const handle = await this.routeOpen(cached.target, cached.identity, {
|
|
901
976
|
consumerIdentity: cached.consumerIdentity ?? null,
|
|
902
977
|
});
|
|
903
|
-
// A closeRoute may have raced this reopen (flipping the tombstone during the
|
|
904
|
-
// route.open await). If so, GOODBYE the channel instead of installing it, so the
|
|
905
|
-
// closed route isn't silently re-established on the new connection.
|
|
906
978
|
if (cached.closed) {
|
|
907
|
-
this.
|
|
979
|
+
this.liveRoutes.delete(handle.channel);
|
|
980
|
+
this.sendRouteGoodbye(handle);
|
|
908
981
|
continue;
|
|
909
982
|
}
|
|
910
|
-
cached.
|
|
911
|
-
cached.generation = this.generation;
|
|
983
|
+
cached.handle = handle;
|
|
912
984
|
}
|
|
913
985
|
}
|
|
914
986
|
|
|
@@ -929,41 +1001,41 @@ export class SubcClient {
|
|
|
929
1001
|
private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
|
|
930
1002
|
try {
|
|
931
1003
|
for (;;) {
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1004
|
+
this.readerActive = false;
|
|
1005
|
+
const frame = await sock.readFrame(
|
|
1006
|
+
Number.POSITIVE_INFINITY,
|
|
1007
|
+
Date.now() + BODY_READ_TIMEOUT_MS,
|
|
1008
|
+
() => {
|
|
1009
|
+
this.readerActive = true;
|
|
1010
|
+
},
|
|
1011
|
+
);
|
|
940
1012
|
try {
|
|
941
|
-
|
|
942
|
-
const body =
|
|
943
|
-
header.len === 0
|
|
944
|
-
? new Uint8Array(0)
|
|
945
|
-
: await sock.readExact(header.len, Date.now() + BODY_READ_TIMEOUT_MS);
|
|
946
|
-
// Drop a frame read off a socket this client has already replaced
|
|
947
|
-
// (reconnect): its pendings were settled by fail(), and dispatching it
|
|
948
|
-
// against the current pending map could match a re-used (channel, corr).
|
|
949
|
-
if (this.sock === sock && this.generation === generation) {
|
|
950
|
-
this.dispatch({ header, body });
|
|
951
|
-
}
|
|
1013
|
+
if (this.sock === sock && this.generation === generation) this.dispatch(frame);
|
|
952
1014
|
} finally {
|
|
953
1015
|
this.readerActive = false;
|
|
954
1016
|
}
|
|
955
1017
|
}
|
|
956
|
-
} catch (
|
|
1018
|
+
} catch (error) {
|
|
957
1019
|
if (this.sock === sock && this.generation === generation) {
|
|
958
|
-
this.fail(
|
|
1020
|
+
this.fail(error instanceof Error ? error : new SubcError(String(error)));
|
|
959
1021
|
}
|
|
960
1022
|
}
|
|
961
1023
|
}
|
|
962
1024
|
|
|
963
1025
|
private dispatch(frame: Frame): void {
|
|
964
|
-
|
|
1026
|
+
let handle: RouteHandle | null = null;
|
|
1027
|
+
if (frame.header.channel !== 0) {
|
|
1028
|
+
handle = this.liveRoutes.get(frame.header.channel) ?? null;
|
|
1029
|
+
if (!handle || handle.epoch !== frame.header.epoch) {
|
|
1030
|
+
this.ingressEpochDropCount += 1;
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const key = pendingKey(handle, frame.header.corr);
|
|
965
1036
|
const pending = this.pending.get(key);
|
|
966
1037
|
if (pending) {
|
|
1038
|
+
if (pending.acceptFrame && !pending.acceptFrame(frame)) return;
|
|
967
1039
|
switch (frame.header.ty) {
|
|
968
1040
|
case FrameType.Push:
|
|
969
1041
|
case FrameType.StreamData:
|
|
@@ -980,32 +1052,34 @@ export class SubcClient {
|
|
|
980
1052
|
return;
|
|
981
1053
|
}
|
|
982
1054
|
}
|
|
983
|
-
|
|
984
|
-
|
|
1055
|
+
|
|
1056
|
+
const late = this.lateResponses.get(key);
|
|
1057
|
+
if (late && (frame.header.ty === FrameType.Response || frame.header.ty === FrameType.Error)) {
|
|
1058
|
+
this.lateResponses.delete(key);
|
|
1059
|
+
late(frame);
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (frame.header.ty === FrameType.Goodbye && handle) {
|
|
1064
|
+
this.failHandle(handle, new SubcError("route closed by subc (GOODBYE)"));
|
|
1065
|
+
if (this.liveRoutes.get(handle.channel) === handle) this.liveRoutes.delete(handle.channel);
|
|
1066
|
+
this.evictRouteHandle(handle);
|
|
985
1067
|
return;
|
|
986
1068
|
}
|
|
987
|
-
// A terminal frame (Response/Error/StreamEnd) with no waiter is almost always
|
|
988
|
-
// a reply that arrived AFTER its request already settled — the fingerprint of a
|
|
989
|
-
// premature timeout under event-loop starvation (the reply raced the deadline
|
|
990
|
-
// and lost). Metadata-only debug log (never the body) so every future
|
|
991
|
-
// occurrence is a one-line diagnosis instead of an invisible drop. Enable with
|
|
992
|
-
// NODE_DEBUG=subc-client.
|
|
993
1069
|
if (
|
|
994
1070
|
frame.header.ty === FrameType.Response ||
|
|
995
1071
|
frame.header.ty === FrameType.Error ||
|
|
996
1072
|
frame.header.ty === FrameType.StreamEnd
|
|
997
1073
|
) {
|
|
998
1074
|
debug(
|
|
999
|
-
"dropped terminal frame with no waiter: type=%d channel=%d corr=%s port=%s",
|
|
1075
|
+
"dropped terminal frame with no waiter: type=%d channel=%d epoch=%d corr=%s port=%s",
|
|
1000
1076
|
frame.header.ty,
|
|
1001
1077
|
frame.header.channel,
|
|
1078
|
+
frame.header.epoch,
|
|
1002
1079
|
frame.header.corr,
|
|
1003
1080
|
this.sock.localPort() ?? "?",
|
|
1004
1081
|
);
|
|
1005
|
-
return;
|
|
1006
1082
|
}
|
|
1007
|
-
// Unmatched Push or stray frame: no registered waiter. Drop it — v1 has no
|
|
1008
|
-
// unsolicited-push consumers.
|
|
1009
1083
|
}
|
|
1010
1084
|
|
|
1011
1085
|
/**
|
|
@@ -1042,11 +1116,15 @@ export class SubcClient {
|
|
|
1042
1116
|
}
|
|
1043
1117
|
}
|
|
1044
1118
|
|
|
1045
|
-
private
|
|
1119
|
+
private evictRouteHandle(handle: RouteHandle): void {
|
|
1120
|
+
for (const cached of this.routes.values()) {
|
|
1121
|
+
if (cached.handle && sameRouteHandle(cached.handle, handle)) cached.handle = null;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
private failHandle(handle: RouteHandle, error: Error): void {
|
|
1046
1126
|
for (const [key, pending] of this.pending) {
|
|
1047
|
-
if (pending.
|
|
1048
|
-
this.rejectPending(key, pending, err);
|
|
1049
|
-
}
|
|
1127
|
+
if (pending.handle && sameRouteHandle(pending.handle, handle)) this.rejectPending(key, pending, error);
|
|
1050
1128
|
}
|
|
1051
1129
|
}
|
|
1052
1130
|
|
|
@@ -1076,6 +1154,50 @@ export class SubcClient {
|
|
|
1076
1154
|
return this.terminalCallError(message, cause);
|
|
1077
1155
|
}
|
|
1078
1156
|
|
|
1157
|
+
/** Number of nonzero-channel ingress frames dropped by endpoint epoch validation. */
|
|
1158
|
+
get droppedIngressFrames(): number {
|
|
1159
|
+
return this.ingressEpochDropCount;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
private installRoute(channel: number, epoch: number): RouteHandle {
|
|
1163
|
+
const handle = createRouteHandle(channel, epoch, this.connectionToken);
|
|
1164
|
+
this.liveRoutes.set(channel, handle);
|
|
1165
|
+
return handle;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
private isLiveHandle(handle: RouteHandle): boolean {
|
|
1169
|
+
return belongsToConnection(handle, this.connectionToken) && this.liveRoutes.get(handle.channel) === handle;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
private assertLiveConnection(handle: RouteHandle): void {
|
|
1173
|
+
if (!belongsToConnection(handle, this.connectionToken)) throw new StaleRouteHandleError(handle);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
private assertLiveHandle(handle: RouteHandle): void {
|
|
1177
|
+
if (!this.isLiveHandle(handle)) throw new StaleRouteHandleError(handle);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
private allocateCorr(): bigint {
|
|
1181
|
+
const maximum = 0xffff_ffff_ffff_ffffn;
|
|
1182
|
+
if (this.nextCorr > maximum) {
|
|
1183
|
+
const error = new SubcError("channel-0 correlation id allocator exhausted", "corr_exhausted");
|
|
1184
|
+
this.fail(error);
|
|
1185
|
+
this.sock.close();
|
|
1186
|
+
this.scheduleReconnectAfterDrop(error);
|
|
1187
|
+
throw error;
|
|
1188
|
+
}
|
|
1189
|
+
const corr = this.nextCorr;
|
|
1190
|
+
this.nextCorr += 1n;
|
|
1191
|
+
return corr;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
private closeConnectionAfterCleanupFailure(): void {
|
|
1195
|
+
const error = new SubcError("late route cleanup could not be queued", "late_route_cleanup_failed");
|
|
1196
|
+
this.fail(error);
|
|
1197
|
+
this.sock.close();
|
|
1198
|
+
this.scheduleReconnectAfterDrop(error);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1079
1201
|
private encode(value: unknown): Uint8Array {
|
|
1080
1202
|
return new Uint8Array(Buffer.from(JSON.stringify(value), "utf8"));
|
|
1081
1203
|
}
|
|
@@ -1178,3 +1300,7 @@ function causeMessage(cause: unknown): string {
|
|
|
1178
1300
|
if (cause === undefined) return "";
|
|
1179
1301
|
return `: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
1180
1302
|
}
|
|
1303
|
+
|
|
1304
|
+
function pendingKey(handle: RouteHandle | null, corr: bigint): string {
|
|
1305
|
+
return handle ? `${handle.channel}:${handle.epoch}:${corr}` : `0:0:${corr}`;
|
|
1306
|
+
}
|