@cortexkit/subc-client 0.18.1 → 0.20.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
@@ -297,6 +297,12 @@ export class SubcCallError extends Error {
297
297
  get detail(): unknown {
298
298
  return this.cause instanceof SubcError ? this.cause.detail : undefined;
299
299
  }
300
+
301
+ /** Why the route ended. A reason never makes an in-flight call safe to resend. */
302
+ get closeReason(): RouteEndReason | undefined {
303
+ return this.cause instanceof SubcError || this.cause instanceof SubcCallError
304
+ ? this.cause.closeReason : undefined;
305
+ }
300
306
  }
301
307
 
302
308
  /**
@@ -324,6 +330,7 @@ export class SubcError extends Error {
324
330
  * those reasons at the transport boundary.
325
331
  */
326
332
  readonly detail?: unknown,
333
+ readonly closeReason?: RouteEndReason,
327
334
  ) {
328
335
  super(message);
329
336
  }
@@ -404,15 +411,11 @@ export interface ConnectOptions {
404
411
  */
405
412
  timeoutArbitrationGraceMs?: number;
406
413
  /**
407
- * Observer for daemon-originated channel-0 control pushes (`route.closing`,
408
- * `route.closed`, and any op added later). Purely advisory: the daemon's
409
- * load-bearing route-death signal remains the GOODBYE frame, and nothing in
410
- * the client's own route lifecycle consumes these. The contract is the wire
411
- * contract verbatim -- unrecognized ops still arrive here (callers ignore
412
- * what they don't know, per the MUST-ignore clause), a push that fails to
413
- * parse as JSON is dropped without surfacing, and an observer throw is
414
- * swallowed like every other caller callback so it cannot fail the read loop
415
- * or unrelated requests.
414
+ * Observer for parsed daemon control pushes, including unrecognized ops which
415
+ * callers should ignore. Malformed JSON is dropped and observer exceptions are
416
+ * swallowed so they cannot fail unrelated requests. The client records route
417
+ * reasons before notifying the observer, but closes routes only on the separate
418
+ * GOODBYE frame, never on a control push.
416
419
  */
417
420
  onControlPush?: (push: ControlPush) => void;
418
421
  }
@@ -431,11 +434,18 @@ export type KnownRouteCloseReason =
431
434
  | "restart"
432
435
  | "disable"
433
436
  | "crash"
434
- | "capability_denied";
437
+ | "capability_denied"
438
+ | "scope_ended"
439
+ | "scope_carrier_removed"
440
+ | "scope_delegation_changed"
441
+ | "scope_parent_ended";
435
442
 
436
443
  /** A route-close reason decoded from a daemon control push. */
437
444
  export type RouteCloseReason = KnownRouteCloseReason | "unknown";
438
445
 
446
+ /** Why a route ended. Even a planned closure can leave a call's outcome unknown. */
447
+ export type RouteEndReason = RouteCloseReason | "closed_by_caller" | "connection_lost";
448
+
439
449
  /** Whether the close reason alone permits reopening a route automatically. */
440
450
  export type RouteCloseDisposition = "may_reopen" | "must_not_reopen";
441
451
 
@@ -452,6 +462,10 @@ export function parseRouteCloseReason(reason: unknown): RouteCloseReason {
452
462
  case "disable":
453
463
  case "crash":
454
464
  case "capability_denied":
465
+ case "scope_ended":
466
+ case "scope_carrier_removed":
467
+ case "scope_delegation_changed":
468
+ case "scope_parent_ended":
455
469
  return reason;
456
470
  default:
457
471
  return "unknown";
@@ -467,6 +481,10 @@ export function classifyRouteCloseReason(reason: unknown): RouteCloseDisposition
467
481
  case "disable":
468
482
  case "crash":
469
483
  case "capability_denied":
484
+ case "scope_ended":
485
+ case "scope_carrier_removed":
486
+ case "scope_delegation_changed":
487
+ case "scope_parent_ended":
470
488
  case "unknown":
471
489
  return "must_not_reopen";
472
490
  }
@@ -600,6 +618,9 @@ export class SubcClient {
600
618
  private readonly lateResponses = new Map<string, (frame: Frame) => void>();
601
619
  private readonly routes = new Map<string, CachedRoute>();
602
620
  private readonly liveRoutes = new Map<number, RouteHandle>();
621
+ private readonly routeModules = new Map<number, string>();
622
+ private readonly routeEndReasons = new Map<number, { reason: RouteCloseReason; final: boolean }>();
623
+ private readonly legacyChannelReasons = new Map<number, RouteCloseReason>();
603
624
  private connectionToken = newConnectionToken();
604
625
  private ingressEpochDropCount = 0;
605
626
  private closedErr: Error | null = null;
@@ -724,6 +745,7 @@ export class SubcClient {
724
745
  throw new SubcError(`route.open returned no route handle: ${JSON.stringify(parsed)}`);
725
746
  }
726
747
  installed = this.installRoute(parsed.route_channel, parsed.route_epoch, reverseRequests);
748
+ this.routeModules.set(parsed.route_channel, target.module_id);
727
749
  return true;
728
750
  };
729
751
  const closeLateRoute = (frame: Frame): void => {
@@ -732,7 +754,7 @@ export class SubcClient {
732
754
  const parsed = this.parseJson(frame) as { route_channel?: number; route_epoch?: number };
733
755
  if (typeof parsed.route_channel !== "number" || typeof parsed.route_epoch !== "number") return;
734
756
  const lateHandle = this.installRoute(parsed.route_channel, parsed.route_epoch);
735
- this.failHandle(lateHandle, new SubcError("late route.open was closed", "route_closed"));
757
+ this.failHandle(lateHandle, new SubcError("late route.open was closed", "route_closed", undefined, "closed_by_caller"));
736
758
  this.liveRoutes.delete(lateHandle.channel);
737
759
  this.sendRouteGoodbye(lateHandle, true);
738
760
  } catch {
@@ -1018,7 +1040,7 @@ export class SubcClient {
1018
1040
  }
1019
1041
  }
1020
1042
  if (opts.drain) await this.drainUnaryOnHandle(handle);
1021
- this.failHandle(handle, new SubcError("route closed by closeRoute", "route_closed"));
1043
+ this.failHandle(handle, new SubcError("route closed by closeRoute", "route_closed", undefined, "closed_by_caller"));
1022
1044
  if (this.liveRoutes.get(handle.channel) === handle) this.liveRoutes.delete(handle.channel);
1023
1045
  this.sendRouteGoodbye(handle);
1024
1046
  }
@@ -1683,7 +1705,8 @@ export class SubcClient {
1683
1705
  }
1684
1706
 
1685
1707
  private routeClosedDuringOpen(): SubcCallError {
1686
- return new SubcCallError("not_sent", "route was closed before route.open completed", "route_closed");
1708
+ const cause = new SubcError("route was closed before route.open completed", "route_closed", undefined, "closed_by_caller");
1709
+ return new SubcCallError("not_sent", cause.message, cause.code, cause);
1687
1710
  }
1688
1711
 
1689
1712
  private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
@@ -1726,12 +1749,12 @@ export class SubcClient {
1726
1749
  if (frame.header.channel === 0 && frame.header.ty === FrameType.Push) {
1727
1750
  // Daemon-originated control push (route.closing / route.closed / future
1728
1751
  // ops). Never matches a pending (corr is daemon-chosen), never an error
1729
- // path: unparseable bodies and absent observers both drop silently per
1730
- // the wire contract's MUST-ignore clause, and an observer throw is
1752
+ // path: unparseable bodies drop silently. Reasons are recorded even without
1753
+ // an observer, and an observer throw is
1731
1754
  // swallowed for the same reason as onProgress below -- a caller callback
1732
1755
  // must not fail the read loop.
1733
1756
  const observer = this.opts.onControlPush;
1734
- if (observer) {
1757
+ {
1735
1758
  let parsed: ControlPush | null = null;
1736
1759
  try {
1737
1760
  const body = this.parseJson(frame) as Record<string, unknown>;
@@ -1740,8 +1763,9 @@ export class SubcClient {
1740
1763
  // Unparseable control push: ignored by contract.
1741
1764
  }
1742
1765
  if (parsed) {
1766
+ this.recordRouteEnd(parsed);
1743
1767
  try {
1744
- observer(parsed);
1768
+ observer?.(parsed);
1745
1769
  } catch {
1746
1770
  // Observer's own throw, on its own stack; the stream must survive.
1747
1771
  }
@@ -1823,7 +1847,7 @@ export class SubcClient {
1823
1847
  // so it stays kind=outcome_unknown -- the same class as a mid-flight socket
1824
1848
  // drop, and never the not_sent/unknown_channel class that call() retries.
1825
1849
  // The code says WHICH route ended, not that it is safe to send again.
1826
- this.failHandle(handle, new SubcError("route closed by subc (GOODBYE)", "route_closed"));
1850
+ this.failHandle(handle, new SubcError("route closed by subc (GOODBYE)", "route_closed", undefined, this.routeEndReason(handle.channel)));
1827
1851
  if (this.liveRoutes.get(handle.channel) === handle) this.liveRoutes.delete(handle.channel);
1828
1852
  this.evictRouteHandle(handle);
1829
1853
  return;
@@ -1990,6 +2014,32 @@ export class SubcClient {
1990
2014
  }
1991
2015
  }
1992
2016
 
2017
+ private recordRouteEnd(push: ControlPush): void {
2018
+ const reason = routeCloseReason(push);
2019
+ if (reason === undefined) return;
2020
+ if ("channels" in push.body) {
2021
+ if (Array.isArray(push.body.channels)) {
2022
+ for (const channel of push.body.channels) {
2023
+ if (typeof channel !== "number" || !Number.isInteger(channel) || channel < 0 || channel > 65535) continue;
2024
+ const final = push.op === "route.closed";
2025
+ if (final || !this.routeEndReasons.get(channel)?.final) {
2026
+ this.routeEndReasons.set(channel, { reason, final });
2027
+ }
2028
+ }
2029
+ }
2030
+ } else if (typeof push.body.module_id === "string") {
2031
+ for (const [channel, module] of this.routeModules) {
2032
+ if (module === push.body.module_id) this.legacyChannelReasons.set(channel, reason);
2033
+ }
2034
+ }
2035
+ }
2036
+
2037
+ private routeEndReason(channel: number): RouteEndReason {
2038
+ const specific = this.routeEndReasons.get(channel);
2039
+ if (specific) return specific.reason;
2040
+ return this.legacyChannelReasons.get(channel) ?? "unknown";
2041
+ }
2042
+
1993
2043
  private failHandle(handle: RouteHandle, error: Error): void {
1994
2044
  for (const [key, pending] of this.pending) {
1995
2045
  if (pending.handle && sameRouteHandle(pending.handle, handle)) this.rejectPending(key, pending, error);
@@ -1997,9 +2047,15 @@ export class SubcClient {
1997
2047
  }
1998
2048
 
1999
2049
  private fail(err: Error): void {
2050
+ const routeError = err instanceof SubcError && err.closeReason ? err
2051
+ : new SubcError(err.message, errorCode(err), undefined,
2052
+ this.closeStarted ? "closed_by_caller" : "connection_lost");
2000
2053
  if (!this.closedErr) this.closedErr = err;
2054
+ this.routeEndReasons.clear();
2055
+ this.legacyChannelReasons.clear();
2056
+ this.routeModules.clear();
2001
2057
  for (const [key, pending] of this.pending) {
2002
- this.rejectPending(key, pending, err);
2058
+ this.rejectPending(key, pending, pending.handle ? routeError : err);
2003
2059
  }
2004
2060
  }
2005
2061
 
@@ -2033,6 +2089,9 @@ export class SubcClient {
2033
2089
  reverseRequests = new ReverseRequestRegistry(),
2034
2090
  ): RouteHandle {
2035
2091
  const handle = createRouteHandle(channel, epoch, this.connectionToken, reverseRequests);
2092
+ this.routeEndReasons.delete(channel);
2093
+ this.legacyChannelReasons.delete(channel);
2094
+ this.routeModules.delete(channel);
2036
2095
  this.liveRoutes.set(channel, handle);
2037
2096
  return handle;
2038
2097
  }
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export {
29
29
  type ControlPush,
30
30
  type KnownRouteCloseReason,
31
31
  type RouteCloseReason,
32
+ type RouteEndReason,
32
33
  type RouteCloseDisposition,
33
34
  type ReconnectBackoff,
34
35
  type SubcCallErrorKind,
package/src/provider.ts CHANGED
@@ -4,6 +4,8 @@ import { AuthError, authenticateClient } from "./auth.js";
4
4
  import {
5
5
  DEFAULT_RECONNECT_BACKOFF,
6
6
  isEstablishedRouteDead,
7
+ parseRouteCloseReason,
8
+ type RouteCloseReason,
7
9
  SUBC_MODULE_ID_ENV,
8
10
  type BindIdentity,
9
11
  type ReconnectBackoff,
@@ -51,6 +53,7 @@ const BODY_READ_TIMEOUT_MS = 30_000;
51
53
  const WRITE_TIMEOUT_MS = 30_000;
52
54
  const DEFAULT_RESTORED_DEBOUNCE_MS = 250;
53
55
  const DEFAULT_PROVIDER_HANDLER_CAPACITY = 64;
56
+ const DRAINING_HOOK_START_LIMIT_MS = 2_000;
54
57
  const HEALTH_CHECK_OP = "health.check";
55
58
  export const HELLO_CORR = 1n;
56
59
 
@@ -287,6 +290,22 @@ export interface SubcProviderConnectOptions {
287
290
  /** Runs only after an accepted bind ack is queued and the handle is installed. */
288
291
  onBound?: (handle: RouteHandle) => void | Promise<void>;
289
292
  onRouteGone?: (handle: RouteHandle) => void | Promise<void>;
293
+ /**
294
+ * The daemon has stopped admitting routes and is draining the module. Stop
295
+ * taking new work and finish or hand off work in flight. Called once per
296
+ * notice, independently of the frame reader; throws and rejections are contained.
297
+ * GOODBYE and connection-end reporting wait at most 2 s for hooks to start,
298
+ * not for them to finish. Hooks may still be running after serving ends.
299
+ * The wall-clock deadline is "no later than", never a grant of time: the
300
+ * daemon enforces its own monotonic ceiling, which can expire earlier.
301
+ *
302
+ * Background work the daemon cannot see holds a drain open only when the
303
+ * manifest declares a Busy self-signal anchored to health gauges. Report
304
+ * those gauges above zero in health metrics until that work finishes. During
305
+ * draining the daemon waits for every declared gauge to reach zero (a missing
306
+ * gauge counts as busy), or for its deadline. A pending hook alone is not busy.
307
+ */
308
+ onDraining?: (reason: RouteCloseReason, deadline: Date) => void | Promise<void>;
290
309
  /** Backoff for provider reconnect after an unexpected socket drop. */
291
310
  reconnectBackoff?: ReconnectBackoff;
292
311
  /** Injectable sleep for timer-free reconnect and debounce tests. */
@@ -328,6 +347,7 @@ interface NormalizedSubcProviderConnectOptions {
328
347
  /** Runs only after an accepted bind ack is queued and the handle is installed. */
329
348
  onBound?: (handle: RouteHandle) => void | Promise<void>;
330
349
  onRouteGone?: (handle: RouteHandle) => void | Promise<void>;
350
+ onDraining?: (reason: RouteCloseReason, deadline: Date) => void | Promise<void>;
331
351
  reconnectBackoff: ReconnectBackoff;
332
352
  sleep: (ms: number) => Promise<void>;
333
353
  restoredDebounceMs: number;
@@ -337,6 +357,11 @@ interface NormalizedSubcProviderConnectOptions {
337
357
  reconnectOnDrop: boolean;
338
358
  }
339
359
 
360
+ interface ControlPushState {
361
+ undecodablePushLogged: boolean;
362
+ drainingHooks: Set<Promise<void>>;
363
+ }
364
+
340
365
  interface OpenedProviderConnection {
341
366
  sock: SubcSocket;
342
367
  conn: ConnectionInfo;
@@ -691,16 +716,18 @@ export class SubcProvider {
691
716
  }
692
717
 
693
718
  private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
719
+ const control: ControlPushState = { undecodablePushLogged: false, drainingHooks: new Set() };
694
720
  try {
695
721
  for (;;) {
696
722
  const frame = await sock.readFrame(Number.POSITIVE_INFINITY, { afterHeaderMs: BODY_READ_TIMEOUT_MS });
697
- const keepGoing = await this.dispatch(frame, sock, generation);
723
+ const keepGoing = await this.dispatch(frame, sock, generation, control);
698
724
  if (!keepGoing) {
699
725
  if (this.sock === sock && this.generation === generation) this.closeStarted = true;
700
726
  break;
701
727
  }
702
728
  }
703
729
  } catch (error) {
730
+ await this.waitForDrainingHooksToStart(control);
704
731
  if (this.sock === sock && this.generation === generation && !this.closeStarted) {
705
732
  this.handleUnexpectedDrop(sock, generation, error instanceof Error ? error : new SubcProviderError(String(error)));
706
733
  return;
@@ -713,7 +740,7 @@ export class SubcProvider {
713
740
  }
714
741
  }
715
742
 
716
- private async dispatch(frame: Frame, sock: SubcSocket, generation: number): Promise<boolean> {
743
+ private async dispatch(frame: Frame, sock: SubcSocket, generation: number, control: ControlPushState = { undecodablePushLogged: false, drainingHooks: new Set() }): Promise<boolean> {
717
744
  let handle: RouteHandle | null = null;
718
745
  if (frame.header.channel !== 0) {
719
746
  handle = this.liveRoutes.get(frame.header.channel) ?? null;
@@ -761,7 +788,11 @@ export class SubcProvider {
761
788
  );
762
789
  }
763
790
  return true;
791
+ case FrameType.Push:
792
+ if (frame.header.channel === 0) this.handleControlPush(frame, control);
793
+ return true;
764
794
  case FrameType.Goodbye:
795
+ await this.waitForDrainingHooksToStart(control);
765
796
  if (!handle) return false;
766
797
  this.liveRoutes.delete(handle.channel);
767
798
  this.abortHandle(handle);
@@ -786,21 +817,58 @@ export class SubcProvider {
786
817
  }
787
818
  }
788
819
 
789
- /**
790
- * Invoke the consumer's route-gone callback without letting it break the
791
- * read loop. Every call site is awaited on the read loop's path, so a throw
792
- * escaping from it is read as an unexpected connection drop and tears down
793
- * every route on the connection; report and absorb it here instead, the same
794
- * way a failing request handler is reported.
795
- */
796
- /**
797
- * Invoke the consumer's bound callback without letting it break the read
798
- * loop. By the time it runs the bind is acknowledged and the route installed,
799
- * so a throw here is a failure of the consumer's setup for THIS route, and
800
- * letting it escape would be read as an unexpected connection drop and tear
801
- * down every other route on the connection. The route stays bound: the
802
- * daemon already considers it live, and the consumer can close it.
803
- */
820
+ private handleControlPush(frame: Frame, control: ControlPushState): void {
821
+ let body: { op?: unknown; reason?: unknown; deadline_ms?: unknown };
822
+ try {
823
+ body = JSON.parse(Buffer.from(frame.body).toString("utf8"));
824
+ if (body === null || body.op !== "module.draining" || typeof body.reason !== "string" ||
825
+ typeof body.deadline_ms !== "number" || !Number.isInteger(body.deadline_ms) || body.deadline_ms < 0) {
826
+ throw new Error("invalid module control command");
827
+ }
828
+ } catch (error) {
829
+ if (!control.undecodablePushLogged) {
830
+ control.undecodablePushLogged = true;
831
+ console.warn("SubcProvider ignoring undecodable channel-0 Push; further warnings suppressed on this connection", error);
832
+ }
833
+ return;
834
+ }
835
+ const deadline = new Date(body.deadline_ms as number);
836
+ // Out-of-range wall clocks expire now rather than granting extra drain time.
837
+ if (Number.isNaN(deadline.getTime())) deadline.setTime(Date.now());
838
+ let markStarted!: () => void;
839
+ const started = new Promise<void>((resolve) => { markStarted = resolve; });
840
+ control.drainingHooks.add(started);
841
+ // A separate microtask runs the hook through its first await, without waiting
842
+ // for its completion on the reader. JavaScript hooks must not block synchronously.
843
+ void Promise.resolve().then(() => {
844
+ try {
845
+ const result = this.opts.onDraining?.(parseRouteCloseReason(body.reason), deadline);
846
+ void Promise.resolve(result).catch((error) => {
847
+ console.warn("SubcProvider draining callback failed", error);
848
+ });
849
+ } catch (error) {
850
+ console.warn("SubcProvider draining callback failed", error);
851
+ } finally {
852
+ markStarted();
853
+ control.drainingHooks.delete(started);
854
+ }
855
+ });
856
+ }
857
+
858
+ private async waitForDrainingHooksToStart(control: ControlPushState): Promise<void> {
859
+ if (control.drainingHooks.size === 0) return;
860
+ let timer: ReturnType<typeof setTimeout> | undefined;
861
+ try {
862
+ await Promise.race([
863
+ Promise.all(control.drainingHooks),
864
+ new Promise<void>((resolve) => { timer = setTimeout(resolve, DRAINING_HOOK_START_LIMIT_MS); }),
865
+ ]);
866
+ } finally {
867
+ clearTimeout(timer);
868
+ }
869
+ }
870
+
871
+ /** Contain callback failures so consumer setup cannot tear down unrelated routes. */
804
872
  private async reportRouteBound(handle: RouteHandle): Promise<void> {
805
873
  if (!this.opts.onBound) return;
806
874
  try {
@@ -810,6 +878,7 @@ export class SubcProvider {
810
878
  }
811
879
  }
812
880
 
881
+ /** Contain callback failures so route teardown cannot end the frame reader. */
813
882
  private async reportRouteGone(handle: RouteHandle): Promise<void> {
814
883
  if (!this.opts.onRouteGone) return;
815
884
  try {
@@ -1408,6 +1477,7 @@ function normalizeProviderConnectOptions(opts: SubcProviderConnectOptions): Norm
1408
1477
  onBind: opts.onBind,
1409
1478
  onBound: opts.onBound,
1410
1479
  onRouteGone: opts.onRouteGone,
1480
+ onDraining: opts.onDraining,
1411
1481
  reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
1412
1482
  sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
1413
1483
  restoredDebounceMs: opts.restoredDebounceMs ?? DEFAULT_RESTORED_DEBOUNCE_MS,