@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/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
- /** Consumer identity to use when looking up the route to close; only needed for routes opened with a non-default consumer identity. */
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
- channel: number;
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
- channel: number | null;
265
- generation: number;
266
- opening: Promise<number> | null;
291
+ handle: RouteHandle | null;
292
+ opening: Promise<RouteHandle> | null;
267
293
  /**
268
- * Tombstone set by closeRoute. An in-flight openCachedRoute holds this exact
269
- * object across its routeOpen await; if closeRoute flips this while the open is in
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
- /** Open a route to a provider (channel-0 route.open); returns the route channel. */
322
- async routeOpen(target: RouteTarget, identity: BindIdentity, opts: RouteOpenOptions = {}): Promise<number> {
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
- const reply = await this.controlRpc(body);
331
- const parsed = this.parseJson(reply) as { op: string; route_channel?: number };
332
- if (typeof parsed.route_channel !== "number") {
333
- throw new SubcError(`route.open returned no route_channel: ${JSON.stringify(parsed)}`);
334
- }
335
- return parsed.route_channel;
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
- /** Send a data-plane request on a route channel and await its terminal reply. */
339
- async request(routeChannel: number, body: unknown, opts: RequestOptions = {}): Promise<unknown> {
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 reply = await this.send(routeChannel, bytes, priority, opts.timeoutMs, opts.onProgress);
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 routeChannel = await this.cachedRouteChannel(moduleId, opts);
416
+ const routeHandle = await this.cachedRouteHandle(moduleId, opts);
361
417
  try {
362
- return (await this.managedRequest(routeChannel, body, opts)) as Response;
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.evictRouteChannel(routeChannel);
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
- routeChannel: number,
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 corr = this.nextCorr++;
411
- const key = `${routeChannel}:${corr}`;
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
- channel: routeChannel,
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(FrameType.Request, buildFlags(false, priority, false), routeChannel, corr, bytes);
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 p = this.pending.get(key);
431
- if (p) this.rejectPending(key, p, err instanceof Error ? err : new SubcError(String(err)));
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
- // Pure-header Cancel on the held-open (channel, corr): the provider aborts its
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
- * Tear down ONE managed route (a route opened via `call()`), keyed by its
453
- * (target, identity). Idempotent and never throws — callers over-call on
454
- * session-end. The teardown:
455
- * - flips a tombstone on the cached route and removes it from the cache, so an
456
- * in-flight `openCachedRoute` for the same key will NOT install its channel
457
- * (the generation guard: close beats a racing reopen), and a later `call()`
458
- * opens a fresh route (this is NOT a permanent tombstone);
459
- * - settles in-flight requests on the channel as RouteClosed (managed requests
460
- * keep their at-most-once classification: outcome_unknown if already sent,
461
- * not_sent otherwise; subscriptions always abort);
462
- * - sends a best-effort route GOODBYE so subc releases the route and notifies
463
- * the module to free per-session resources.
464
- * `opts.drain` waits for in-flight UNARY requests to settle before tearing down.
465
- */
466
- async closeRoute(
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: CloseRouteOptions = {},
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; // never opened / already closed — idempotent no-op.
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 channel = cached.channel;
481
- cached.channel = null;
482
- // channel === null means the route was still opening (no channel installed yet);
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
- * Tear down ONE route by its channel number — the primitive for callers that
490
- * opened a route with `routeOpen` directly (e.g. a tool route carrying raw
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
- /** Resolve once every in-flight UNARY request on the channel (snapshot at call
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.channel === channel && !pending.subscription) {
578
+ if (pending.handle === handle && !pending.subscription) {
524
579
  waiters.push(
525
580
  new Promise<void>((resolve) => {
526
- const prev = pending.onSettle;
581
+ const previous = pending.onSettle;
527
582
  pending.onSettle = () => {
528
- prev?.();
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
- /** Send a best-effort header-only route GOODBYE for `channel`. One-way: the daemon
539
- * releases the route and relays a route-gone GOODBYE to the module; no ack. */
540
- private sendRouteGoodbye(channel: number): void {
541
- if (this.closedErr) return; // connection already gone — the route died with it.
542
- const goodbye = buildFrame(FrameType.Goodbye, buildFlags(false, Priority.Interactive, false), channel, 0n, EMPTY_BODY);
543
- this.sock.write(encodeFrame(goodbye), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => {
544
- // Best-effort: if the socket is already gone, the route is torn down anyway.
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(body: Uint8Array): Promise<Frame> {
563
- // Match the canonical probe: control requests go out Interactive on channel 0.
564
- return this.send(0, body, Priority.Interactive, undefined, undefined);
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
- channel: number,
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
- const corr = this.nextCorr++;
576
- const key = `${channel}:${corr}`;
577
- const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false), channel, corr, body);
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
- channel,
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((err) => {
593
- const p = this.pending.get(key);
594
- if (p) this.rejectPending(key, p, err instanceof Error ? err : new SubcError(String(err)));
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(routeChannel, bytes, priority, opts.timeoutMs, opts.onProgress);
728
+ const reply = await this.sendManaged(handle, bytes, priority, admission, opts.timeoutMs, opts.onProgress);
644
729
  return this.parseJson(reply);
645
- } catch (err) {
646
- if (err instanceof SubcCallError) throw err;
647
- throw this.terminalCallError("managed call failed", err);
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
- channel: number,
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(this.notSentCallError("request was not sent because the subc connection was already closed", this.closedErr));
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
- const corr = this.nextCorr++;
663
- const key = `${channel}:${corr}`;
664
- const frame = buildFrame(FrameType.Request, buildFlags(false, priority, false), channel, corr, body);
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 = (err: Error): SubcCallError => {
668
- // This is the load-bearing asymmetry: only the pre-write paths are NotSent.
669
- // As soon as writeTracked reports that bytes were queued to Node's socket,
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(err)}`,
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
- err,
779
+ error,
686
780
  );
687
781
  }
688
- return this.outcomeUnknownCallError("connection dropped before the managed call returned a response", err);
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
- channel,
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((err) => {
709
- const p = this.pending.get(key);
710
- if (p) this.rejectPending(key, p, err instanceof Error ? err : new SubcError(String(err)));
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 cachedRouteChannel(moduleId: string, opts: ManagedCallOptions): Promise<number> {
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
- channel: null,
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<number> {
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 (err) {
766
- throw this.notSentRecoveryError("route.open could not run because reconnect failed", err);
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 channel = await this.routeOpen(cached.target, cached.identity, {
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.sendRouteGoodbye(channel);
861
+ this.liveRoutes.delete(handle.channel);
862
+ this.sendRouteGoodbye(handle);
783
863
  throw this.routeClosedDuringOpen();
784
864
  }
785
- cached.channel = channel;
786
- cached.generation = this.generation;
787
- return channel;
788
- } catch (err) {
789
- if (err instanceof SubcCallError && err.code === "route_closed") throw err;
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(err);
793
- } catch (reconnectErr) {
794
- throw this.notSentRecoveryError("route.open was not sent and reconnect failed", reconnectErr);
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
- // A daemon-rejected route.open with a RETRYABLE code (target booting /
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}: ${err.code} (retry budget exhausted)`,
814
- err,
885
+ `route.open failed for module ${cached.moduleId}: ${error.code} (retry budget exhausted)`,
886
+ error,
815
887
  );
816
888
  }
817
- // A permanent route.open rejection (bad_consumer_identity, config_divergence,
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.channel = null;
902
- cached.generation = 0;
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.sendRouteGoodbye(channel);
979
+ this.liveRoutes.delete(handle.channel);
980
+ this.sendRouteGoodbye(handle);
919
981
  continue;
920
982
  }
921
- cached.channel = channel;
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
- // Header read waits indefinitely — idle time between frames is normal, and
944
- // the reader is NOT "active" while parked here (a racing timeout must not
945
- // grant grace just because the connection is idle between frames).
946
- const headerBytes = await sock.readExact(HEADER_LEN, Number.POSITIVE_INFINITY);
947
- // A frame is now arriving. Mark the reader active THROUGH dispatch so a
948
- // timeout that fires mid-arrival grants the reply its bounded grace window
949
- // instead of settling as a spurious timeout.
950
- this.readerActive = true;
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
- const header = decodeHeader(headerBytes);
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 (err) {
1018
+ } catch (error) {
968
1019
  if (this.sock === sock && this.generation === generation) {
969
- this.fail(err instanceof Error ? err : new SubcError(String(err)));
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
- const key = `${frame.header.channel}:${frame.header.corr}`;
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
- if (frame.header.ty === FrameType.Goodbye) {
995
- this.failChannel(frame.header.channel, new SubcError("route closed by subc (GOODBYE)"));
996
- // The route is gone daemon-side (module restart, drain, or provider close).
997
- // Evict the cached bind so the NEXT managed call re-opens instead of
998
- // resending into the dead channel and dying on a terminal unknown_channel.
999
- this.evictRouteChannel(frame.header.channel);
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.channel === channel && cached.generation === this.generation) {
1069
- cached.channel = null;
1070
- }
1121
+ if (cached.handle && sameRouteHandle(cached.handle, handle)) cached.handle = null;
1071
1122
  }
1072
1123
  }
1073
1124
 
1074
- private failChannel(channel: number, err: Error): void {
1125
+ private failHandle(handle: RouteHandle, error: Error): void {
1075
1126
  for (const [key, pending] of this.pending) {
1076
- if (pending.channel === channel) {
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
+ }