@cortexkit/subc-client 0.3.4 → 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 -54
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +303 -262
- 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 +374 -277
- 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
|
|
|
@@ -357,9 +413,9 @@ export class SubcClient {
|
|
|
357
413
|
|
|
358
414
|
let retriedUnknownChannel = false;
|
|
359
415
|
for (;;) {
|
|
360
|
-
const
|
|
416
|
+
const routeHandle = await this.cachedRouteHandle(moduleId, opts);
|
|
361
417
|
try {
|
|
362
|
-
return (await this.managedRequest(
|
|
418
|
+
return (await this.managedRequest(routeHandle, body, opts)) as Response;
|
|
363
419
|
} catch (err) {
|
|
364
420
|
if (!(err instanceof SubcCallError)) throw this.terminalCallError("managed call failed", err);
|
|
365
421
|
// unknown_channel is the daemon ROUTER refusing an unrouted channel — the
|
|
@@ -369,7 +425,7 @@ export class SubcClient {
|
|
|
369
425
|
// re-opens the route instead of resending into the same dead channel.
|
|
370
426
|
if (err.code === "unknown_channel" && !retriedUnknownChannel && !this.closeStarted) {
|
|
371
427
|
retriedUnknownChannel = true;
|
|
372
|
-
this.
|
|
428
|
+
this.evictRouteHandle(routeHandle);
|
|
373
429
|
continue;
|
|
374
430
|
}
|
|
375
431
|
if (err.kind === "not_sent") {
|
|
@@ -392,43 +448,44 @@ export class SubcClient {
|
|
|
392
448
|
}
|
|
393
449
|
}
|
|
394
450
|
|
|
395
|
-
|
|
396
|
-
* Open a held-open event subscription on a route channel. Sends one Request the
|
|
397
|
-
* provider keeps open, delivering each interim StreamData frame to `onEvent`; the
|
|
398
|
-
* returned `closed` settles on the StreamEnd terminal (resolve) or an Error / route
|
|
399
|
-
* GOODBYE (reject). Events ride this held-open request's correlation id — they are
|
|
400
|
-
* never unsolicited, so they are not dropped. Call `unsubscribe()` to cancel.
|
|
401
|
-
*/
|
|
451
|
+
/** Open a held request on exactly one route generation. */
|
|
402
452
|
subscribe(
|
|
403
|
-
|
|
453
|
+
handle: RouteHandle,
|
|
404
454
|
body: unknown,
|
|
405
455
|
onEvent: (event: Uint8Array) => void,
|
|
406
456
|
opts: SubscribeOptions = {},
|
|
407
457
|
): Subscription {
|
|
458
|
+
this.assertLiveHandle(handle);
|
|
408
459
|
const bytes = body instanceof Uint8Array ? body : this.encode(body);
|
|
409
460
|
const priority = opts.priority ?? Priority.Interactive;
|
|
410
|
-
const
|
|
411
|
-
const
|
|
461
|
+
const admission = opts.admissionClass ?? AdmissionClass.Normal;
|
|
462
|
+
const corr = this.allocateCorr();
|
|
463
|
+
const key = pendingKey(handle, corr);
|
|
412
464
|
|
|
413
465
|
const closed = new Promise<void>((resolve, reject) => {
|
|
414
466
|
if (this.closedErr) {
|
|
415
467
|
reject(this.closedErr);
|
|
416
468
|
return;
|
|
417
469
|
}
|
|
418
|
-
// No timeout: a subscription stays open indefinitely until StreamEnd, Error,
|
|
419
|
-
// route GOODBYE, or unsubscribe.
|
|
420
470
|
this.pending.set(key, {
|
|
421
|
-
|
|
471
|
+
handle,
|
|
422
472
|
resolve: () => resolve(),
|
|
423
473
|
reject,
|
|
424
474
|
onProgress: onEvent,
|
|
425
475
|
timer: null,
|
|
426
476
|
subscription: true,
|
|
427
477
|
});
|
|
428
|
-
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
|
+
);
|
|
429
486
|
this.sock.write(encodeFrame(frame), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch((err) => {
|
|
430
|
-
const
|
|
431
|
-
if (
|
|
487
|
+
const pending = this.pending.get(key);
|
|
488
|
+
if (pending) this.rejectPending(key, pending, err instanceof Error ? err : new SubcError(String(err)));
|
|
432
489
|
});
|
|
433
490
|
});
|
|
434
491
|
|
|
@@ -436,77 +493,77 @@ export class SubcClient {
|
|
|
436
493
|
const unsubscribe = (): void => {
|
|
437
494
|
if (cancelled) return;
|
|
438
495
|
cancelled = true;
|
|
439
|
-
|
|
440
|
-
// handler and ends with StreamEnd, which settles `closed`.
|
|
441
|
-
const cancel = buildFrame(FrameType.Cancel, buildFlags(false, priority, false), routeChannel, corr, EMPTY_BODY);
|
|
442
|
-
this.sock.write(encodeFrame(cancel), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => {
|
|
443
|
-
// Best-effort: if the socket is already gone, the read loop fails the
|
|
444
|
-
// pending waiter and `closed` rejects on its own.
|
|
445
|
-
});
|
|
496
|
+
this.cancel(handle, corr, priority);
|
|
446
497
|
};
|
|
447
|
-
|
|
448
498
|
return { unsubscribe, closed };
|
|
449
499
|
}
|
|
450
500
|
|
|
451
|
-
/**
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
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(
|
|
467
550
|
target: Extract<RouteTarget, { kind: ManagedRouteKind }>,
|
|
468
551
|
identity: BindIdentity,
|
|
469
|
-
opts:
|
|
552
|
+
opts: ManagedCloseRouteOptions = {},
|
|
470
553
|
): Promise<void> {
|
|
471
554
|
const key = routeCacheKey(target, identity, routeOpenConsumerIdentity(opts));
|
|
472
555
|
const cached = this.routes.get(key);
|
|
473
|
-
if (!cached) return;
|
|
474
|
-
// Generation guard: an in-flight openCachedRoute holds this same object and
|
|
475
|
-
// re-checks `closed` before installing its channel, so flipping it here makes
|
|
476
|
-
// close win over a racing reopen. Removing the map entry lets a later call()
|
|
477
|
-
// create a fresh route for the key (not a permanent tombstone).
|
|
556
|
+
if (!cached) return;
|
|
478
557
|
cached.closed = true;
|
|
479
558
|
this.routes.delete(key);
|
|
480
|
-
const
|
|
481
|
-
cached.
|
|
482
|
-
|
|
483
|
-
// the racing open will see closed=true and GOODBYE whatever it opens, so there is
|
|
484
|
-
// nothing local to tear down here.
|
|
485
|
-
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);
|
|
486
562
|
}
|
|
487
563
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
* {name, arguments}) and hold the channel themselves. Idempotent, never throws.
|
|
492
|
-
* Settles in-flight requests on the channel as RouteClosed and sends a best-effort
|
|
493
|
-
* route GOODBYE. `opts.drain` awaits in-flight UNARY requests first; subscriptions
|
|
494
|
-
* are always aborted (a held-open stream cannot be drained).
|
|
495
|
-
*/
|
|
496
|
-
async closeRouteChannel(channel: number, opts: CloseRouteOptions = {}): Promise<void> {
|
|
497
|
-
if (channel === 0) return; // channel 0 is the control plane, never a route.
|
|
498
|
-
if (opts.drain) {
|
|
499
|
-
// Wait only for in-flight UNARY requests on this channel; subscriptions are
|
|
500
|
-
// aborted below (a held-open stream has no natural completion to drain to).
|
|
501
|
-
await this.drainUnaryOnChannel(channel);
|
|
502
|
-
}
|
|
503
|
-
// Settle anything still in flight on the channel (all of it in abort mode; only
|
|
504
|
-
// subscriptions + late stragglers after a drain). Managed requests are classified
|
|
505
|
-
// at-most-once via their classifyFailure; raw requests/subscriptions get a plain
|
|
506
|
-
// RouteClosed error.
|
|
507
|
-
this.failChannel(channel, new SubcError("route closed by closeRoute", "route_closed"));
|
|
508
|
-
// Best-effort GOODBYE: releases the route on the daemon and notifies the module.
|
|
509
|
-
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);
|
|
510
567
|
}
|
|
511
568
|
|
|
512
569
|
close(): void {
|
|
@@ -515,17 +572,15 @@ export class SubcClient {
|
|
|
515
572
|
this.sock.close();
|
|
516
573
|
}
|
|
517
574
|
|
|
518
|
-
|
|
519
|
-
* time) has settled. Subscriptions are excluded — they are aborted, not drained. */
|
|
520
|
-
private drainUnaryOnChannel(channel: number): Promise<void> {
|
|
575
|
+
private drainUnaryOnHandle(handle: RouteHandle): Promise<void> {
|
|
521
576
|
const waiters: Promise<void>[] = [];
|
|
522
577
|
for (const pending of this.pending.values()) {
|
|
523
|
-
if (pending.
|
|
578
|
+
if (pending.handle === handle && !pending.subscription) {
|
|
524
579
|
waiters.push(
|
|
525
580
|
new Promise<void>((resolve) => {
|
|
526
|
-
const
|
|
581
|
+
const previous = pending.onSettle;
|
|
527
582
|
pending.onSettle = () => {
|
|
528
|
-
|
|
583
|
+
previous?.();
|
|
529
584
|
resolve();
|
|
530
585
|
};
|
|
531
586
|
}),
|
|
@@ -535,13 +590,24 @@ export class SubcClient {
|
|
|
535
590
|
return Promise.all(waiters).then(() => undefined);
|
|
536
591
|
}
|
|
537
592
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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();
|
|
545
611
|
});
|
|
546
612
|
}
|
|
547
613
|
|
|
@@ -559,39 +625,60 @@ export class SubcClient {
|
|
|
559
625
|
return { sock, conn };
|
|
560
626
|
}
|
|
561
627
|
|
|
562
|
-
private async controlRpc(
|
|
563
|
-
|
|
564
|
-
|
|
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);
|
|
565
634
|
}
|
|
566
635
|
|
|
567
636
|
private send(
|
|
568
|
-
|
|
637
|
+
handle: RouteHandle | null,
|
|
569
638
|
body: Uint8Array,
|
|
570
639
|
priority: Priority,
|
|
640
|
+
admission: AdmissionClass,
|
|
571
641
|
timeoutMs: number | undefined,
|
|
572
642
|
onProgress: ((body: Uint8Array) => void) | undefined,
|
|
643
|
+
acceptFrame?: (frame: Frame) => boolean,
|
|
644
|
+
onLateResponse?: (frame: Frame) => void,
|
|
573
645
|
): Promise<Frame> {
|
|
646
|
+
if (handle) this.assertLiveHandle(handle);
|
|
574
647
|
if (this.closedErr) return Promise.reject(this.closedErr);
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
+
);
|
|
578
665
|
|
|
579
666
|
return new Promise<Frame>((resolve, reject) => {
|
|
580
667
|
const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
581
668
|
const pending: Pending = {
|
|
582
|
-
|
|
669
|
+
handle,
|
|
583
670
|
resolve,
|
|
584
671
|
reject,
|
|
585
672
|
onProgress,
|
|
586
673
|
timer: null,
|
|
674
|
+
acceptFrame,
|
|
675
|
+
onLateResponse,
|
|
587
676
|
};
|
|
588
|
-
pending.timer = setTimeout(() =>
|
|
589
|
-
this.arbitrateTimeout(key, pending, channel, corr, ms);
|
|
590
|
-
}, ms);
|
|
677
|
+
pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, channel, corr, ms), ms);
|
|
591
678
|
this.pending.set(key, pending);
|
|
592
|
-
this.sock.write(encodeFrame(frame), Date.now() + ms).catch((
|
|
593
|
-
const
|
|
594
|
-
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)));
|
|
595
682
|
});
|
|
596
683
|
});
|
|
597
684
|
}
|
|
@@ -608,6 +695,7 @@ export class SubcClient {
|
|
|
608
695
|
*/
|
|
609
696
|
private arbitrateTimeout(key: string, pending: Pending, channel: number, corr: bigint, ms: number): void {
|
|
610
697
|
const settleAsTimeout = (): void => {
|
|
698
|
+
if (pending.onLateResponse) this.lateResponses.set(key, pending.onLateResponse);
|
|
611
699
|
this.rejectPending(
|
|
612
700
|
key,
|
|
613
701
|
pending,
|
|
@@ -632,87 +720,90 @@ export class SubcClient {
|
|
|
632
720
|
setImmediate(arbitrate);
|
|
633
721
|
}
|
|
634
722
|
|
|
635
|
-
private async managedRequest(
|
|
636
|
-
routeChannel: number,
|
|
637
|
-
body: unknown,
|
|
638
|
-
opts: ManagedCallOptions,
|
|
639
|
-
): Promise<unknown> {
|
|
723
|
+
private async managedRequest(handle: RouteHandle, body: unknown, opts: ManagedCallOptions): Promise<unknown> {
|
|
640
724
|
const bytes = body instanceof Uint8Array ? body : this.encode(body);
|
|
641
725
|
const priority = opts.priority ?? Priority.Interactive;
|
|
726
|
+
const admission = opts.admissionClass ?? AdmissionClass.Normal;
|
|
642
727
|
try {
|
|
643
|
-
const reply = await this.sendManaged(
|
|
728
|
+
const reply = await this.sendManaged(handle, bytes, priority, admission, opts.timeoutMs, opts.onProgress);
|
|
644
729
|
return this.parseJson(reply);
|
|
645
|
-
} catch (
|
|
646
|
-
if (
|
|
647
|
-
throw this.terminalCallError("managed call failed",
|
|
730
|
+
} catch (error) {
|
|
731
|
+
if (error instanceof SubcCallError) throw error;
|
|
732
|
+
throw this.terminalCallError("managed call failed", error);
|
|
648
733
|
}
|
|
649
734
|
}
|
|
650
735
|
|
|
651
736
|
private sendManaged(
|
|
652
|
-
|
|
737
|
+
handle: RouteHandle,
|
|
653
738
|
body: Uint8Array,
|
|
654
739
|
priority: Priority,
|
|
740
|
+
admission: AdmissionClass,
|
|
655
741
|
timeoutMs: number | undefined,
|
|
656
742
|
onProgress: ((body: Uint8Array) => void) | undefined,
|
|
657
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
|
+
}
|
|
658
749
|
if (this.closedErr) {
|
|
659
|
-
return Promise.reject(
|
|
750
|
+
return Promise.reject(
|
|
751
|
+
this.notSentCallError("request was not sent because the subc connection was already closed", this.closedErr),
|
|
752
|
+
);
|
|
660
753
|
}
|
|
661
754
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
+
);
|
|
665
770
|
let handedToSocket = false;
|
|
666
771
|
|
|
667
|
-
const classifyFailure = (
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
// those bytes may already be in the OS buffer or at the daemon. Any later
|
|
671
|
-
// close, write callback error, route GOODBYE, or timeout before a response is
|
|
672
|
-
// therefore OutcomeUnknown to avoid an unsafe double-mutation retry.
|
|
673
|
-
if (!handedToSocket) {
|
|
674
|
-
return this.notSentCallError("request bytes were not queued to the subc socket", err);
|
|
675
|
-
}
|
|
676
|
-
// A request-deadline timeout (arbitration expired without observing a drop)
|
|
677
|
-
// is refined from a real connection drop: the socket was NOT seen to fail, so
|
|
678
|
-
// the caller can skip a was-it-even-sent recovery path. Still outcome_unknown
|
|
679
|
-
// — queued-to-local-socket is not proof the daemon received or ran it.
|
|
680
|
-
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) {
|
|
681
775
|
return new SubcCallError(
|
|
682
776
|
"outcome_unknown",
|
|
683
|
-
`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)}`,
|
|
684
778
|
DEADLINE_NO_DROP_CODE,
|
|
685
|
-
|
|
779
|
+
error,
|
|
686
780
|
);
|
|
687
781
|
}
|
|
688
|
-
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);
|
|
689
783
|
};
|
|
690
784
|
|
|
691
785
|
return new Promise<Frame>((resolve, reject) => {
|
|
692
786
|
const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
693
787
|
const pending: Pending = {
|
|
694
|
-
|
|
788
|
+
handle,
|
|
695
789
|
resolve,
|
|
696
790
|
reject,
|
|
697
791
|
onProgress,
|
|
698
792
|
timer: null,
|
|
699
793
|
classifyFailure,
|
|
700
794
|
};
|
|
701
|
-
pending.timer = setTimeout(() =>
|
|
702
|
-
this.arbitrateTimeout(key, pending, channel, corr, ms);
|
|
703
|
-
}, ms);
|
|
795
|
+
pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, handle.channel, corr, ms), ms);
|
|
704
796
|
this.pending.set(key, pending);
|
|
705
|
-
|
|
706
797
|
const write = this.sock.writeTracked(encodeFrame(frame), Date.now() + ms);
|
|
707
798
|
handedToSocket = write.queued;
|
|
708
|
-
write.completed.catch((
|
|
709
|
-
const
|
|
710
|
-
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)));
|
|
711
802
|
});
|
|
712
803
|
});
|
|
713
804
|
}
|
|
714
805
|
|
|
715
|
-
private async
|
|
806
|
+
private async cachedRouteHandle(moduleId: string, opts: ManagedCallOptions): Promise<RouteHandle> {
|
|
716
807
|
const identity = opts.identity ?? this.opts.identity;
|
|
717
808
|
if (!identity) {
|
|
718
809
|
throw new SubcCallError(
|
|
@@ -721,7 +812,6 @@ export class SubcClient {
|
|
|
721
812
|
"missing_identity",
|
|
722
813
|
);
|
|
723
814
|
}
|
|
724
|
-
|
|
725
815
|
const target = { kind: opts.targetKind ?? this.opts.targetKind, module_id: moduleId } as Extract<
|
|
726
816
|
RouteTarget,
|
|
727
817
|
{ kind: ManagedRouteKind }
|
|
@@ -736,16 +826,12 @@ export class SubcClient {
|
|
|
736
826
|
target,
|
|
737
827
|
identity,
|
|
738
828
|
consumerIdentity,
|
|
739
|
-
|
|
740
|
-
generation: 0,
|
|
829
|
+
handle: null,
|
|
741
830
|
opening: null,
|
|
742
831
|
};
|
|
743
832
|
this.routes.set(key, cached);
|
|
744
833
|
}
|
|
745
|
-
|
|
746
|
-
if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
|
|
747
|
-
return cached.channel;
|
|
748
|
-
}
|
|
834
|
+
if (cached.handle && this.isLiveHandle(cached.handle)) return cached.handle;
|
|
749
835
|
if (!cached.opening) {
|
|
750
836
|
cached.opening = this.openCachedRoute(cached).finally(() => {
|
|
751
837
|
cached.opening = null;
|
|
@@ -754,7 +840,7 @@ export class SubcClient {
|
|
|
754
840
|
return cached.opening;
|
|
755
841
|
}
|
|
756
842
|
|
|
757
|
-
private async openCachedRoute(cached: CachedRoute): Promise<
|
|
843
|
+
private async openCachedRoute(cached: CachedRoute): Promise<RouteHandle> {
|
|
758
844
|
const routeRetryDeadline = Date.now() + ROUTE_OPEN_RETRY_DEADLINE_MS;
|
|
759
845
|
let routeRetryDelay = this.opts.reconnectBackoff.baseMs;
|
|
760
846
|
let routeRetryAttempt = 0;
|
|
@@ -762,47 +848,33 @@ export class SubcClient {
|
|
|
762
848
|
if (cached.closed) throw this.routeClosedDuringOpen();
|
|
763
849
|
try {
|
|
764
850
|
await this.ensureConnectedForManaged();
|
|
765
|
-
} catch (
|
|
766
|
-
throw this.notSentRecoveryError("route.open could not run because reconnect failed",
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
|
|
770
|
-
return cached.channel;
|
|
851
|
+
} catch (error) {
|
|
852
|
+
throw this.notSentRecoveryError("route.open could not run because reconnect failed", error);
|
|
771
853
|
}
|
|
854
|
+
if (cached.handle && this.isLiveHandle(cached.handle)) return cached.handle;
|
|
772
855
|
|
|
773
856
|
try {
|
|
774
|
-
const
|
|
857
|
+
const handle = await this.routeOpen(cached.target, cached.identity, {
|
|
775
858
|
consumerIdentity: cached.consumerIdentity ?? null,
|
|
776
859
|
});
|
|
777
|
-
// Generation guard: a closeRoute may have flipped the tombstone WHILE this
|
|
778
|
-
// route.open was in flight. If so, close wins — do NOT install the channel
|
|
779
|
-
// into the (already-removed) cache entry; GOODBYE the channel we just opened
|
|
780
|
-
// so the daemon/module don't leak it, and fail as RouteClosed.
|
|
781
860
|
if (cached.closed) {
|
|
782
|
-
this.
|
|
861
|
+
this.liveRoutes.delete(handle.channel);
|
|
862
|
+
this.sendRouteGoodbye(handle);
|
|
783
863
|
throw this.routeClosedDuringOpen();
|
|
784
864
|
}
|
|
785
|
-
cached.
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
if (
|
|
790
|
-
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)) {
|
|
791
870
|
try {
|
|
792
|
-
await this.reconnectAfterDrop(
|
|
793
|
-
} catch (
|
|
794
|
-
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);
|
|
795
874
|
}
|
|
796
875
|
continue;
|
|
797
876
|
}
|
|
798
|
-
|
|
799
|
-
// reloading / momentarily absent) is retried IN-PLACE against the same live
|
|
800
|
-
// connection — never a socket reconnect, which would needlessly disrupt this
|
|
801
|
-
// connection's other routes — until the route-retry deadline. Past the
|
|
802
|
-
// deadline it surfaces as not_sent: provably pre-send (no data frame ever
|
|
803
|
-
// left the client) AND still transient, so the caller's own retry policy may
|
|
804
|
-
// safely re-attempt later. Reason and set kept in parity with subc-client-rs.
|
|
805
|
-
if (!this.closeStarted && err instanceof SubcError && isRetryableRouteOpenCode(err.code)) {
|
|
877
|
+
if (!this.closeStarted && error instanceof SubcError && isRetryableRouteOpenCode(error.code)) {
|
|
806
878
|
routeRetryAttempt += 1;
|
|
807
879
|
if (routeRetryAttempt < this.opts.reconnectBackoff.maxAttempts && Date.now() < routeRetryDeadline) {
|
|
808
880
|
await this.opts.sleep(routeRetryDelay);
|
|
@@ -810,15 +882,11 @@ export class SubcClient {
|
|
|
810
882
|
continue;
|
|
811
883
|
}
|
|
812
884
|
throw this.notSentCallError(
|
|
813
|
-
`route.open failed for module ${cached.moduleId}: ${
|
|
814
|
-
|
|
885
|
+
`route.open failed for module ${cached.moduleId}: ${error.code} (retry budget exhausted)`,
|
|
886
|
+
error,
|
|
815
887
|
);
|
|
816
888
|
}
|
|
817
|
-
|
|
818
|
-
// unknown_target, ...) is pre-send but would never succeed on retry, so it
|
|
819
|
-
// stays terminal — a not_sent class here would invite a retry storm against a
|
|
820
|
-
// request the daemon will always reject.
|
|
821
|
-
throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, err);
|
|
889
|
+
throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, error);
|
|
822
890
|
}
|
|
823
891
|
}
|
|
824
892
|
}
|
|
@@ -893,33 +961,26 @@ export class SubcClient {
|
|
|
893
961
|
this.currentConn = opened.conn;
|
|
894
962
|
this.closedErr = null;
|
|
895
963
|
this.generation += 1;
|
|
964
|
+
this.connectionToken = newConnectionToken();
|
|
965
|
+
this.liveRoutes.clear();
|
|
966
|
+
this.lateResponses.clear();
|
|
967
|
+
this.nextCorr = 1n;
|
|
896
968
|
void this.readLoop(opened.sock, this.generation);
|
|
897
969
|
}
|
|
898
970
|
|
|
899
971
|
private async reopenCachedRoutes(): Promise<void> {
|
|
972
|
+
for (const cached of this.routes.values()) cached.handle = null;
|
|
900
973
|
for (const cached of this.routes.values()) {
|
|
901
|
-
cached.
|
|
902
|
-
cached.
|
|
903
|
-
}
|
|
904
|
-
for (const cached of this.routes.values()) {
|
|
905
|
-
if (cached.closed) continue; // closed concurrently with reconnect — don't reopen.
|
|
906
|
-
// Thread the route's consumer identity through the reopen, exactly as the
|
|
907
|
-
// lazy per-call path (openCachedRoute) does. Dropping it here would make a
|
|
908
|
-
// route reopened after a reconnect send route.open with no consumer_identity,
|
|
909
|
-
// so the daemon would re-stamp it with a different (weaker) principal than the
|
|
910
|
-
// one it was originally bound under — a silent post-reconnect trust downgrade.
|
|
911
|
-
const channel = await this.routeOpen(cached.target, cached.identity, {
|
|
974
|
+
if (cached.closed) continue;
|
|
975
|
+
const handle = await this.routeOpen(cached.target, cached.identity, {
|
|
912
976
|
consumerIdentity: cached.consumerIdentity ?? null,
|
|
913
977
|
});
|
|
914
|
-
// A closeRoute may have raced this reopen (flipping the tombstone during the
|
|
915
|
-
// route.open await). If so, GOODBYE the channel instead of installing it, so the
|
|
916
|
-
// closed route isn't silently re-established on the new connection.
|
|
917
978
|
if (cached.closed) {
|
|
918
|
-
this.
|
|
979
|
+
this.liveRoutes.delete(handle.channel);
|
|
980
|
+
this.sendRouteGoodbye(handle);
|
|
919
981
|
continue;
|
|
920
982
|
}
|
|
921
|
-
cached.
|
|
922
|
-
cached.generation = this.generation;
|
|
983
|
+
cached.handle = handle;
|
|
923
984
|
}
|
|
924
985
|
}
|
|
925
986
|
|
|
@@ -940,41 +1001,41 @@ export class SubcClient {
|
|
|
940
1001
|
private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
|
|
941
1002
|
try {
|
|
942
1003
|
for (;;) {
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
+
);
|
|
951
1012
|
try {
|
|
952
|
-
|
|
953
|
-
const body =
|
|
954
|
-
header.len === 0
|
|
955
|
-
? new Uint8Array(0)
|
|
956
|
-
: await sock.readExact(header.len, Date.now() + BODY_READ_TIMEOUT_MS);
|
|
957
|
-
// Drop a frame read off a socket this client has already replaced
|
|
958
|
-
// (reconnect): its pendings were settled by fail(), and dispatching it
|
|
959
|
-
// against the current pending map could match a re-used (channel, corr).
|
|
960
|
-
if (this.sock === sock && this.generation === generation) {
|
|
961
|
-
this.dispatch({ header, body });
|
|
962
|
-
}
|
|
1013
|
+
if (this.sock === sock && this.generation === generation) this.dispatch(frame);
|
|
963
1014
|
} finally {
|
|
964
1015
|
this.readerActive = false;
|
|
965
1016
|
}
|
|
966
1017
|
}
|
|
967
|
-
} catch (
|
|
1018
|
+
} catch (error) {
|
|
968
1019
|
if (this.sock === sock && this.generation === generation) {
|
|
969
|
-
this.fail(
|
|
1020
|
+
this.fail(error instanceof Error ? error : new SubcError(String(error)));
|
|
970
1021
|
}
|
|
971
1022
|
}
|
|
972
1023
|
}
|
|
973
1024
|
|
|
974
1025
|
private dispatch(frame: Frame): void {
|
|
975
|
-
|
|
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);
|
|
976
1036
|
const pending = this.pending.get(key);
|
|
977
1037
|
if (pending) {
|
|
1038
|
+
if (pending.acceptFrame && !pending.acceptFrame(frame)) return;
|
|
978
1039
|
switch (frame.header.ty) {
|
|
979
1040
|
case FrameType.Push:
|
|
980
1041
|
case FrameType.StreamData:
|
|
@@ -991,36 +1052,34 @@ export class SubcClient {
|
|
|
991
1052
|
return;
|
|
992
1053
|
}
|
|
993
1054
|
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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);
|
|
1000
1067
|
return;
|
|
1001
1068
|
}
|
|
1002
|
-
// A terminal frame (Response/Error/StreamEnd) with no waiter is almost always
|
|
1003
|
-
// a reply that arrived AFTER its request already settled — the fingerprint of a
|
|
1004
|
-
// premature timeout under event-loop starvation (the reply raced the deadline
|
|
1005
|
-
// and lost). Metadata-only debug log (never the body) so every future
|
|
1006
|
-
// occurrence is a one-line diagnosis instead of an invisible drop. Enable with
|
|
1007
|
-
// NODE_DEBUG=subc-client.
|
|
1008
1069
|
if (
|
|
1009
1070
|
frame.header.ty === FrameType.Response ||
|
|
1010
1071
|
frame.header.ty === FrameType.Error ||
|
|
1011
1072
|
frame.header.ty === FrameType.StreamEnd
|
|
1012
1073
|
) {
|
|
1013
1074
|
debug(
|
|
1014
|
-
"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",
|
|
1015
1076
|
frame.header.ty,
|
|
1016
1077
|
frame.header.channel,
|
|
1078
|
+
frame.header.epoch,
|
|
1017
1079
|
frame.header.corr,
|
|
1018
1080
|
this.sock.localPort() ?? "?",
|
|
1019
1081
|
);
|
|
1020
|
-
return;
|
|
1021
1082
|
}
|
|
1022
|
-
// Unmatched Push or stray frame: no registered waiter. Drop it — v1 has no
|
|
1023
|
-
// unsolicited-push consumers.
|
|
1024
1083
|
}
|
|
1025
1084
|
|
|
1026
1085
|
/**
|
|
@@ -1057,25 +1116,15 @@ export class SubcClient {
|
|
|
1057
1116
|
}
|
|
1058
1117
|
}
|
|
1059
1118
|
|
|
1060
|
-
|
|
1061
|
-
* Drop a dead channel from the managed-route cache so the next call re-opens.
|
|
1062
|
-
* Only clears entries still pointing at THIS channel in the CURRENT socket
|
|
1063
|
-
* generation; a route already re-opened (new channel) or re-created after a
|
|
1064
|
-
* reconnect (new generation) is left alone.
|
|
1065
|
-
*/
|
|
1066
|
-
private evictRouteChannel(channel: number): void {
|
|
1119
|
+
private evictRouteHandle(handle: RouteHandle): void {
|
|
1067
1120
|
for (const cached of this.routes.values()) {
|
|
1068
|
-
if (cached.
|
|
1069
|
-
cached.channel = null;
|
|
1070
|
-
}
|
|
1121
|
+
if (cached.handle && sameRouteHandle(cached.handle, handle)) cached.handle = null;
|
|
1071
1122
|
}
|
|
1072
1123
|
}
|
|
1073
1124
|
|
|
1074
|
-
private
|
|
1125
|
+
private failHandle(handle: RouteHandle, error: Error): void {
|
|
1075
1126
|
for (const [key, pending] of this.pending) {
|
|
1076
|
-
if (pending.
|
|
1077
|
-
this.rejectPending(key, pending, err);
|
|
1078
|
-
}
|
|
1127
|
+
if (pending.handle && sameRouteHandle(pending.handle, handle)) this.rejectPending(key, pending, error);
|
|
1079
1128
|
}
|
|
1080
1129
|
}
|
|
1081
1130
|
|
|
@@ -1105,6 +1154,50 @@ export class SubcClient {
|
|
|
1105
1154
|
return this.terminalCallError(message, cause);
|
|
1106
1155
|
}
|
|
1107
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
|
+
|
|
1108
1201
|
private encode(value: unknown): Uint8Array {
|
|
1109
1202
|
return new Uint8Array(Buffer.from(JSON.stringify(value), "utf8"));
|
|
1110
1203
|
}
|
|
@@ -1207,3 +1300,7 @@ function causeMessage(cause: unknown): string {
|
|
|
1207
1300
|
if (cause === undefined) return "";
|
|
1208
1301
|
return `: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
1209
1302
|
}
|
|
1303
|
+
|
|
1304
|
+
function pendingKey(handle: RouteHandle | null, corr: bigint): string {
|
|
1305
|
+
return handle ? `${handle.channel}:${handle.epoch}:${corr}` : `0:0:${corr}`;
|
|
1306
|
+
}
|