@cortexkit/subc-client 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -355,12 +411,23 @@ export class SubcClient {
355
411
  ): Promise<Response> {
356
412
  const body = params === undefined ? { method } : { method, params };
357
413
 
414
+ let retriedUnknownChannel = false;
358
415
  for (;;) {
359
- const routeChannel = await this.cachedRouteChannel(moduleId, opts);
416
+ const routeHandle = await this.cachedRouteHandle(moduleId, opts);
360
417
  try {
361
- return (await this.managedRequest(routeChannel, body, opts)) as Response;
418
+ return (await this.managedRequest(routeHandle, body, opts)) as Response;
362
419
  } catch (err) {
363
420
  if (!(err instanceof SubcCallError)) throw this.terminalCallError("managed call failed", err);
421
+ // unknown_channel is the daemon ROUTER refusing an unrouted channel — the
422
+ // request provably never reached a module, so one in-place retry cannot
423
+ // double-execute anything. The cached bind is dead (module restarted and
424
+ // its route-gone GOODBYE raced or was missed); evict it so the retry
425
+ // re-opens the route instead of resending into the same dead channel.
426
+ if (err.code === "unknown_channel" && !retriedUnknownChannel && !this.closeStarted) {
427
+ retriedUnknownChannel = true;
428
+ this.evictRouteHandle(routeHandle);
429
+ continue;
430
+ }
364
431
  if (err.kind === "not_sent") {
365
432
  try {
366
433
  await this.reconnectAfterDrop(err);
@@ -381,43 +448,44 @@ export class SubcClient {
381
448
  }
382
449
  }
383
450
 
384
- /**
385
- * Open a held-open event subscription on a route channel. Sends one Request the
386
- * provider keeps open, delivering each interim StreamData frame to `onEvent`; the
387
- * returned `closed` settles on the StreamEnd terminal (resolve) or an Error / route
388
- * GOODBYE (reject). Events ride this held-open request's correlation id — they are
389
- * never unsolicited, so they are not dropped. Call `unsubscribe()` to cancel.
390
- */
451
+ /** Open a held request on exactly one route generation. */
391
452
  subscribe(
392
- routeChannel: number,
453
+ handle: RouteHandle,
393
454
  body: unknown,
394
455
  onEvent: (event: Uint8Array) => void,
395
456
  opts: SubscribeOptions = {},
396
457
  ): Subscription {
458
+ this.assertLiveHandle(handle);
397
459
  const bytes = body instanceof Uint8Array ? body : this.encode(body);
398
460
  const priority = opts.priority ?? Priority.Interactive;
399
- const corr = this.nextCorr++;
400
- const key = `${routeChannel}:${corr}`;
461
+ const admission = opts.admissionClass ?? AdmissionClass.Normal;
462
+ const corr = this.allocateCorr();
463
+ const key = pendingKey(handle, corr);
401
464
 
402
465
  const closed = new Promise<void>((resolve, reject) => {
403
466
  if (this.closedErr) {
404
467
  reject(this.closedErr);
405
468
  return;
406
469
  }
407
- // No timeout: a subscription stays open indefinitely until StreamEnd, Error,
408
- // route GOODBYE, or unsubscribe.
409
470
  this.pending.set(key, {
410
- channel: routeChannel,
471
+ handle,
411
472
  resolve: () => resolve(),
412
473
  reject,
413
474
  onProgress: onEvent,
414
475
  timer: null,
415
476
  subscription: true,
416
477
  });
417
- const frame = buildFrame(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
+ );
418
486
  this.sock.write(encodeFrame(frame), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch((err) => {
419
- const p = this.pending.get(key);
420
- 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)));
421
489
  });
422
490
  });
423
491
 
@@ -425,77 +493,77 @@ export class SubcClient {
425
493
  const unsubscribe = (): void => {
426
494
  if (cancelled) return;
427
495
  cancelled = true;
428
- // Pure-header Cancel on the held-open (channel, corr): the provider aborts its
429
- // handler and ends with StreamEnd, which settles `closed`.
430
- const cancel = buildFrame(FrameType.Cancel, buildFlags(false, priority, false), routeChannel, corr, EMPTY_BODY);
431
- this.sock.write(encodeFrame(cancel), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => {
432
- // Best-effort: if the socket is already gone, the read loop fails the
433
- // pending waiter and `closed` rejects on its own.
434
- });
496
+ this.cancel(handle, corr, priority);
435
497
  };
436
-
437
498
  return { unsubscribe, closed };
438
499
  }
439
500
 
440
- /**
441
- * Tear down ONE managed route (a route opened via `call()`), keyed by its
442
- * (target, identity). Idempotent and never throws — callers over-call on
443
- * session-end. The teardown:
444
- * - flips a tombstone on the cached route and removes it from the cache, so an
445
- * in-flight `openCachedRoute` for the same key will NOT install its channel
446
- * (the generation guard: close beats a racing reopen), and a later `call()`
447
- * opens a fresh route (this is NOT a permanent tombstone);
448
- * - settles in-flight requests on the channel as RouteClosed (managed requests
449
- * keep their at-most-once classification: outcome_unknown if already sent,
450
- * not_sent otherwise; subscriptions always abort);
451
- * - sends a best-effort route GOODBYE so subc releases the route and notifies
452
- * the module to free per-session resources.
453
- * `opts.drain` waits for in-flight UNARY requests to settle before tearing down.
454
- */
455
- 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(
456
550
  target: Extract<RouteTarget, { kind: ManagedRouteKind }>,
457
551
  identity: BindIdentity,
458
- opts: CloseRouteOptions = {},
552
+ opts: ManagedCloseRouteOptions = {},
459
553
  ): Promise<void> {
460
554
  const key = routeCacheKey(target, identity, routeOpenConsumerIdentity(opts));
461
555
  const cached = this.routes.get(key);
462
- if (!cached) return; // never opened / already closed — idempotent no-op.
463
- // Generation guard: an in-flight openCachedRoute holds this same object and
464
- // re-checks `closed` before installing its channel, so flipping it here makes
465
- // close win over a racing reopen. Removing the map entry lets a later call()
466
- // create a fresh route for the key (not a permanent tombstone).
556
+ if (!cached) return;
467
557
  cached.closed = true;
468
558
  this.routes.delete(key);
469
- const channel = cached.channel;
470
- cached.channel = null;
471
- // channel === null means the route was still opening (no channel installed yet);
472
- // the racing open will see closed=true and GOODBYE whatever it opens, so there is
473
- // nothing local to tear down here.
474
- if (channel !== null) await this.closeRouteChannel(channel, opts);
559
+ const handle = cached.handle;
560
+ cached.handle = null;
561
+ if (handle) await this.closeRoute(handle, opts);
475
562
  }
476
563
 
477
- /**
478
- * Tear down ONE route by its channel number — the primitive for callers that
479
- * opened a route with `routeOpen` directly (e.g. a tool route carrying raw
480
- * {name, arguments}) and hold the channel themselves. Idempotent, never throws.
481
- * Settles in-flight requests on the channel as RouteClosed and sends a best-effort
482
- * route GOODBYE. `opts.drain` awaits in-flight UNARY requests first; subscriptions
483
- * are always aborted (a held-open stream cannot be drained).
484
- */
485
- async closeRouteChannel(channel: number, opts: CloseRouteOptions = {}): Promise<void> {
486
- if (channel === 0) return; // channel 0 is the control plane, never a route.
487
- if (opts.drain) {
488
- // Wait only for in-flight UNARY requests on this channel; subscriptions are
489
- // aborted below (a held-open stream has no natural completion to drain to).
490
- await this.drainUnaryOnChannel(channel);
491
- }
492
- // Settle anything still in flight on the channel (all of it in abort mode; only
493
- // subscriptions + late stragglers after a drain). Managed requests are classified
494
- // at-most-once via their classifyFailure; raw requests/subscriptions get a plain
495
- // RouteClosed error.
496
- this.failChannel(channel, new SubcError("route closed by closeRoute", "route_closed"));
497
- // Best-effort GOODBYE: releases the route on the daemon and notifies the module.
498
- this.sendRouteGoodbye(channel);
564
+ /** Alias retained for callers that name the operation by protocol channel; it still requires a full handle. */
565
+ async closeRouteChannel(handle: RouteHandle, opts: CloseRouteOptions = {}): Promise<void> {
566
+ await this.closeRoute(handle, opts);
499
567
  }
500
568
 
501
569
  close(): void {
@@ -504,17 +572,15 @@ export class SubcClient {
504
572
  this.sock.close();
505
573
  }
506
574
 
507
- /** Resolve once every in-flight UNARY request on the channel (snapshot at call
508
- * time) has settled. Subscriptions are excluded — they are aborted, not drained. */
509
- private drainUnaryOnChannel(channel: number): Promise<void> {
575
+ private drainUnaryOnHandle(handle: RouteHandle): Promise<void> {
510
576
  const waiters: Promise<void>[] = [];
511
577
  for (const pending of this.pending.values()) {
512
- if (pending.channel === channel && !pending.subscription) {
578
+ if (pending.handle === handle && !pending.subscription) {
513
579
  waiters.push(
514
580
  new Promise<void>((resolve) => {
515
- const prev = pending.onSettle;
581
+ const previous = pending.onSettle;
516
582
  pending.onSettle = () => {
517
- prev?.();
583
+ previous?.();
518
584
  resolve();
519
585
  };
520
586
  }),
@@ -524,13 +590,24 @@ export class SubcClient {
524
590
  return Promise.all(waiters).then(() => undefined);
525
591
  }
526
592
 
527
- /** Send a best-effort header-only route GOODBYE for `channel`. One-way: the daemon
528
- * releases the route and relays a route-gone GOODBYE to the module; no ack. */
529
- private sendRouteGoodbye(channel: number): void {
530
- if (this.closedErr) return; // connection already gone — the route died with it.
531
- const goodbye = buildFrame(FrameType.Goodbye, buildFlags(false, Priority.Interactive, false), channel, 0n, EMPTY_BODY);
532
- this.sock.write(encodeFrame(goodbye), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => {
533
- // 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();
534
611
  });
535
612
  }
536
613
 
@@ -548,39 +625,60 @@ export class SubcClient {
548
625
  return { sock, conn };
549
626
  }
550
627
 
551
- private async controlRpc(body: Uint8Array): Promise<Frame> {
552
- // Match the canonical probe: control requests go out Interactive on channel 0.
553
- 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);
554
634
  }
555
635
 
556
636
  private send(
557
- channel: number,
637
+ handle: RouteHandle | null,
558
638
  body: Uint8Array,
559
639
  priority: Priority,
640
+ admission: AdmissionClass,
560
641
  timeoutMs: number | undefined,
561
642
  onProgress: ((body: Uint8Array) => void) | undefined,
643
+ acceptFrame?: (frame: Frame) => boolean,
644
+ onLateResponse?: (frame: Frame) => void,
562
645
  ): Promise<Frame> {
646
+ if (handle) this.assertLiveHandle(handle);
563
647
  if (this.closedErr) return Promise.reject(this.closedErr);
564
- const corr = this.nextCorr++;
565
- const key = `${channel}:${corr}`;
566
- 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
+ );
567
665
 
568
666
  return new Promise<Frame>((resolve, reject) => {
569
667
  const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
570
668
  const pending: Pending = {
571
- channel,
669
+ handle,
572
670
  resolve,
573
671
  reject,
574
672
  onProgress,
575
673
  timer: null,
674
+ acceptFrame,
675
+ onLateResponse,
576
676
  };
577
- pending.timer = setTimeout(() => {
578
- this.arbitrateTimeout(key, pending, channel, corr, ms);
579
- }, ms);
677
+ pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, channel, corr, ms), ms);
580
678
  this.pending.set(key, pending);
581
- this.sock.write(encodeFrame(frame), Date.now() + ms).catch((err) => {
582
- const p = this.pending.get(key);
583
- 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)));
584
682
  });
585
683
  });
586
684
  }
@@ -597,6 +695,7 @@ export class SubcClient {
597
695
  */
598
696
  private arbitrateTimeout(key: string, pending: Pending, channel: number, corr: bigint, ms: number): void {
599
697
  const settleAsTimeout = (): void => {
698
+ if (pending.onLateResponse) this.lateResponses.set(key, pending.onLateResponse);
600
699
  this.rejectPending(
601
700
  key,
602
701
  pending,
@@ -621,87 +720,90 @@ export class SubcClient {
621
720
  setImmediate(arbitrate);
622
721
  }
623
722
 
624
- private async managedRequest(
625
- routeChannel: number,
626
- body: unknown,
627
- opts: ManagedCallOptions,
628
- ): Promise<unknown> {
723
+ private async managedRequest(handle: RouteHandle, body: unknown, opts: ManagedCallOptions): Promise<unknown> {
629
724
  const bytes = body instanceof Uint8Array ? body : this.encode(body);
630
725
  const priority = opts.priority ?? Priority.Interactive;
726
+ const admission = opts.admissionClass ?? AdmissionClass.Normal;
631
727
  try {
632
- const reply = await this.sendManaged(routeChannel, bytes, priority, opts.timeoutMs, opts.onProgress);
728
+ const reply = await this.sendManaged(handle, bytes, priority, admission, opts.timeoutMs, opts.onProgress);
633
729
  return this.parseJson(reply);
634
- } catch (err) {
635
- if (err instanceof SubcCallError) throw err;
636
- 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);
637
733
  }
638
734
  }
639
735
 
640
736
  private sendManaged(
641
- channel: number,
737
+ handle: RouteHandle,
642
738
  body: Uint8Array,
643
739
  priority: Priority,
740
+ admission: AdmissionClass,
644
741
  timeoutMs: number | undefined,
645
742
  onProgress: ((body: Uint8Array) => void) | undefined,
646
743
  ): Promise<Frame> {
744
+ try {
745
+ this.assertLiveHandle(handle);
746
+ } catch (error) {
747
+ return Promise.reject(this.notSentCallError("request used a stale route handle", error));
748
+ }
647
749
  if (this.closedErr) {
648
- return Promise.reject(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
+ );
649
753
  }
650
754
 
651
- const corr = this.nextCorr++;
652
- const key = `${channel}:${corr}`;
653
- 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
+ );
654
770
  let handedToSocket = false;
655
771
 
656
- const classifyFailure = (err: Error): SubcCallError => {
657
- // This is the load-bearing asymmetry: only the pre-write paths are NotSent.
658
- // As soon as writeTracked reports that bytes were queued to Node's socket,
659
- // those bytes may already be in the OS buffer or at the daemon. Any later
660
- // close, write callback error, route GOODBYE, or timeout before a response is
661
- // therefore OutcomeUnknown to avoid an unsafe double-mutation retry.
662
- if (!handedToSocket) {
663
- return this.notSentCallError("request bytes were not queued to the subc socket", err);
664
- }
665
- // A request-deadline timeout (arbitration expired without observing a drop)
666
- // is refined from a real connection drop: the socket was NOT seen to fail, so
667
- // the caller can skip a was-it-even-sent recovery path. Still outcome_unknown
668
- // — queued-to-local-socket is not proof the daemon received or ran it.
669
- if (err instanceof SubcError && err.code === REQUEST_DEADLINE_MARKER) {
772
+ const classifyFailure = (error: Error): SubcCallError => {
773
+ if (!handedToSocket) return this.notSentCallError("request bytes were not queued to the subc socket", error);
774
+ if (error instanceof SubcError && error.code === REQUEST_DEADLINE_MARKER) {
670
775
  return new SubcCallError(
671
776
  "outcome_unknown",
672
- `managed call deadline exceeded after request bytes were queued to the local socket; no terminal response was observed; outcome unknown${causeMessage(err)}`,
777
+ `managed call deadline exceeded after request bytes were queued to the local socket; no terminal response was observed; outcome unknown${causeMessage(error)}`,
673
778
  DEADLINE_NO_DROP_CODE,
674
- err,
779
+ error,
675
780
  );
676
781
  }
677
- 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);
678
783
  };
679
784
 
680
785
  return new Promise<Frame>((resolve, reject) => {
681
786
  const ms = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
682
787
  const pending: Pending = {
683
- channel,
788
+ handle,
684
789
  resolve,
685
790
  reject,
686
791
  onProgress,
687
792
  timer: null,
688
793
  classifyFailure,
689
794
  };
690
- pending.timer = setTimeout(() => {
691
- this.arbitrateTimeout(key, pending, channel, corr, ms);
692
- }, ms);
795
+ pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, handle.channel, corr, ms), ms);
693
796
  this.pending.set(key, pending);
694
-
695
797
  const write = this.sock.writeTracked(encodeFrame(frame), Date.now() + ms);
696
798
  handedToSocket = write.queued;
697
- write.completed.catch((err) => {
698
- const p = this.pending.get(key);
699
- 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)));
700
802
  });
701
803
  });
702
804
  }
703
805
 
704
- private async cachedRouteChannel(moduleId: string, opts: ManagedCallOptions): Promise<number> {
806
+ private async cachedRouteHandle(moduleId: string, opts: ManagedCallOptions): Promise<RouteHandle> {
705
807
  const identity = opts.identity ?? this.opts.identity;
706
808
  if (!identity) {
707
809
  throw new SubcCallError(
@@ -710,7 +812,6 @@ export class SubcClient {
710
812
  "missing_identity",
711
813
  );
712
814
  }
713
-
714
815
  const target = { kind: opts.targetKind ?? this.opts.targetKind, module_id: moduleId } as Extract<
715
816
  RouteTarget,
716
817
  { kind: ManagedRouteKind }
@@ -725,16 +826,12 @@ export class SubcClient {
725
826
  target,
726
827
  identity,
727
828
  consumerIdentity,
728
- channel: null,
729
- generation: 0,
829
+ handle: null,
730
830
  opening: null,
731
831
  };
732
832
  this.routes.set(key, cached);
733
833
  }
734
-
735
- if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
736
- return cached.channel;
737
- }
834
+ if (cached.handle && this.isLiveHandle(cached.handle)) return cached.handle;
738
835
  if (!cached.opening) {
739
836
  cached.opening = this.openCachedRoute(cached).finally(() => {
740
837
  cached.opening = null;
@@ -743,7 +840,7 @@ export class SubcClient {
743
840
  return cached.opening;
744
841
  }
745
842
 
746
- private async openCachedRoute(cached: CachedRoute): Promise<number> {
843
+ private async openCachedRoute(cached: CachedRoute): Promise<RouteHandle> {
747
844
  const routeRetryDeadline = Date.now() + ROUTE_OPEN_RETRY_DEADLINE_MS;
748
845
  let routeRetryDelay = this.opts.reconnectBackoff.baseMs;
749
846
  let routeRetryAttempt = 0;
@@ -751,47 +848,33 @@ export class SubcClient {
751
848
  if (cached.closed) throw this.routeClosedDuringOpen();
752
849
  try {
753
850
  await this.ensureConnectedForManaged();
754
- } catch (err) {
755
- throw this.notSentRecoveryError("route.open could not run because reconnect failed", err);
756
- }
757
-
758
- if (cached.channel !== null && cached.generation === this.generation && !this.closedErr) {
759
- return cached.channel;
851
+ } catch (error) {
852
+ throw this.notSentRecoveryError("route.open could not run because reconnect failed", error);
760
853
  }
854
+ if (cached.handle && this.isLiveHandle(cached.handle)) return cached.handle;
761
855
 
762
856
  try {
763
- const channel = await this.routeOpen(cached.target, cached.identity, {
857
+ const handle = await this.routeOpen(cached.target, cached.identity, {
764
858
  consumerIdentity: cached.consumerIdentity ?? null,
765
859
  });
766
- // Generation guard: a closeRoute may have flipped the tombstone WHILE this
767
- // route.open was in flight. If so, close wins — do NOT install the channel
768
- // into the (already-removed) cache entry; GOODBYE the channel we just opened
769
- // so the daemon/module don't leak it, and fail as RouteClosed.
770
860
  if (cached.closed) {
771
- this.sendRouteGoodbye(channel);
861
+ this.liveRoutes.delete(handle.channel);
862
+ this.sendRouteGoodbye(handle);
772
863
  throw this.routeClosedDuringOpen();
773
864
  }
774
- cached.channel = channel;
775
- cached.generation = this.generation;
776
- return channel;
777
- } catch (err) {
778
- if (err instanceof SubcCallError && err.code === "route_closed") throw err;
779
- if (!this.closeStarted && isConsumerReconnectTransient(err)) {
865
+ cached.handle = handle;
866
+ return handle;
867
+ } catch (error) {
868
+ if (error instanceof SubcCallError && error.code === "route_closed") throw error;
869
+ if (!this.closeStarted && isConsumerReconnectTransient(error)) {
780
870
  try {
781
- await this.reconnectAfterDrop(err);
782
- } catch (reconnectErr) {
783
- 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);
784
874
  }
785
875
  continue;
786
876
  }
787
- // A daemon-rejected route.open with a RETRYABLE code (target booting /
788
- // reloading / momentarily absent) is retried IN-PLACE against the same live
789
- // connection — never a socket reconnect, which would needlessly disrupt this
790
- // connection's other routes — until the route-retry deadline. Past the
791
- // deadline it surfaces as not_sent: provably pre-send (no data frame ever
792
- // left the client) AND still transient, so the caller's own retry policy may
793
- // safely re-attempt later. Reason and set kept in parity with subc-client-rs.
794
- if (!this.closeStarted && err instanceof SubcError && isRetryableRouteOpenCode(err.code)) {
877
+ if (!this.closeStarted && error instanceof SubcError && isRetryableRouteOpenCode(error.code)) {
795
878
  routeRetryAttempt += 1;
796
879
  if (routeRetryAttempt < this.opts.reconnectBackoff.maxAttempts && Date.now() < routeRetryDeadline) {
797
880
  await this.opts.sleep(routeRetryDelay);
@@ -799,15 +882,11 @@ export class SubcClient {
799
882
  continue;
800
883
  }
801
884
  throw this.notSentCallError(
802
- `route.open failed for module ${cached.moduleId}: ${err.code} (retry budget exhausted)`,
803
- err,
885
+ `route.open failed for module ${cached.moduleId}: ${error.code} (retry budget exhausted)`,
886
+ error,
804
887
  );
805
888
  }
806
- // A permanent route.open rejection (bad_consumer_identity, config_divergence,
807
- // unknown_target, ...) is pre-send but would never succeed on retry, so it
808
- // stays terminal — a not_sent class here would invite a retry storm against a
809
- // request the daemon will always reject.
810
- throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, err);
889
+ throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, error);
811
890
  }
812
891
  }
813
892
  }
@@ -882,33 +961,26 @@ export class SubcClient {
882
961
  this.currentConn = opened.conn;
883
962
  this.closedErr = null;
884
963
  this.generation += 1;
964
+ this.connectionToken = newConnectionToken();
965
+ this.liveRoutes.clear();
966
+ this.lateResponses.clear();
967
+ this.nextCorr = 1n;
885
968
  void this.readLoop(opened.sock, this.generation);
886
969
  }
887
970
 
888
971
  private async reopenCachedRoutes(): Promise<void> {
972
+ for (const cached of this.routes.values()) cached.handle = null;
889
973
  for (const cached of this.routes.values()) {
890
- cached.channel = null;
891
- cached.generation = 0;
892
- }
893
- for (const cached of this.routes.values()) {
894
- if (cached.closed) continue; // closed concurrently with reconnect — don't reopen.
895
- // Thread the route's consumer identity through the reopen, exactly as the
896
- // lazy per-call path (openCachedRoute) does. Dropping it here would make a
897
- // route reopened after a reconnect send route.open with no consumer_identity,
898
- // so the daemon would re-stamp it with a different (weaker) principal than the
899
- // one it was originally bound under — a silent post-reconnect trust downgrade.
900
- const channel = await this.routeOpen(cached.target, cached.identity, {
974
+ if (cached.closed) continue;
975
+ const handle = await this.routeOpen(cached.target, cached.identity, {
901
976
  consumerIdentity: cached.consumerIdentity ?? null,
902
977
  });
903
- // A closeRoute may have raced this reopen (flipping the tombstone during the
904
- // route.open await). If so, GOODBYE the channel instead of installing it, so the
905
- // closed route isn't silently re-established on the new connection.
906
978
  if (cached.closed) {
907
- this.sendRouteGoodbye(channel);
979
+ this.liveRoutes.delete(handle.channel);
980
+ this.sendRouteGoodbye(handle);
908
981
  continue;
909
982
  }
910
- cached.channel = channel;
911
- cached.generation = this.generation;
983
+ cached.handle = handle;
912
984
  }
913
985
  }
914
986
 
@@ -929,41 +1001,41 @@ export class SubcClient {
929
1001
  private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
930
1002
  try {
931
1003
  for (;;) {
932
- // Header read waits indefinitely — idle time between frames is normal, and
933
- // the reader is NOT "active" while parked here (a racing timeout must not
934
- // grant grace just because the connection is idle between frames).
935
- const headerBytes = await sock.readExact(HEADER_LEN, Number.POSITIVE_INFINITY);
936
- // A frame is now arriving. Mark the reader active THROUGH dispatch so a
937
- // timeout that fires mid-arrival grants the reply its bounded grace window
938
- // instead of settling as a spurious timeout.
939
- 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
+ );
940
1012
  try {
941
- const header = decodeHeader(headerBytes);
942
- const body =
943
- header.len === 0
944
- ? new Uint8Array(0)
945
- : await sock.readExact(header.len, Date.now() + BODY_READ_TIMEOUT_MS);
946
- // Drop a frame read off a socket this client has already replaced
947
- // (reconnect): its pendings were settled by fail(), and dispatching it
948
- // against the current pending map could match a re-used (channel, corr).
949
- if (this.sock === sock && this.generation === generation) {
950
- this.dispatch({ header, body });
951
- }
1013
+ if (this.sock === sock && this.generation === generation) this.dispatch(frame);
952
1014
  } finally {
953
1015
  this.readerActive = false;
954
1016
  }
955
1017
  }
956
- } catch (err) {
1018
+ } catch (error) {
957
1019
  if (this.sock === sock && this.generation === generation) {
958
- this.fail(err instanceof Error ? err : new SubcError(String(err)));
1020
+ this.fail(error instanceof Error ? error : new SubcError(String(error)));
959
1021
  }
960
1022
  }
961
1023
  }
962
1024
 
963
1025
  private dispatch(frame: Frame): void {
964
- 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);
965
1036
  const pending = this.pending.get(key);
966
1037
  if (pending) {
1038
+ if (pending.acceptFrame && !pending.acceptFrame(frame)) return;
967
1039
  switch (frame.header.ty) {
968
1040
  case FrameType.Push:
969
1041
  case FrameType.StreamData:
@@ -980,32 +1052,34 @@ export class SubcClient {
980
1052
  return;
981
1053
  }
982
1054
  }
983
- if (frame.header.ty === FrameType.Goodbye) {
984
- this.failChannel(frame.header.channel, new SubcError("route closed by subc (GOODBYE)"));
1055
+
1056
+ const late = this.lateResponses.get(key);
1057
+ if (late && (frame.header.ty === FrameType.Response || frame.header.ty === FrameType.Error)) {
1058
+ this.lateResponses.delete(key);
1059
+ late(frame);
1060
+ return;
1061
+ }
1062
+
1063
+ if (frame.header.ty === FrameType.Goodbye && handle) {
1064
+ this.failHandle(handle, new SubcError("route closed by subc (GOODBYE)"));
1065
+ if (this.liveRoutes.get(handle.channel) === handle) this.liveRoutes.delete(handle.channel);
1066
+ this.evictRouteHandle(handle);
985
1067
  return;
986
1068
  }
987
- // A terminal frame (Response/Error/StreamEnd) with no waiter is almost always
988
- // a reply that arrived AFTER its request already settled — the fingerprint of a
989
- // premature timeout under event-loop starvation (the reply raced the deadline
990
- // and lost). Metadata-only debug log (never the body) so every future
991
- // occurrence is a one-line diagnosis instead of an invisible drop. Enable with
992
- // NODE_DEBUG=subc-client.
993
1069
  if (
994
1070
  frame.header.ty === FrameType.Response ||
995
1071
  frame.header.ty === FrameType.Error ||
996
1072
  frame.header.ty === FrameType.StreamEnd
997
1073
  ) {
998
1074
  debug(
999
- "dropped terminal frame with no waiter: type=%d channel=%d corr=%s port=%s",
1075
+ "dropped terminal frame with no waiter: type=%d channel=%d epoch=%d corr=%s port=%s",
1000
1076
  frame.header.ty,
1001
1077
  frame.header.channel,
1078
+ frame.header.epoch,
1002
1079
  frame.header.corr,
1003
1080
  this.sock.localPort() ?? "?",
1004
1081
  );
1005
- return;
1006
1082
  }
1007
- // Unmatched Push or stray frame: no registered waiter. Drop it — v1 has no
1008
- // unsolicited-push consumers.
1009
1083
  }
1010
1084
 
1011
1085
  /**
@@ -1042,11 +1116,15 @@ export class SubcClient {
1042
1116
  }
1043
1117
  }
1044
1118
 
1045
- private failChannel(channel: number, err: Error): void {
1119
+ private evictRouteHandle(handle: RouteHandle): void {
1120
+ for (const cached of this.routes.values()) {
1121
+ if (cached.handle && sameRouteHandle(cached.handle, handle)) cached.handle = null;
1122
+ }
1123
+ }
1124
+
1125
+ private failHandle(handle: RouteHandle, error: Error): void {
1046
1126
  for (const [key, pending] of this.pending) {
1047
- if (pending.channel === channel) {
1048
- this.rejectPending(key, pending, err);
1049
- }
1127
+ if (pending.handle && sameRouteHandle(pending.handle, handle)) this.rejectPending(key, pending, error);
1050
1128
  }
1051
1129
  }
1052
1130
 
@@ -1076,6 +1154,50 @@ export class SubcClient {
1076
1154
  return this.terminalCallError(message, cause);
1077
1155
  }
1078
1156
 
1157
+ /** Number of nonzero-channel ingress frames dropped by endpoint epoch validation. */
1158
+ get droppedIngressFrames(): number {
1159
+ return this.ingressEpochDropCount;
1160
+ }
1161
+
1162
+ private installRoute(channel: number, epoch: number): RouteHandle {
1163
+ const handle = createRouteHandle(channel, epoch, this.connectionToken);
1164
+ this.liveRoutes.set(channel, handle);
1165
+ return handle;
1166
+ }
1167
+
1168
+ private isLiveHandle(handle: RouteHandle): boolean {
1169
+ return belongsToConnection(handle, this.connectionToken) && this.liveRoutes.get(handle.channel) === handle;
1170
+ }
1171
+
1172
+ private assertLiveConnection(handle: RouteHandle): void {
1173
+ if (!belongsToConnection(handle, this.connectionToken)) throw new StaleRouteHandleError(handle);
1174
+ }
1175
+
1176
+ private assertLiveHandle(handle: RouteHandle): void {
1177
+ if (!this.isLiveHandle(handle)) throw new StaleRouteHandleError(handle);
1178
+ }
1179
+
1180
+ private allocateCorr(): bigint {
1181
+ const maximum = 0xffff_ffff_ffff_ffffn;
1182
+ if (this.nextCorr > maximum) {
1183
+ const error = new SubcError("channel-0 correlation id allocator exhausted", "corr_exhausted");
1184
+ this.fail(error);
1185
+ this.sock.close();
1186
+ this.scheduleReconnectAfterDrop(error);
1187
+ throw error;
1188
+ }
1189
+ const corr = this.nextCorr;
1190
+ this.nextCorr += 1n;
1191
+ return corr;
1192
+ }
1193
+
1194
+ private closeConnectionAfterCleanupFailure(): void {
1195
+ const error = new SubcError("late route cleanup could not be queued", "late_route_cleanup_failed");
1196
+ this.fail(error);
1197
+ this.sock.close();
1198
+ this.scheduleReconnectAfterDrop(error);
1199
+ }
1200
+
1079
1201
  private encode(value: unknown): Uint8Array {
1080
1202
  return new Uint8Array(Buffer.from(JSON.stringify(value), "utf8"));
1081
1203
  }
@@ -1178,3 +1300,7 @@ function causeMessage(cause: unknown): string {
1178
1300
  if (cause === undefined) return "";
1179
1301
  return `: ${cause instanceof Error ? cause.message : String(cause)}`;
1180
1302
  }
1303
+
1304
+ function pendingKey(handle: RouteHandle | null, corr: bigint): string {
1305
+ return handle ? `${handle.channel}:${handle.epoch}:${corr}` : `0:0:${corr}`;
1306
+ }