@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/provider.ts CHANGED
@@ -1,20 +1,32 @@
1
1
  import { Buffer } from "node:buffer";
2
2
 
3
3
  import { AuthError, authenticateClient } from "./auth.js";
4
- import { DEFAULT_RECONNECT_BACKOFF, type BindIdentity, type ReconnectBackoff, type RouteTarget } from "./client.js";
4
+ import {
5
+ DEFAULT_RECONNECT_BACKOFF,
6
+ type BindIdentity,
7
+ type ReconnectBackoff,
8
+ type RequestOptions,
9
+ type RouteTarget,
10
+ } from "./client.js";
5
11
  import { ConnectionFileError, readConnectionFile, type ConnectionInfo } from "./connection-file.js";
6
12
  import {
13
+ AdmissionClass,
7
14
  buildFlags,
8
15
  buildFrame,
9
16
  buildFrameWithVersion,
10
- decodeHeader,
11
17
  encodeFrame,
12
18
  FrameType,
13
- HEADER_LEN,
14
19
  Priority,
15
20
  PROTOCOL_VERSION,
16
21
  type Frame,
17
22
  } from "./envelope.js";
23
+ import {
24
+ belongsToConnection,
25
+ createRouteHandle,
26
+ newConnectionToken,
27
+ RouteHandle,
28
+ StaleRouteHandleError,
29
+ } from "./route-handle.js";
18
30
  import {
19
31
  SocketClosedError,
20
32
  SocketTimeoutError,
@@ -181,12 +193,14 @@ export interface ManagementSurfaceManifestOptions {
181
193
  * events and `signal` to learn when the consumer cancelled or the route went away.
182
194
  */
183
195
  export interface ProviderRequestContext {
196
+ /** The immutable route generation that received this request. */
197
+ readonly handle: RouteHandle;
184
198
  /**
185
199
  * Emit an interim event as a StreamData frame on this request's (channel, corr).
186
200
  * The consumer receives it via its subscription `onEvent`. A no-op once the
187
201
  * request has been aborted (cancelled or route-gone).
188
202
  */
189
- emit(body: Uint8Array): Promise<void>;
203
+ emit(body: Uint8Array, opts?: ProviderEmitOptions): Promise<void>;
190
204
  /** Aborts when the consumer sends Cancel for this request, or the route is torn down. */
191
205
  signal: AbortSignal;
192
206
  /**
@@ -207,7 +221,7 @@ export interface ProviderRequestContext {
207
221
  * events via `ctx.emit`). Throwing produces an Error terminal.
208
222
  */
209
223
  export type ProviderHandler = (
210
- routeChannel: number,
224
+ handle: RouteHandle,
211
225
  body: Uint8Array,
212
226
  ctx: ProviderRequestContext,
213
227
  ) => Promise<Uint8Array | void> | Uint8Array | void;
@@ -220,10 +234,17 @@ export type Principal =
220
234
  | { kind: "unverified" };
221
235
 
222
236
  export interface RouteBindRequest {
223
- route_channel: number;
237
+ handle: RouteHandle;
224
238
  target: RouteTarget;
225
239
  identity: BindIdentity;
226
240
  principal?: Principal;
241
+ /** Consumer-declared reverse-request capabilities for this bind. 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". */
242
+ consumer_capabilities?: string[];
243
+ }
244
+
245
+ export interface ProviderEmitOptions {
246
+ priority?: Priority;
247
+ admissionClass?: AdmissionClass;
227
248
  }
228
249
 
229
250
  export type BindDecision =
@@ -248,7 +269,9 @@ export interface SubcProviderConnectOptions {
248
269
  handshakeTimeoutMs?: number;
249
270
  controlOps?: string[] | null;
250
271
  onBind?: (request: RouteBindRequest) => Promise<BindDecision> | BindDecision;
251
- onRouteGone?: (routeChannel: number) => void | Promise<void>;
272
+ /** Runs only after an accepted bind ack is queued and the handle is installed. */
273
+ onBound?: (handle: RouteHandle) => void | Promise<void>;
274
+ onRouteGone?: (handle: RouteHandle) => void | Promise<void>;
252
275
  /** Backoff for provider reconnect after an unexpected socket drop. */
253
276
  reconnectBackoff?: ReconnectBackoff;
254
277
  /** Injectable sleep for timer-free reconnect and debounce tests. */
@@ -273,7 +296,9 @@ interface NormalizedSubcProviderConnectOptions {
273
296
  handshakeTimeoutMs?: number;
274
297
  controlOps?: string[] | null;
275
298
  onBind?: (request: RouteBindRequest) => Promise<BindDecision> | BindDecision;
276
- onRouteGone?: (routeChannel: number) => void | Promise<void>;
299
+ /** Runs only after an accepted bind ack is queued and the handle is installed. */
300
+ onBound?: (handle: RouteHandle) => void | Promise<void>;
301
+ onRouteGone?: (handle: RouteHandle) => void | Promise<void>;
277
302
  reconnectBackoff: ReconnectBackoff;
278
303
  sleep: (ms: number) => Promise<void>;
279
304
  restoredDebounceMs: number;
@@ -386,11 +411,11 @@ export function managementSurfaceManifest(opts: ManagementSurfaceManifestOptions
386
411
  }
387
412
 
388
413
  export function jsonProviderHandler<Request = unknown, Response = unknown>(
389
- handler: (routeChannel: number, request: Request) => Promise<Response> | Response,
414
+ handler: (handle: RouteHandle, request: Request) => Promise<Response> | Response,
390
415
  ): ProviderHandler {
391
- return async (routeChannel, body) => {
416
+ return async (handle, body) => {
392
417
  const request = JSON.parse(Buffer.from(body).toString("utf8")) as Request;
393
- const response = await handler(routeChannel, request);
418
+ const response = await handler(handle, request);
394
419
  return encodeJson(response);
395
420
  };
396
421
  }
@@ -404,6 +429,11 @@ export class SubcProvider {
404
429
  // A socket drop only makes the reply path stale; handlers may still finish their
405
430
  // durable work, and their late sends are ignored by the generation guard.
406
431
  private readonly inflight = new Map<string, AbortController>();
432
+ private readonly pending = new Map<string, { resolve: (frame: Frame) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>();
433
+ private readonly liveRoutes = new Map<number, RouteHandle>();
434
+ private connectionToken = newConnectionToken();
435
+ private nextCorr = 1n;
436
+ private ingressEpochDropCount = 0;
407
437
  private readonly requestGate = new AsyncPermitPool(DEFAULT_PROVIDER_HANDLER_CAPACITY);
408
438
  private reconnecting: Promise<void> | null = null;
409
439
  private generation = 1;
@@ -433,6 +463,11 @@ export class SubcProvider {
433
463
  this.enqueueConnectionState({ state: "connected", epoch: this.connectionEpoch });
434
464
  }
435
465
 
466
+ /** Number of nonzero-channel ingress frames rejected by endpoint validation. */
467
+ get droppedIngressFrames(): number {
468
+ return this.ingressEpochDropCount;
469
+ }
470
+
436
471
  get conn(): ConnectionInfo {
437
472
  return this.currentConn;
438
473
  }
@@ -441,6 +476,86 @@ export class SubcProvider {
441
476
  return this.connectionEpoch;
442
477
  }
443
478
 
479
+ /** Send a reverse request on exactly one installed route generation. */
480
+ async request(handle: RouteHandle, body: Uint8Array, opts: RequestOptions = {}): Promise<Uint8Array> {
481
+ this.assertLiveHandle(handle);
482
+ const corr = this.allocateCorr();
483
+ const key = routeKey(handle, corr);
484
+ const timeoutMs = opts.timeoutMs ?? WRITE_TIMEOUT_MS;
485
+ const frame = buildFrame(
486
+ FrameType.Request,
487
+ buildFlags(
488
+ false,
489
+ opts.priority ?? Priority.Interactive,
490
+ false,
491
+ opts.admissionClass ?? AdmissionClass.Normal,
492
+ ),
493
+ handle.channel,
494
+ handle.epoch,
495
+ corr,
496
+ body,
497
+ );
498
+ return await new Promise<Uint8Array>((resolve, reject) => {
499
+ const timer = setTimeout(() => {
500
+ if (this.pending.delete(key)) reject(new SubcProviderError("reverse request timed out", "request_timeout"));
501
+ }, timeoutMs);
502
+ this.pending.set(key, {
503
+ resolve: (response) => resolve(response.body),
504
+ reject,
505
+ timer,
506
+ });
507
+ this.sendOn(this.sock, this.generation, frame).catch((error) => {
508
+ const pending = this.pending.get(key);
509
+ if (!pending) return;
510
+ this.pending.delete(key);
511
+ clearTimeout(pending.timer);
512
+ reject(error instanceof Error ? error : new SubcProviderError(String(error)));
513
+ });
514
+ });
515
+ }
516
+
517
+ /** Emit an unsolicited Push after onBound has published the route. */
518
+ async push(handle: RouteHandle, body: Uint8Array, opts: ProviderEmitOptions = {}): Promise<void> {
519
+ this.assertLiveHandle(handle);
520
+ await this.sendOn(
521
+ this.sock,
522
+ this.generation,
523
+ buildFrame(
524
+ FrameType.Push,
525
+ buildFlags(
526
+ false,
527
+ opts.priority ?? Priority.Interactive,
528
+ false,
529
+ opts.admissionClass ?? AdmissionClass.Normal,
530
+ ),
531
+ handle.channel,
532
+ handle.epoch,
533
+ 0n,
534
+ body,
535
+ ),
536
+ );
537
+ }
538
+
539
+ cancel(handle: RouteHandle, corr: bigint): void {
540
+ this.assertLiveHandle(handle);
541
+ void this.sendOn(
542
+ this.sock,
543
+ this.generation,
544
+ buildFrame(FrameType.Cancel, controlFlags(), handle.channel, handle.epoch, corr, new Uint8Array(0)),
545
+ );
546
+ }
547
+
548
+ closeRoute(handle: RouteHandle): void {
549
+ this.assertLiveHandle(handle);
550
+ this.liveRoutes.delete(handle.channel);
551
+ this.abortHandle(handle);
552
+ void this.sendOn(
553
+ this.sock,
554
+ this.generation,
555
+ buildFrame(FrameType.Goodbye, controlFlags(), handle.channel, handle.epoch, 0n, new Uint8Array(0)),
556
+ );
557
+ }
558
+
444
559
  /** Read the connection file, authenticate as a client, register the manifest with HELLO, and serve frames. */
445
560
  static async connect(opts: SubcProviderConnectOptions): Promise<SubcProvider> {
446
561
  if (opts.manifest.protocol_ver !== PROTOCOL_VERSION) {
@@ -461,7 +576,7 @@ export class SubcProvider {
461
576
  this.cancelRestoredDebounce();
462
577
  const sock = this.sock;
463
578
  try {
464
- await sendFrame(sock, buildFrame(FrameType.Goodbye, controlFlags(), 0, 0n, new Uint8Array(0)));
579
+ await sendFrame(sock, buildFrame(FrameType.Goodbye, controlFlags(), 0, 0, 0n, new Uint8Array(0)));
465
580
  } catch {
466
581
  // The daemon may already have closed the connection; close() remains best-effort.
467
582
  } finally {
@@ -491,22 +606,16 @@ export class SubcProvider {
491
606
  private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
492
607
  try {
493
608
  for (;;) {
494
- // Header read waits indefinitely idle time between frames is normal.
495
- const headerBytes = await sock.readExact(HEADER_LEN, Number.POSITIVE_INFINITY);
496
- const header = decodeHeader(headerBytes);
497
- const body =
498
- header.len === 0
499
- ? new Uint8Array(0)
500
- : await sock.readExact(header.len, Date.now() + BODY_READ_TIMEOUT_MS);
501
- const keepGoing = await this.dispatch({ header, body }, sock, generation);
609
+ const frame = await sock.readFrame(Number.POSITIVE_INFINITY, Date.now() + BODY_READ_TIMEOUT_MS);
610
+ const keepGoing = await this.dispatch(frame, sock, generation);
502
611
  if (!keepGoing) {
503
612
  if (this.sock === sock && this.generation === generation) this.closeStarted = true;
504
613
  break;
505
614
  }
506
615
  }
507
- } catch (err) {
616
+ } catch (error) {
508
617
  if (this.sock === sock && this.generation === generation && !this.closeStarted) {
509
- this.handleUnexpectedDrop(sock, generation, err instanceof Error ? err : new SubcProviderError(String(err)));
618
+ this.handleUnexpectedDrop(sock, generation, error instanceof Error ? error : new SubcProviderError(String(error)));
510
619
  return;
511
620
  }
512
621
  } finally {
@@ -518,6 +627,35 @@ export class SubcProvider {
518
627
  }
519
628
 
520
629
  private async dispatch(frame: Frame, sock: SubcSocket, generation: number): Promise<boolean> {
630
+ let handle: RouteHandle | null = null;
631
+ if (frame.header.channel !== 0) {
632
+ handle = this.liveRoutes.get(frame.header.channel) ?? null;
633
+ if (!handle || handle.epoch !== frame.header.epoch) {
634
+ this.ingressEpochDropCount += 1;
635
+ return true;
636
+ }
637
+ }
638
+
639
+ if (handle) {
640
+ const pendingKey = routeKey(handle, frame.header.corr);
641
+ const pending = this.pending.get(pendingKey);
642
+ if (pending) {
643
+ if (frame.header.ty === FrameType.Push || frame.header.ty === FrameType.StreamData) return true;
644
+ if (frame.header.ty === FrameType.Response || frame.header.ty === FrameType.StreamEnd) {
645
+ this.pending.delete(pendingKey);
646
+ clearTimeout(pending.timer);
647
+ pending.resolve(frame);
648
+ return true;
649
+ }
650
+ if (frame.header.ty === FrameType.Error) {
651
+ this.pending.delete(pendingKey);
652
+ clearTimeout(pending.timer);
653
+ pending.reject(providerErrorFromFrame(frame));
654
+ return true;
655
+ }
656
+ }
657
+ }
658
+
521
659
  switch (frame.header.ty) {
522
660
  case FrameType.Ping:
523
661
  if (frame.header.channel === 0) {
@@ -529,6 +667,7 @@ export class SubcProvider {
529
667
  FrameType.Pong,
530
668
  frame.header.flags,
531
669
  0,
670
+ 0,
532
671
  frame.header.corr,
533
672
  new Uint8Array(0),
534
673
  ),
@@ -536,24 +675,21 @@ export class SubcProvider {
536
675
  }
537
676
  return true;
538
677
  case FrameType.Goodbye:
539
- if (frame.header.channel === 0) return false;
540
- // The route is gone: abort in-flight requests on that route so streaming
541
- // handlers unwind, then notify the provider owner.
542
- this.abortChannel(generation, frame.header.channel);
543
- await this.opts.onRouteGone?.(frame.header.channel);
678
+ if (!handle) return false;
679
+ this.liveRoutes.delete(handle.channel);
680
+ this.abortHandle(handle);
681
+ await this.opts.onRouteGone?.(handle);
544
682
  return true;
545
683
  case FrameType.Cancel:
546
- // The consumer cancelled one request: abort the matching handler. Its
547
- // streaming handler observes ctx.signal and ends with a StreamEnd terminal.
548
- this.inflight.get(routeKey(generation, frame.header.channel, frame.header.corr))?.abort();
684
+ if (handle) this.inflight.get(routeKey(handle, frame.header.corr))?.abort();
549
685
  return true;
550
686
  case FrameType.Request:
551
687
  if (frame.header.channel === 0) {
552
688
  await this.handleControlRequest(frame, sock, generation);
553
- } else {
554
- void this.handleDataRequest(frame, sock, generation).catch((err) => {
689
+ } else if (handle) {
690
+ void this.handleDataRequest(frame, handle, sock, generation).catch((error) => {
555
691
  if (!this.closeStarted && this.sock === sock && this.generation === generation) {
556
- console.warn("SubcProvider handler failed after its request was dispatched", err);
692
+ console.warn("SubcProvider handler failed after its request was dispatched", error);
557
693
  }
558
694
  });
559
695
  }
@@ -563,19 +699,27 @@ export class SubcProvider {
563
699
  }
564
700
  }
565
701
 
566
- /** Abort every in-flight request on a route channel for the current socket generation. */
567
- private abortChannel(generation: number, channel: number): void {
568
- const prefix = `${generation}:${channel}:`;
702
+ private abortHandle(handle: RouteHandle): void {
703
+ const prefix = `${handle.channel}:${handle.epoch}:`;
569
704
  for (const [key, controller] of this.inflight) {
570
705
  if (key.startsWith(prefix)) controller.abort();
571
706
  }
707
+ for (const [key, pending] of this.pending) {
708
+ if (!key.startsWith(prefix)) continue;
709
+ this.pending.delete(key);
710
+ clearTimeout(pending.timer);
711
+ pending.reject(new StaleRouteHandleError(handle));
712
+ }
572
713
  }
573
714
 
574
- private abortGeneration(generation: number): void {
575
- const prefix = `${generation}:`;
576
- for (const [key, controller] of this.inflight) {
577
- if (key.startsWith(prefix)) controller.abort();
715
+ private abortGeneration(_generation: number): void {
716
+ this.abortAllInflight();
717
+ for (const [key, pending] of this.pending) {
718
+ this.pending.delete(key);
719
+ clearTimeout(pending.timer);
720
+ pending.reject(new SubcProviderError("provider connection dropped", "connection_dropped"));
578
721
  }
722
+ this.liveRoutes.clear();
579
723
  }
580
724
 
581
725
  private abortAllInflight(): void {
@@ -583,11 +727,19 @@ export class SubcProvider {
583
727
  }
584
728
 
585
729
  private async handleControlRequest(frame: Frame, sock: SubcSocket, generation: number): Promise<void> {
586
- const request = parseJson(frame.body) as Partial<RouteBindRequest> & { op?: string };
730
+ const request = parseJson(frame.body) as {
731
+ op?: string;
732
+ route_channel?: unknown;
733
+ epoch?: unknown;
734
+ target?: unknown;
735
+ identity?: unknown;
736
+ principal?: unknown;
737
+ consumer_capabilities?: unknown;
738
+ };
587
739
  if (request.op === HEALTH_CHECK_OP) {
588
- void this.handleHealthRequest(frame, sock, generation).catch((err) => {
740
+ void this.handleHealthRequest(frame, sock, generation).catch((error) => {
589
741
  if (!this.closeStarted && this.sock === sock && this.generation === generation) {
590
- console.warn("SubcProvider health handler failed after its request was dispatched", err);
742
+ console.warn("SubcProvider health handler failed after its request was dispatched", error);
591
743
  }
592
744
  });
593
745
  return;
@@ -596,81 +748,154 @@ export class SubcProvider {
596
748
  throw new SubcProviderError(`unsupported module control request ${request.op ?? "<missing op>"}`);
597
749
  }
598
750
 
751
+ const tentative = createRouteHandle(
752
+ numberField(request.route_channel, "route_channel"),
753
+ numberField(request.epoch, "epoch"),
754
+ this.connectionToken,
755
+ );
599
756
  const bindRequest: RouteBindRequest = {
600
- route_channel: numberField(request.route_channel, "route_channel"),
757
+ handle: tentative,
601
758
  target: request.target as RouteTarget,
602
759
  identity: request.identity as BindIdentity,
603
760
  principal: request.principal as Principal | undefined,
761
+ consumer_capabilities: request.consumer_capabilities as string[] | undefined,
604
762
  };
605
763
 
606
- const decision = await this.opts.onBind?.(bindRequest);
764
+ let decision: BindDecision | undefined;
765
+ try {
766
+ decision = await this.opts.onBind?.(bindRequest);
767
+ } catch (error) {
768
+ try {
769
+ await this.sendError(
770
+ frame,
771
+ "route_rejected",
772
+ error instanceof Error ? error.message : String(error),
773
+ controlFlags(),
774
+ sock,
775
+ generation,
776
+ );
777
+ } finally {
778
+ await this.opts.onRouteGone?.(tentative);
779
+ }
780
+ return;
781
+ }
607
782
  const rejection = bindRejection(decision);
608
783
  if (rejection) {
609
- await this.sendError(frame, rejection.code, rejection.message, controlFlags(), sock, generation);
784
+ try {
785
+ await this.sendError(frame, rejection.code, rejection.message, controlFlags(), sock, generation);
786
+ } finally {
787
+ await this.opts.onRouteGone?.(tentative);
788
+ }
610
789
  return;
611
790
  }
612
791
 
613
- await this.sendOn(
614
- sock,
615
- generation,
616
- buildFrameWithVersion(
617
- frame.header.ver,
618
- FrameType.Response,
619
- controlFlags(),
620
- 0,
621
- frame.header.corr,
622
- encodeJson({ op: "route.bind" }),
623
- ),
624
- );
792
+ try {
793
+ await this.sendOn(
794
+ sock,
795
+ generation,
796
+ buildFrameWithVersion(
797
+ frame.header.ver,
798
+ FrameType.Response,
799
+ controlFlags(),
800
+ 0,
801
+ 0,
802
+ frame.header.corr,
803
+ encodeJson({ op: "route.bind" }),
804
+ ),
805
+ );
806
+ } catch (error) {
807
+ await this.opts.onRouteGone?.(tentative);
808
+ throw error;
809
+ }
810
+
811
+ if (this.sock !== sock || this.generation !== generation || this.closeStarted || this.closedErr) {
812
+ await this.opts.onRouteGone?.(tentative);
813
+ return;
814
+ }
815
+ this.liveRoutes.set(tentative.channel, tentative);
816
+ await this.opts.onBound?.(tentative);
625
817
  }
626
818
 
627
- private async handleDataRequest(frame: Frame, sock: SubcSocket, generation: number): Promise<void> {
628
- const { channel, corr, ver } = frame.header;
629
- const key = routeKey(generation, channel, corr);
819
+ private async handleDataRequest(
820
+ frame: Frame,
821
+ handle: RouteHandle,
822
+ sock: SubcSocket,
823
+ generation: number,
824
+ ): Promise<void> {
825
+ const { corr, ver } = frame.header;
826
+ const key = routeKey(handle, corr);
630
827
  const controller = new AbortController();
631
828
  this.inflight.set(key, controller);
632
- const dataFlags = buildFlags(false, Priority.Interactive, false);
633
- const ctx: ProviderRequestContext = {
829
+ const context: ProviderRequestContext = {
830
+ handle,
634
831
  signal: controller.signal,
635
832
  currentEpoch: () => this.connectionEpoch,
636
- emit: async (eventBody) => {
637
- // Once aborted (cancel / route-gone / socket drop), drop further events silently.
833
+ emit: async (eventBody, options = {}) => {
834
+ this.assertLiveHandle(handle);
638
835
  if (controller.signal.aborted) return;
639
836
  await this.sendOn(
640
837
  sock,
641
838
  generation,
642
- buildFrameWithVersion(ver, FrameType.StreamData, dataFlags, channel, corr, eventBody),
839
+ buildFrameWithVersion(
840
+ ver,
841
+ FrameType.StreamData,
842
+ buildFlags(
843
+ false,
844
+ options.priority ?? Priority.Interactive,
845
+ false,
846
+ options.admissionClass ?? AdmissionClass.Normal,
847
+ ),
848
+ handle.channel,
849
+ handle.epoch,
850
+ corr,
851
+ eventBody,
852
+ ),
643
853
  );
644
854
  },
645
855
  };
646
856
  const releasePermit = await (this.requestGate ?? new AsyncPermitPool(DEFAULT_PROVIDER_HANDLER_CAPACITY)).acquire();
857
+ const dataFlags = buildFlags(false, Priority.Interactive, false);
647
858
  try {
648
- const body = await this.opts.handler(channel, frame.body, ctx);
859
+ const body = await this.opts.handler(handle, frame.body, context);
860
+ if (controller.signal.aborted) return;
861
+ this.assertLiveHandle(handle);
649
862
  if (body === undefined) {
650
- // A streaming handler that ended: close the held-open request with a
651
- // StreamEnd terminal (the consumer's subscription resolves).
652
863
  await this.sendOn(
653
864
  sock,
654
865
  generation,
655
- buildFrameWithVersion(ver, FrameType.StreamEnd, dataFlags, channel, corr, new Uint8Array(0)),
866
+ buildFrameWithVersion(
867
+ ver,
868
+ FrameType.StreamEnd,
869
+ dataFlags,
870
+ handle.channel,
871
+ handle.epoch,
872
+ corr,
873
+ new Uint8Array(0),
874
+ ),
656
875
  );
657
876
  } else if (body instanceof Uint8Array) {
658
877
  await this.sendOn(
659
878
  sock,
660
879
  generation,
661
- buildFrameWithVersion(ver, FrameType.Response, dataFlags, channel, corr, body),
880
+ buildFrameWithVersion(
881
+ ver,
882
+ FrameType.Response,
883
+ dataFlags,
884
+ handle.channel,
885
+ handle.epoch,
886
+ corr,
887
+ body,
888
+ ),
662
889
  );
663
890
  } else {
664
- throw new SubcProviderError(
665
- "provider handler must return a Uint8Array or void",
666
- "invalid_handler_response",
667
- );
891
+ throw new SubcProviderError("provider handler must return a Uint8Array or void", "invalid_handler_response");
668
892
  }
669
- } catch (err) {
893
+ } catch (error) {
894
+ if (error instanceof StaleRouteHandleError || controller.signal.aborted) return;
670
895
  await this.sendError(
671
896
  frame,
672
- err instanceof SubcProviderError && err.code ? err.code : "handler_error",
673
- err instanceof Error ? err.message : String(err),
897
+ error instanceof SubcProviderError && error.code ? error.code : "handler_error",
898
+ error instanceof Error ? error.message : String(error),
674
899
  dataFlags,
675
900
  sock,
676
901
  generation,
@@ -682,8 +907,8 @@ export class SubcProvider {
682
907
  }
683
908
 
684
909
  private async handleHealthRequest(frame: Frame, sock: SubcSocket, generation: number): Promise<void> {
685
- const { channel, corr, ver } = frame.header;
686
- const key = routeKey(generation, channel, corr);
910
+ const { corr, ver } = frame.header;
911
+ const key = `control:${generation}:${corr}`;
687
912
  const controller = new AbortController();
688
913
  this.inflight.set(key, controller);
689
914
  const releasePermit = await (this.requestGate ?? new AsyncPermitPool(DEFAULT_PROVIDER_HANDLER_CAPACITY)).acquire();
@@ -697,7 +922,8 @@ export class SubcProvider {
697
922
  ver,
698
923
  FrameType.Response,
699
924
  controlFlags(),
700
- channel,
925
+ 0,
926
+ 0,
701
927
  corr,
702
928
  encodeJson({
703
929
  op: HEALTH_CHECK_OP,
@@ -707,11 +933,11 @@ export class SubcProvider {
707
933
  }),
708
934
  ),
709
935
  );
710
- } catch (err) {
936
+ } catch (error) {
711
937
  await this.sendError(
712
938
  frame,
713
- err instanceof SubcProviderError && err.code ? err.code : "health_error",
714
- err instanceof Error ? err.message : String(err),
939
+ error instanceof SubcProviderError && error.code ? error.code : "health_error",
940
+ error instanceof Error ? error.message : String(error),
715
941
  controlFlags(),
716
942
  sock,
717
943
  generation,
@@ -738,6 +964,7 @@ export class SubcProvider {
738
964
  FrameType.Error,
739
965
  flags,
740
966
  frame.header.channel,
967
+ frame.header.epoch,
741
968
  frame.header.corr,
742
969
  encodeJson({ code, message }),
743
970
  ),
@@ -799,13 +1026,16 @@ export class SubcProvider {
799
1026
  }
800
1027
  }
801
1028
 
802
- private replaceConnection(opened: OpenedProviderConnection): void {
1029
+ private replaceConnection(opened: OpenedProviderConnection): void {
803
1030
  this.sock.close();
804
1031
  this.sock = opened.sock;
805
1032
  this.currentConn = opened.conn;
806
1033
  this.storage = opened.ack.storage;
807
1034
  this.closedErr = null;
808
1035
  this.connectionEpoch += 1;
1036
+ this.connectionToken = newConnectionToken();
1037
+ this.liveRoutes.clear();
1038
+ this.nextCorr = 1n;
809
1039
  const generation = this.generation;
810
1040
  void this.readLoop(opened.sock, generation);
811
1041
  this.scheduleRestored(generation, this.connectionEpoch);
@@ -869,6 +1099,26 @@ export class SubcProvider {
869
1099
  }
870
1100
  }
871
1101
 
1102
+ private isLiveHandle(handle: RouteHandle): boolean {
1103
+ return belongsToConnection(handle, this.connectionToken) && this.liveRoutes.get(handle.channel) === handle;
1104
+ }
1105
+
1106
+ private assertLiveHandle(handle: RouteHandle): void {
1107
+ if (!this.isLiveHandle(handle)) throw new StaleRouteHandleError(handle);
1108
+ }
1109
+
1110
+ private allocateCorr(): bigint {
1111
+ const maximum = 0xffff_ffff_ffff_ffffn;
1112
+ if (this.nextCorr > maximum) {
1113
+ const error = new SubcProviderError("channel-0 correlation id allocator exhausted", "corr_exhausted");
1114
+ this.handleUnexpectedDrop(this.sock, this.generation, error);
1115
+ throw error;
1116
+ }
1117
+ const corr = this.nextCorr;
1118
+ this.nextCorr += 1n;
1119
+ return corr;
1120
+ }
1121
+
872
1122
  private failFatal(err: Error): void {
873
1123
  if (!this.closedErr) this.closedErr = err;
874
1124
  this.closeStarted = true;
@@ -883,8 +1133,17 @@ export class SubcProvider {
883
1133
  }
884
1134
  }
885
1135
 
886
- function routeKey(generation: number, channel: number, corr: bigint): string {
887
- return `${generation}:${channel}:${corr}`;
1136
+ function routeKey(handle: RouteHandle, corr: bigint): string {
1137
+ return `${handle.channel}:${handle.epoch}:${corr}`;
1138
+ }
1139
+
1140
+ function providerErrorFromFrame(frame: Frame): SubcProviderError {
1141
+ try {
1142
+ const body = parseJson(frame.body) as { code?: string; message?: string };
1143
+ return new SubcProviderError(body.message ?? "subc error", body.code);
1144
+ } catch {
1145
+ return new SubcProviderError(Buffer.from(frame.body).toString("utf8") || "subc error");
1146
+ }
888
1147
  }
889
1148
 
890
1149
  function launchNonce(opts: SubcProviderConnectOptions): string | undefined {
@@ -903,6 +1162,7 @@ function normalizeProviderConnectOptions(opts: SubcProviderConnectOptions): Norm
903
1162
  handshakeTimeoutMs: opts.handshakeTimeoutMs,
904
1163
  controlOps: opts.controlOps,
905
1164
  onBind: opts.onBind,
1165
+ onBound: opts.onBound,
906
1166
  onRouteGone: opts.onRouteGone,
907
1167
  reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
908
1168
  sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
@@ -925,6 +1185,7 @@ function buildHelloFrame(opts: NormalizedSubcProviderConnectOptions): Frame {
925
1185
  FrameType.Hello,
926
1186
  controlFlags(),
927
1187
  0,
1188
+ 0,
928
1189
  HELLO_CORR,
929
1190
  encodeJson({
930
1191
  manifest: normalizeManifest(opts.manifest),
@@ -977,14 +1238,20 @@ async function sendFrame(sock: SubcSocket, frame: Frame): Promise<void> {
977
1238
  }
978
1239
 
979
1240
  async function expectHelloAck(sock: SubcSocket, deadline: number): Promise<ModuleHelloAckBody> {
980
- const header = decodeHeader(await sock.readExact(HEADER_LEN, deadline));
981
- const body = header.len === 0 ? new Uint8Array(0) : await sock.readExact(header.len, deadline);
982
- const frame = { header, body };
983
- switch (header.ty) {
984
- case FrameType.HelloAck:
985
- return parseJson(body) as ModuleHelloAckBody;
1241
+ const frame = await sock.readFrame(deadline, deadline);
1242
+ switch (frame.header.ty) {
1243
+ case FrameType.HelloAck: {
1244
+ const ack = parseJson(frame.body) as ModuleHelloAckBody;
1245
+ if (ack.negotiated_ver !== PROTOCOL_VERSION) {
1246
+ throw new SubcProviderError(
1247
+ `subc negotiated protocol ${ack.negotiated_ver}; expected exactly ${PROTOCOL_VERSION}`,
1248
+ "unsupported_version",
1249
+ );
1250
+ }
1251
+ return ack;
1252
+ }
986
1253
  case FrameType.Error: {
987
- const error = parseJson(body) as { code?: string; message?: string };
1254
+ const error = parseJson(frame.body) as { code?: string; message?: string };
988
1255
  throw new SubcProviderError(
989
1256
  `subc rejected HELLO: ${error.code ?? "unknown"} — ${error.message ?? "subc error"}`,
990
1257
  error.code,