@cortexkit/aft-bridge 0.57.2 → 0.58.1

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.
Files changed (116) hide show
  1. package/dist/bash-format.d.ts +7 -0
  2. package/dist/bash-format.d.ts.map +1 -1
  3. package/dist/bash-format.js +9 -0
  4. package/dist/bash-format.js.map +1 -1
  5. package/dist/bash-hints.d.ts +31 -3
  6. package/dist/bash-hints.d.ts.map +1 -1
  7. package/dist/bash-hints.js +40 -9
  8. package/dist/bash-hints.js.map +1 -1
  9. package/dist/bash-host-fallback.js +1 -1
  10. package/dist/bash-host-fallback.js.map +1 -1
  11. package/dist/binary-identity.d.ts +109 -0
  12. package/dist/binary-identity.d.ts.map +1 -0
  13. package/dist/binary-identity.js +295 -0
  14. package/dist/binary-identity.js.map +1 -0
  15. package/dist/bridge.d.ts +10 -1
  16. package/dist/bridge.d.ts.map +1 -1
  17. package/dist/bridge.js +74 -24
  18. package/dist/bridge.js.map +1 -1
  19. package/dist/child-process.d.ts +39 -0
  20. package/dist/child-process.d.ts.map +1 -0
  21. package/dist/child-process.js +64 -0
  22. package/dist/child-process.js.map +1 -0
  23. package/dist/coerce.d.ts +2 -0
  24. package/dist/coerce.d.ts.map +1 -1
  25. package/dist/coerce.js +29 -0
  26. package/dist/coerce.js.map +1 -1
  27. package/dist/config-error.d.ts +71 -0
  28. package/dist/config-error.d.ts.map +1 -0
  29. package/dist/config-error.js +112 -0
  30. package/dist/config-error.js.map +1 -0
  31. package/dist/config-watch.d.ts +170 -0
  32. package/dist/config-watch.d.ts.map +1 -0
  33. package/dist/config-watch.js +479 -0
  34. package/dist/config-watch.js.map +1 -0
  35. package/dist/downloader.d.ts +4 -2
  36. package/dist/downloader.d.ts.map +1 -1
  37. package/dist/downloader.js +41 -20
  38. package/dist/downloader.js.map +1 -1
  39. package/dist/durable-log.d.ts +11 -0
  40. package/dist/durable-log.d.ts.map +1 -1
  41. package/dist/durable-log.js +81 -5
  42. package/dist/durable-log.js.map +1 -1
  43. package/dist/edit-summary.d.ts +8 -0
  44. package/dist/edit-summary.d.ts.map +1 -1
  45. package/dist/edit-summary.js +7 -0
  46. package/dist/edit-summary.js.map +1 -1
  47. package/dist/feature-config.d.ts +192 -0
  48. package/dist/feature-config.d.ts.map +1 -0
  49. package/dist/feature-config.js +657 -0
  50. package/dist/feature-config.js.map +1 -0
  51. package/dist/index.d.ts +19 -8
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +19 -7
  54. package/dist/index.js.map +1 -1
  55. package/dist/message-detach.d.ts +25 -0
  56. package/dist/message-detach.d.ts.map +1 -0
  57. package/dist/message-detach.js +34 -0
  58. package/dist/message-detach.js.map +1 -0
  59. package/dist/migration-notices.d.ts +29 -0
  60. package/dist/migration-notices.d.ts.map +1 -0
  61. package/dist/migration-notices.js +114 -0
  62. package/dist/migration-notices.js.map +1 -0
  63. package/dist/migration.d.ts +1 -1
  64. package/dist/migration.d.ts.map +1 -1
  65. package/dist/migration.js +1 -1
  66. package/dist/migration.js.map +1 -1
  67. package/dist/npm-resolver.d.ts +1 -1
  68. package/dist/npm-resolver.d.ts.map +1 -1
  69. package/dist/npm-resolver.js +1 -1
  70. package/dist/npm-resolver.js.map +1 -1
  71. package/dist/onnx-probe.d.ts +44 -0
  72. package/dist/onnx-probe.d.ts.map +1 -0
  73. package/dist/onnx-probe.js +167 -0
  74. package/dist/onnx-probe.js.map +1 -0
  75. package/dist/onnx-runtime.d.ts +19 -4
  76. package/dist/onnx-runtime.d.ts.map +1 -1
  77. package/dist/onnx-runtime.js +146 -34
  78. package/dist/onnx-runtime.js.map +1 -1
  79. package/dist/path-lookup.d.ts +22 -0
  80. package/dist/path-lookup.d.ts.map +1 -0
  81. package/dist/path-lookup.js +51 -0
  82. package/dist/path-lookup.js.map +1 -0
  83. package/dist/resolver.d.ts +47 -21
  84. package/dist/resolver.d.ts.map +1 -1
  85. package/dist/resolver.js +254 -159
  86. package/dist/resolver.js.map +1 -1
  87. package/dist/revivable-transport.d.ts.map +1 -1
  88. package/dist/revivable-transport.js +12 -0
  89. package/dist/revivable-transport.js.map +1 -1
  90. package/dist/semantic-status.d.ts +10 -1
  91. package/dist/semantic-status.d.ts.map +1 -1
  92. package/dist/semantic-status.js +50 -2
  93. package/dist/semantic-status.js.map +1 -1
  94. package/dist/storage-paths.d.ts +9 -0
  95. package/dist/storage-paths.d.ts.map +1 -1
  96. package/dist/storage-paths.js +18 -1
  97. package/dist/storage-paths.js.map +1 -1
  98. package/dist/subc-transport.d.ts +58 -1
  99. package/dist/subc-transport.d.ts.map +1 -1
  100. package/dist/subc-transport.js +378 -50
  101. package/dist/subc-transport.js.map +1 -1
  102. package/dist/tar-executable.js +1 -1
  103. package/dist/tar-executable.js.map +1 -1
  104. package/dist/transport-factory.d.ts +14 -2
  105. package/dist/transport-factory.d.ts.map +1 -1
  106. package/dist/transport-factory.js +24 -16
  107. package/dist/transport-factory.js.map +1 -1
  108. package/dist/version-probe.d.ts +33 -0
  109. package/dist/version-probe.d.ts.map +1 -0
  110. package/dist/version-probe.js +108 -0
  111. package/dist/version-probe.js.map +1 -0
  112. package/package.json +2 -2
  113. package/dist/bash-timeout.d.ts +0 -19
  114. package/dist/bash-timeout.d.ts.map +0 -1
  115. package/dist/bash-timeout.js +0 -26
  116. package/dist/bash-timeout.js.map +0 -1
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { existsSync, statSync } from "node:fs";
20
20
  import { connectionFileExists, isConsumerReconnectTransient, StaleRouteHandleError, SubcCallError, SubcClient, SubcError, } from "@cortexkit/subc-client";
21
- import { log } from "./active-logger.js";
21
+ import { log, warn } from "./active-logger.js";
22
22
  import { isRouteOpenReloadWindowError, isRouteRequestReloadRefusal } from "./error-contract.js";
23
23
  import { asCanonicalRootPath, asRootGeneration, LifecycleRegistry, } from "./lifecycle-registry.js";
24
24
  import { canonicalizeProjectRoot } from "./project-identity.js";
@@ -33,20 +33,102 @@ export class SubcTransportShuttingDownError extends SubcCallError {
33
33
  export function isSubcClientClosedError(error) {
34
34
  return error instanceof SubcError && error.message === "client closed";
35
35
  }
36
- function isConsumerReconnectRequired(error) {
36
+ /**
37
+ * True only when an error is evidence that the shared socket itself is dead, so
38
+ * every route on it is gone and the client must be replaced.
39
+ *
40
+ * subc-client's `isConsumerReconnectTransient` answers a different question
41
+ * ("may a managed call reconnect and retry?") and also accepts a managed
42
+ * `SubcCallError` of kind `not_sent` / `outcome_unknown`. Those kinds are the
43
+ * verdict on ONE call: a call whose deadline passed while the socket stayed up
44
+ * is `outcome_unknown` too. So a `SubcCallError` counts as socket death only when
45
+ * the error it wraps does. Everything else defers to the client's classifier,
46
+ * whose remaining members are all socket-level: the socket closed or reset, a
47
+ * write that could not complete, a handshake/auth failure while reconnecting,
48
+ * ECONN and EPIPE errno codes. The client's own "client closed" rejection means
49
+ * the connection has already been torn down locally.
50
+ */
51
+ export function isSocketDeathError(error, depth = 0) {
52
+ if (error instanceof SubcCallError) {
53
+ const cause = error.cause;
54
+ return depth < 4 && cause !== undefined && cause !== error
55
+ ? isSocketDeathError(cause, depth + 1)
56
+ : false;
57
+ }
37
58
  return isConsumerReconnectTransient(error) || isSubcClientClosedError(error);
38
59
  }
60
+ /**
61
+ * A call that never got an answer on a socket that may still be alive: the
62
+ * client's own request deadline (raw `request`/`routeOpen` reject with code
63
+ * `request_deadline`) or a managed verdict of `not_sent` / `outcome_unknown`
64
+ * that is not backed by socket death. Several of these in a row are grounds to
65
+ * check the connection, never to close it on their own.
66
+ */
67
+ function isUnansweredCallError(error) {
68
+ if (isSocketDeathError(error))
69
+ return false;
70
+ if (error instanceof SubcCallError)
71
+ return error.kind !== "terminal";
72
+ return error instanceof SubcError && error.code === REQUEST_DEADLINE_CODE;
73
+ }
74
+ /**
75
+ * A rejection that the daemon itself sent back (a coded wire Error frame, such as
76
+ * a refused route.open), which proves the socket still carries replies. The
77
+ * client's local deadline marker is also a code, so it is excluded.
78
+ */
79
+ function isDaemonAnswerError(error) {
80
+ return (error instanceof SubcError &&
81
+ typeof error.code === "string" &&
82
+ error.code !== REQUEST_DEADLINE_CODE &&
83
+ !isSubcClientClosedError(error));
84
+ }
85
+ /** Short `Class/kind/code` label for a drop log line. */
86
+ function describeFailure(error) {
87
+ if (!(error instanceof Error))
88
+ return `non-error:${String(error)}`;
89
+ const className = error.name && error.name !== "Error" ? error.name : (error.constructor?.name ?? "Error");
90
+ const parts = [className];
91
+ if (error instanceof SubcCallError)
92
+ parts.push(error.kind);
93
+ const code = error.code;
94
+ if (typeof code === "string" && code.length > 0)
95
+ parts.push(code);
96
+ return parts.join("/");
97
+ }
98
+ function routeLabel(route) {
99
+ return route ? `${route.channel}@${route.epoch}` : undefined;
100
+ }
101
+ /** The liveness probe saw no reply on the shared connection within its window. */
102
+ export class SubcLivenessProbeTimeoutError extends Error {
103
+ code = "liveness_probe_timeout";
104
+ constructor(windowMs) {
105
+ super(`no reply to a channel-0 catalog.list probe within ${windowMs}ms`);
106
+ this.name = "SubcLivenessProbeTimeoutError";
107
+ }
108
+ }
39
109
  /** The subc module id AFT registers under (matches the daemon manifest). */
40
110
  const AFT_MODULE_ID = "aft";
41
111
  /**
42
- * A run of consecutive NON-transient transport throws (timeout / route GOODBYE)
43
- * on the SAME client is presumed a dead half-open connection (local writes
44
- * succeed, no response ever arrives), so the client is dropped after this many.
45
- * A single throw does not drop the client (a slow tool can legitimately time out
46
- * once); the counter resets on any successful request. Tool-level errors never
47
- * count — they return `success:false`, they do not throw. (Audit B-#4.)
112
+ * A run of consecutive per-call failures that got no answer (timeout / route
113
+ * GOODBYE / refusal) on the SAME client raises the suspicion of a dead half-open
114
+ * connection: local writes succeed, no response ever arrives. The run alone never
115
+ * closes the client, because the calls may belong to different sessions that are
116
+ * each merely slow. It starts a liveness probe on the same connection, and the
117
+ * client is dropped only if that probe also goes unanswered. The counter resets
118
+ * on any successful request. Tool-level errors never count: they return
119
+ * `success:false`, they do not throw. (Audit B-#4.)
48
120
  */
49
121
  const MAX_CONSECUTIVE_TRANSPORT_FAILURES = 3;
122
+ /**
123
+ * How long the liveness probe waits for its reply. subc-client documents that
124
+ * the daemon handles a connection's channel-0 frames in order and can hold one
125
+ * route.open for its whole bind relay (about 12s in production), so the probe
126
+ * may queue behind a legitimate bind. A shorter window would convict a busy but
127
+ * healthy connection.
128
+ */
129
+ const LIVENESS_PROBE_TIMEOUT_MS = 15_000;
130
+ /** Code subc-client puts on a raw request or route.open that hit its own deadline. */
131
+ const REQUEST_DEADLINE_CODE = "request_deadline";
50
132
  /** Every transport reconnect waits at least one event-loop turn and caps repeated failures. */
51
133
  const RECONNECT_RETRY_FLOOR_MS = 100;
52
134
  const RECONNECT_RETRY_CAP_MS = 2_000;
@@ -66,6 +148,8 @@ const SUBC_DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
66
148
  * example bash host fallback) promptly instead of after a long transport timeout.
67
149
  */
68
150
  const ROUTE_OPEN_RELOAD_WAIT_CEILING_MS = 45_000;
151
+ /** Resolution of the timer a call races its route.open against when its deadline passes first. */
152
+ const CALL_DEADLINE_EXPIRED = Symbol("call deadline expired");
69
153
  function reloadWaitExhaustedSuffix(callDeadlineMs) {
70
154
  if (callDeadlineMs > ROUTE_OPEN_RELOAD_WAIT_CEILING_MS) {
71
155
  const ceilingSeconds = ROUTE_OPEN_RELOAD_WAIT_CEILING_MS / 1000;
@@ -174,13 +258,21 @@ class BgSubscription {
174
258
  dispatchProbeIntervalMs;
175
259
  nudgeRef;
176
260
  isCurrent;
261
+ noteClientAlive;
262
+ noteUnansweredCall;
177
263
  stopped = false;
178
264
  /** The live subscription handle, read by stop() to wake the loop's `await closed`. */
179
265
  current = null;
266
+ /** Client carrying the live subscription route, counted when that client is dropped. */
267
+ attached = null;
180
268
  loop;
181
269
  lifecycleLogState = new Map();
182
270
  nudgeReceiptLogState = null;
183
- constructor(identity, acquireClient, dropClient, consumerIdentity, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, dispatchProbeIntervalMs, nudgeRef, isCurrent = () => true) {
271
+ constructor(identity, acquireClient, dropClient, consumerIdentity, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, dispatchProbeIntervalMs, nudgeRef, isCurrent = () => true,
272
+ /** Reports a reply seen on the client, which proves its socket still delivers. */
273
+ noteClientAlive = () => undefined,
274
+ /** Reports a call on the client that got no answer; enough in a row start a liveness probe. */
275
+ noteUnansweredCall = () => undefined) {
184
276
  this.identity = identity;
185
277
  this.acquireClient = acquireClient;
186
278
  this.dropClient = dropClient;
@@ -193,8 +285,14 @@ class BgSubscription {
193
285
  this.dispatchProbeIntervalMs = dispatchProbeIntervalMs;
194
286
  this.nudgeRef = nudgeRef;
195
287
  this.isCurrent = isCurrent;
288
+ this.noteClientAlive = noteClientAlive;
289
+ this.noteUnansweredCall = noteUnansweredCall;
196
290
  this.loop = this.run();
197
291
  }
292
+ /** The client whose route currently carries this subscription, if any. */
293
+ get attachedClient() {
294
+ return this.attached;
295
+ }
198
296
  async stop() {
199
297
  this.stopped = true;
200
298
  // Wake a live `await sub.closed`; the loop's `finally` remains the sole
@@ -325,8 +423,23 @@ class BgSubscription {
325
423
  this.onDormant();
326
424
  return;
327
425
  }
328
- if (isConsumerReconnectRequired(err))
329
- this.dropClient(client);
426
+ if (isSocketDeathError(err)) {
427
+ this.dropClient(client, {
428
+ error: err,
429
+ trigger: "bg_events route.open",
430
+ session: this.identity.session,
431
+ });
432
+ }
433
+ else if (isDaemonAnswerError(err)) {
434
+ this.noteClientAlive(client);
435
+ }
436
+ else if (isUnansweredCallError(err)) {
437
+ this.noteUnansweredCall(client, {
438
+ error: err,
439
+ trigger: "bg_events route.open",
440
+ session: this.identity.session,
441
+ });
442
+ }
330
443
  if (!reconnecting)
331
444
  beginReconnect();
332
445
  this.info("reconnect-error", `reconnect error attempt=${reconnectAttempt} error=${this.errorText(err)}`);
@@ -341,6 +454,7 @@ class BgSubscription {
341
454
  giveUp(this.stopped ? "stopped" : "stale-session");
342
455
  return;
343
456
  }
457
+ this.noteClientAlive(client);
344
458
  const subscribedAt = Date.now();
345
459
  const routeId = this.routeId(route);
346
460
  let stopDispatchProbe = () => undefined;
@@ -351,12 +465,14 @@ class BgSubscription {
351
465
  return;
352
466
  }
353
467
  this.recordNudgeReceipt(routeId);
468
+ this.noteClientAlive(client);
354
469
  if (!this.isCurrent()) {
355
470
  this.info("nudge-stale-carrier", `nudge carried by stale subscription; checking current session channel=${routeId}`);
356
471
  }
357
472
  this.onNudge();
358
473
  });
359
474
  this.current = sub;
475
+ this.attached = client;
360
476
  stopDispatchProbe = this.startDispatchProbe(client, routeId);
361
477
  this.info("subscription-open", `subscription open channel=${routeId}`);
362
478
  if (reconnecting) {
@@ -391,8 +507,14 @@ class BgSubscription {
391
507
  giveUp("stopped");
392
508
  return;
393
509
  }
394
- if (isConsumerReconnectRequired(err))
395
- this.dropClient(client);
510
+ if (isSocketDeathError(err)) {
511
+ this.dropClient(client, {
512
+ error: err,
513
+ trigger: "bg_events stream",
514
+ session: this.identity.session,
515
+ route: routeId,
516
+ });
517
+ }
396
518
  if (Date.now() - subscribedAt >= BG_STABLE_MS)
397
519
  backoffAttempt = 0;
398
520
  beginReconnect();
@@ -400,6 +522,7 @@ class BgSubscription {
400
522
  finally {
401
523
  stopDispatchProbe();
402
524
  this.current = null;
525
+ this.attached = null;
403
526
  safeCloseRoute(client, route);
404
527
  }
405
528
  await this.backoff(backoffAttempt++);
@@ -434,7 +557,23 @@ function absentRootError(root) {
434
557
  }
435
558
  /** Preserve the daemon's final refusal while making the reload timeout actionable. */
436
559
  function reloadWindowExhaustedError(error, callDeadlineMs) {
437
- const suffix = reloadWaitExhaustedSuffix(callDeadlineMs);
560
+ return withDeadlineSuffix(error, reloadWaitExhaustedSuffix(callDeadlineMs));
561
+ }
562
+ /**
563
+ * The error a call surfaces once its own deadline has passed before a reply.
564
+ * `lastError` is the most recent route refusal the call saw (it keeps its code,
565
+ * so a reload refusal still lets bash take its host fallback); with none, the
566
+ * error carries subc-client's own `request_deadline` code, the same one a
567
+ * route.open or request that ran out of time inside the client would carry.
568
+ */
569
+ function callDeadlinePassedError(lastError, callDeadlineMs) {
570
+ const seconds = Math.round(callDeadlineMs / 100) / 10;
571
+ const suffix = ` The AFT daemon module did not return within this call's ${seconds}s deadline.`;
572
+ if (lastError !== undefined)
573
+ return withDeadlineSuffix(lastError, suffix);
574
+ return new SubcError(`route to module '${AFT_MODULE_ID}' was not ready before the call ran out of time.${suffix}`, REQUEST_DEADLINE_CODE);
575
+ }
576
+ function withDeadlineSuffix(error, suffix) {
438
577
  if (error instanceof Error) {
439
578
  try {
440
579
  error.message += suffix;
@@ -617,6 +756,7 @@ export class SubcTransportPool {
617
756
  bgBackoffSleep;
618
757
  routeRetrySleep;
619
758
  bgDispatchProbeIntervalMs;
759
+ livenessProbeTimeoutMs;
620
760
  lifecycleDemandCheck;
621
761
  onLifecycleEvent;
622
762
  onBgNudgeRejected;
@@ -642,8 +782,15 @@ export class SubcTransportPool {
642
782
  rootIndex = new Map();
643
783
  /** Roots whose route binds must stay dormant until their directories return. */
644
784
  dormantRoots = new Map();
645
- /** Consecutive non-transient failures on the current pool-local client. */
785
+ /** Consecutive unanswered calls on the current pool-local client. */
646
786
  transportFailures = 0;
787
+ /** The in-flight liveness probe of the current client; at most one runs at a time. */
788
+ livenessProbe = null;
789
+ /**
790
+ * Bumped whenever the current client delivers any reply. A liveness probe that
791
+ * times out while this moved has been outrun by other traffic, not by a dead socket.
792
+ */
793
+ clientLivenessMark = 0;
647
794
  /** Concrete per-root facades, including their captured root generation. */
648
795
  transports = new Map();
649
796
  generationRejections = new Set();
@@ -666,6 +813,7 @@ export class SubcTransportPool {
666
813
  options.routeRetrySleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
667
814
  this.bgDispatchProbeIntervalMs =
668
815
  options.bgDispatchProbeIntervalMs ?? BG_DISPATCH_PROBE_INTERVAL_MS;
816
+ this.livenessProbeTimeoutMs = options.livenessProbeTimeoutMs ?? LIVENESS_PROBE_TIMEOUT_MS;
669
817
  const lifecycle = options.lifecycle;
670
818
  const demandCheck = options.lifecycleDemandCheck ?? options.demandCheck ?? lifecycle?.demandCheck;
671
819
  this.lifecycleDemandCheck = demandCheck;
@@ -1137,6 +1285,7 @@ export class SubcTransportPool {
1137
1285
  }
1138
1286
  /** Open or reuse a route while guarding every lifecycle boundary. */
1139
1287
  async routeRequest(identity, body, timeoutMs, onProgress, expectedGeneration, abortSignal) {
1288
+ const callStartedAt = performance.now();
1140
1289
  const root = asCanonicalRootPath(identity.project_root);
1141
1290
  let generation = expectedGeneration;
1142
1291
  if (this.lifecycleEnabled()) {
@@ -1170,10 +1319,25 @@ export class SubcTransportPool {
1170
1319
  throw this.annotateReapError(error, record);
1171
1320
  if (error instanceof RouteTornDownError)
1172
1321
  throw error;
1173
- if (isConsumerReconnectRequired(error) &&
1174
- this.isCurrentSession(key, record) &&
1175
- this.client === client) {
1176
- this.dropClient(client);
1322
+ const ownsClient = this.isCurrentSession(key, record) && this.client === client;
1323
+ if (ownsClient && isSocketDeathError(error)) {
1324
+ this.dropClient(client, {
1325
+ error,
1326
+ trigger: "route.open",
1327
+ session: identity.session,
1328
+ });
1329
+ }
1330
+ else if (isDaemonAnswerError(error)) {
1331
+ // A refusal (cap reached, target unavailable, ...) fails this route
1332
+ // only, and proves the shared socket still delivers replies.
1333
+ this.noteClientAlive(client);
1334
+ }
1335
+ else if (ownsClient && isUnansweredCallError(error)) {
1336
+ this.noteUnansweredCall(client, {
1337
+ error,
1338
+ trigger: "route.open",
1339
+ session: identity.session,
1340
+ });
1177
1341
  }
1178
1342
  throw error;
1179
1343
  }
@@ -1181,27 +1345,64 @@ export class SubcTransportPool {
1181
1345
  // A module reload (drain, restart, warm-up) refuses route.open for as long
1182
1346
  // as it lasts. The call waits it out for as long as its own deadline allows,
1183
1347
  // but never longer than ROUTE_OPEN_RELOAD_WAIT_CEILING_MS, and then surfaces
1184
- // the refusal (which lets a caller such as bash fall back). Time spent
1185
- // waiting comes out of the request's budget, so the call never outlives the
1186
- // deadline it was given.
1348
+ // the refusal (which lets a caller such as bash fall back).
1349
+ //
1350
+ // Everything the call does counts against its deadline: route opens
1351
+ // (a bind relay can hold one for about 12 s), retry sleeps, and the
1352
+ // request itself, which is sent with only the time that is left. So the
1353
+ // call never outlives the deadline it was given, whichever path it takes.
1187
1354
  const callDeadlineMs = timeoutMs ?? SUBC_DEFAULT_REQUEST_TIMEOUT_MS;
1188
1355
  const reloadWaitBudgetMs = Math.min(callDeadlineMs, ROUTE_OPEN_RELOAD_WAIT_CEILING_MS);
1189
- let reloadWaitedMs = 0;
1356
+ // Retry delays this call has been charged for. Spent time never counts
1357
+ // less than these: a caller that joins a shared retry timer part-way is
1358
+ // still charged its full delay, as it always was, and an injected test
1359
+ // sleeper may finish a delay without real time passing.
1360
+ let scheduledDelayMs = 0;
1361
+ // The most recent route refusal, so a deadline that passes mid-open still
1362
+ // names the daemon's reason instead of a bare timeout.
1363
+ let lastRefusal;
1364
+ const spentMs = () => Math.max(performance.now() - callStartedAt, scheduledDelayMs);
1365
+ const remainingMs = () => callDeadlineMs - spentMs();
1366
+ // subc-client's routeOpen takes no deadline of its own (only its fixed
1367
+ // channel-0 default), so the call stops waiting for the open once its
1368
+ // own time is up. An open that completes later still lands in the
1369
+ // session's route entry, where the next call reuses it or teardown
1370
+ // closes it.
1371
+ const openRouteWithinDeadline = async () => {
1372
+ const remaining = remainingMs();
1373
+ if (remaining <= 0)
1374
+ throw callDeadlinePassedError(lastRefusal, callDeadlineMs);
1375
+ let timer;
1376
+ const expired = new Promise((resolve) => {
1377
+ timer = setTimeout(() => resolve(CALL_DEADLINE_EXPIRED), remaining);
1378
+ });
1379
+ try {
1380
+ const opened = await Promise.race([openRoute(), expired]);
1381
+ if (opened === CALL_DEADLINE_EXPIRED) {
1382
+ throw callDeadlinePassedError(lastRefusal, callDeadlineMs);
1383
+ }
1384
+ return opened;
1385
+ }
1386
+ finally {
1387
+ clearTimeout(timer);
1388
+ }
1389
+ };
1190
1390
  const openRouteAfterReloadWindow = async () => {
1191
1391
  while (true) {
1192
1392
  try {
1193
- return await openRoute();
1393
+ return await openRouteWithinDeadline();
1194
1394
  }
1195
1395
  catch (error) {
1196
1396
  if (!isRouteOpenReloadWindowError(error))
1197
1397
  throw error;
1398
+ lastRefusal = error;
1198
1399
  // Check the budget before starting (or joining) the shared retry
1199
1400
  // timer, so a call that gives up leaves no orphan timer behind.
1200
- if (reloadWaitedMs + this.nextRouteReopenDelayMs() >= reloadWaitBudgetMs) {
1401
+ if (spentMs() + this.nextRouteReopenDelayMs() >= reloadWaitBudgetMs) {
1201
1402
  throw reloadWindowExhaustedError(error, callDeadlineMs);
1202
1403
  }
1203
1404
  const { delayMs, wait } = this.waitForRouteReopenBackoff();
1204
- reloadWaitedMs += delayMs;
1405
+ scheduledDelayMs = spentMs() + delayMs;
1205
1406
  await wait;
1206
1407
  }
1207
1408
  }
@@ -1215,25 +1416,35 @@ export class SubcTransportPool {
1215
1416
  safeCloseRoute(entry.client, entry.handle);
1216
1417
  };
1217
1418
  const handleRequestFailure = (error, entry) => {
1419
+ const aborted = error instanceof Error && error.name === "AbortError";
1420
+ const ownsClient = this.isCurrentSession(key, record) &&
1421
+ this.client === client &&
1422
+ !this.isReapInduced(record);
1423
+ if (!aborted && ownsClient && isSocketDeathError(error)) {
1424
+ // Dropped before the route is cleared so the drop log counts it.
1425
+ this.dropClient(client, {
1426
+ error,
1427
+ trigger: "request",
1428
+ session: identity.session,
1429
+ route: routeLabel(entry.handle),
1430
+ });
1431
+ }
1218
1432
  clearRouteEntry(entry);
1219
- if (error instanceof Error && error.name === "AbortError")
1433
+ if (aborted || !ownsClient || this.client !== client)
1220
1434
  return;
1221
- if (!this.isCurrentSession(key, record) ||
1222
- this.client !== client ||
1223
- this.isReapInduced(record))
1435
+ if (isRouteProvenAbsentError(error))
1224
1436
  return;
1225
- if (isConsumerReconnectRequired(error)) {
1226
- this.transportFailures = 0;
1227
- this.dropClient(client);
1228
- return;
1229
- }
1230
- if (!isRouteProvenAbsentError(error) &&
1231
- ++this.transportFailures >= MAX_CONSECUTIVE_TRANSPORT_FAILURES) {
1232
- this.transportFailures = 0;
1233
- this.dropClient(client);
1234
- }
1437
+ // Anything else is this call's own outcome: a timeout, a GOODBYE, a
1438
+ // refusal, or a managed not_sent/outcome_unknown verdict. It fails this
1439
+ // call and route only; a run of them just checks the connection.
1440
+ this.noteUnansweredCall(client, {
1441
+ error,
1442
+ trigger: "request",
1443
+ session: identity.session,
1444
+ route: routeLabel(entry.handle),
1445
+ });
1235
1446
  };
1236
- const requestOnRoute = async (route, entry) => {
1447
+ const requestOnRoute = async (route, entry, requestTimeoutMs) => {
1237
1448
  this.assertRecordLive(record);
1238
1449
  if (abortSignal?.aborted) {
1239
1450
  clearRouteEntry(entry);
@@ -1249,8 +1460,10 @@ export class SubcTransportPool {
1249
1460
  };
1250
1461
  abortSignal?.addEventListener("abort", onAbort, { once: true });
1251
1462
  try {
1252
- const requestTimeoutMs = reloadWaitedMs > 0 ? callDeadlineMs - reloadWaitedMs : timeoutMs;
1253
- const request = client.request(route, body, { timeoutMs: requestTimeoutMs, onProgress });
1463
+ const request = client.request(route, body, {
1464
+ timeoutMs: Math.ceil(requestTimeoutMs),
1465
+ onProgress,
1466
+ });
1254
1467
  const reply = abortSignal ? await Promise.race([request, aborted]) : await request;
1255
1468
  // A legacy closeSession may intentionally let an already-delivered reply
1256
1469
  // settle. It must not mutate shared state or recreate a subscription.
@@ -1262,6 +1475,7 @@ export class SubcTransportPool {
1262
1475
  this.assertGeneration(record.canonicalRoot, record.generation, "request_completion");
1263
1476
  if (this.client === client)
1264
1477
  this.transportFailures = 0;
1478
+ this.noteClientAlive(client);
1265
1479
  this.ensureBgSubscription(identity, record);
1266
1480
  return reply;
1267
1481
  }
@@ -1273,8 +1487,13 @@ export class SubcTransportPool {
1273
1487
  let reopened = false;
1274
1488
  let retriedAbsentRoute = false;
1275
1489
  while (true) {
1490
+ // Checked outside the try below so the retry branches never mistake
1491
+ // this call's own expiry for a daemon refusal worth retrying.
1492
+ const requestTimeoutMs = remainingMs();
1493
+ if (requestTimeoutMs <= 0)
1494
+ throw callDeadlinePassedError(lastRefusal, callDeadlineMs);
1276
1495
  try {
1277
- const reply = await requestOnRoute(routeAndEntry.route, routeAndEntry.entry);
1496
+ const reply = await requestOnRoute(routeAndEntry.route, routeAndEntry.entry, requestTimeoutMs);
1278
1497
  if (reopened)
1279
1498
  this.resetRouteReopenBackoff();
1280
1499
  return reply;
@@ -1293,11 +1512,12 @@ export class SubcTransportPool {
1293
1512
  // failure.
1294
1513
  if (ownsRoute && isRouteRequestReloadRefusal(error)) {
1295
1514
  clearRouteEntry(routeAndEntry.entry);
1296
- if (reloadWaitedMs + this.nextRouteReopenDelayMs() >= reloadWaitBudgetMs) {
1515
+ lastRefusal = error;
1516
+ if (spentMs() + this.nextRouteReopenDelayMs() >= reloadWaitBudgetMs) {
1297
1517
  throw reloadWindowExhaustedError(error, callDeadlineMs);
1298
1518
  }
1299
1519
  const { delayMs, wait } = this.waitForRouteReopenBackoff();
1300
- reloadWaitedMs += delayMs;
1520
+ scheduledDelayMs = spentMs() + delayMs;
1301
1521
  await wait;
1302
1522
  routeAndEntry = await openRouteAfterReloadWindow();
1303
1523
  reopened = true;
@@ -1306,7 +1526,15 @@ export class SubcTransportPool {
1306
1526
  if (ownsRoute && !retriedAbsentRoute && isRouteProvenAbsentError(error)) {
1307
1527
  retriedAbsentRoute = true;
1308
1528
  clearRouteEntry(routeAndEntry.entry);
1309
- await this.waitForRouteReopenBackoff().wait;
1529
+ // The one resend after a vanished route is still bounded by the
1530
+ // call's deadline: its delay is charged like any other, and a
1531
+ // delay that would run past the deadline is not taken.
1532
+ if (spentMs() + this.nextRouteReopenDelayMs() >= callDeadlineMs) {
1533
+ throw callDeadlinePassedError(error, callDeadlineMs);
1534
+ }
1535
+ const { delayMs, wait } = this.waitForRouteReopenBackoff();
1536
+ scheduledDelayMs = spentMs() + delayMs;
1537
+ await wait;
1310
1538
  routeAndEntry = await openRouteAfterReloadWindow();
1311
1539
  reopened = true;
1312
1540
  continue;
@@ -1425,6 +1653,7 @@ export class SubcTransportPool {
1425
1653
  }
1426
1654
  entry.handle = route;
1427
1655
  entry.opening = null;
1656
+ this.noteClientAlive(client);
1428
1657
  return route;
1429
1658
  })
1430
1659
  .catch((error) => {
@@ -1487,13 +1716,13 @@ export class SubcTransportPool {
1487
1716
  if (sub && record.bgSub === sub)
1488
1717
  record.bgSub = null;
1489
1718
  };
1490
- sub = new BgSubscription(identity, () => this.ensureClient(), (client) => this.dropClient(client), this.consumerIdentity, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
1719
+ sub = new BgSubscription(identity, () => this.ensureClient(), (client, cause) => this.dropClient(client, cause), this.consumerIdentity, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
1491
1720
  if (!isAbsentRootRouteError(error))
1492
1721
  return false;
1493
1722
  this.markRootDormant(record.canonicalRoot, error);
1494
1723
  return true;
1495
1724
  }, clearDormantSubscription, this.bgDispatchProbeIntervalMs, nudgeRef, () => this.isCurrentSession(record.identityKey, record) &&
1496
- this.isCurrentLiveGeneration(record.canonicalRoot, record.generation));
1725
+ this.isCurrentLiveGeneration(record.canonicalRoot, record.generation), (client) => this.noteClientAlive(client), (client, cause) => this.noteUnansweredCall(client, cause));
1497
1726
  record.bgSub = sub;
1498
1727
  if (!this.rootCanAttach(record.canonicalRoot)) {
1499
1728
  record.bgSub = null;
@@ -1503,10 +1732,30 @@ export class SubcTransportPool {
1503
1732
  /**
1504
1733
  * Invalidate only routes owned by a dead client. Sessions remain indexed so a
1505
1734
  * replacement client can reconnect the same identity and its bg subscription.
1735
+ *
1736
+ * Only socket death reaches here (see {@link isSocketDeathError}), or a run of
1737
+ * unanswered calls that a liveness probe then confirmed. Every drop takes all
1738
+ * sessions' routes down with it, so each one logs its cause and its reach.
1506
1739
  */
1507
- dropClient(client) {
1740
+ dropClient(client, cause) {
1508
1741
  if (this.client !== client)
1509
1742
  return;
1743
+ let sessions = 0;
1744
+ let routes = 0;
1745
+ for (const record of this.sessions.values()) {
1746
+ const toolRoute = record.routeEntry?.client === client ? 1 : 0;
1747
+ const bgRoute = record.bgSub?.attachedClient === client ? 1 : 0;
1748
+ if (toolRoute + bgRoute > 0)
1749
+ sessions += 1;
1750
+ routes += toolRoute + bgRoute;
1751
+ }
1752
+ const message = cause.error instanceof Error ? cause.error.message : String(cause.error);
1753
+ warn(`subc pool: dropping shared client cause=${describeFailure(cause.error)} trigger=${cause.trigger}` +
1754
+ (cause.session ? ` session=${cause.session}` : "") +
1755
+ (cause.route ? ` route=${cause.route}` : "") +
1756
+ ` sessions=${sessions} routes=${routes}` +
1757
+ (cause.detail ? ` ${cause.detail}` : "") +
1758
+ ` message=${JSON.stringify(message)}`);
1510
1759
  this.client = null;
1511
1760
  for (const record of this.sessions.values()) {
1512
1761
  const entry = record.routeEntry;
@@ -1524,6 +1773,85 @@ export class SubcTransportPool {
1524
1773
  // A dead socket is already released by the peer.
1525
1774
  }
1526
1775
  }
1776
+ /** Record that the current client delivered a reply, which proves its socket is alive. */
1777
+ noteClientAlive(client) {
1778
+ if (this.client === client)
1779
+ this.clientLivenessMark += 1;
1780
+ }
1781
+ /**
1782
+ * Count a call on `client` that failed without proving the socket dead. After
1783
+ * MAX_CONSECUTIVE_TRANSPORT_FAILURES in a row the connection is checked with a
1784
+ * liveness probe; the count alone never closes it.
1785
+ */
1786
+ noteUnansweredCall(client, cause) {
1787
+ if (this.client !== client)
1788
+ return;
1789
+ if (++this.transportFailures < MAX_CONSECUTIVE_TRANSPORT_FAILURES)
1790
+ return;
1791
+ this.transportFailures = 0;
1792
+ this.probeClientLiveness(client, cause);
1793
+ }
1794
+ /**
1795
+ * Decide whether a connection that stopped answering calls is dead. A channel-0
1796
+ * catalog.list round trip is sent on the same connection; the client is dropped
1797
+ * only when that probe fails with socket death, or goes unanswered for the
1798
+ * probe window while no other reply arrived on the connection either. Any
1799
+ * reply, including a refusal, keeps the client.
1800
+ */
1801
+ probeClientLiveness(client, suspicion) {
1802
+ if (this.livenessProbe || this.client !== client)
1803
+ return;
1804
+ const suspicionText = `after ${MAX_CONSECUTIVE_TRANSPORT_FAILURES} consecutive unanswered calls; last ${suspicion.trigger} failed with ${describeFailure(suspicion.error)}`;
1805
+ if (typeof client.catalogList !== "function") {
1806
+ log(`subc pool: keeping shared client ${suspicionText}; the client offers no liveness probe`);
1807
+ return;
1808
+ }
1809
+ const mark = this.clientLivenessMark;
1810
+ const windowMs = this.livenessProbeTimeoutMs;
1811
+ let timer;
1812
+ const timedOut = new Promise((resolve) => {
1813
+ timer = setTimeout(() => resolve("timeout"), windowMs);
1814
+ timer.unref?.();
1815
+ });
1816
+ const probe = (async () => {
1817
+ let deathEvidence = null;
1818
+ try {
1819
+ const reply = Promise.resolve().then(() => client.catalogList?.(AFT_MODULE_ID));
1820
+ const outcome = await Promise.race([reply.then(() => "answered"), timedOut]);
1821
+ if (outcome === "timeout")
1822
+ deathEvidence = new SubcLivenessProbeTimeoutError(windowMs);
1823
+ }
1824
+ catch (error) {
1825
+ // Only socket death or the probe's own deadline convicts. Any other
1826
+ // rejection came back over the connection, which is proof it works.
1827
+ if (isSocketDeathError(error) ||
1828
+ (error instanceof SubcError && error.code === REQUEST_DEADLINE_CODE)) {
1829
+ deathEvidence = error;
1830
+ }
1831
+ }
1832
+ finally {
1833
+ clearTimeout(timer);
1834
+ }
1835
+ if (this.client !== client)
1836
+ return;
1837
+ if (deathEvidence !== null && this.clientLivenessMark === mark) {
1838
+ this.dropClient(client, {
1839
+ error: deathEvidence,
1840
+ trigger: "liveness probe",
1841
+ session: suspicion.session,
1842
+ route: suspicion.route,
1843
+ detail: suspicionText,
1844
+ });
1845
+ return;
1846
+ }
1847
+ this.transportFailures = 0;
1848
+ log(`subc pool: keeping shared client ${suspicionText}; the connection answered`);
1849
+ })().finally(() => {
1850
+ if (this.livenessProbe === probe)
1851
+ this.livenessProbe = null;
1852
+ });
1853
+ this.livenessProbe = probe;
1854
+ }
1527
1855
  /** Synchronous concrete-facade eviction used by the registry coordinator. */
1528
1856
  evictConcreteFacade(root, generation) {
1529
1857
  const facade = this.transports.get(root);