@cortexkit/aft-bridge 0.57.2 → 0.58.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.
Files changed (104) hide show
  1. package/dist/bash-hints.d.ts +22 -3
  2. package/dist/bash-hints.d.ts.map +1 -1
  3. package/dist/bash-hints.js +28 -9
  4. package/dist/bash-hints.js.map +1 -1
  5. package/dist/bash-host-fallback.js +1 -1
  6. package/dist/bash-host-fallback.js.map +1 -1
  7. package/dist/binary-identity.d.ts +109 -0
  8. package/dist/binary-identity.d.ts.map +1 -0
  9. package/dist/binary-identity.js +295 -0
  10. package/dist/binary-identity.js.map +1 -0
  11. package/dist/bridge.d.ts +10 -1
  12. package/dist/bridge.d.ts.map +1 -1
  13. package/dist/bridge.js +50 -14
  14. package/dist/bridge.js.map +1 -1
  15. package/dist/child-process.d.ts +37 -0
  16. package/dist/child-process.d.ts.map +1 -0
  17. package/dist/child-process.js +62 -0
  18. package/dist/child-process.js.map +1 -0
  19. package/dist/coerce.d.ts +2 -0
  20. package/dist/coerce.d.ts.map +1 -1
  21. package/dist/coerce.js +29 -0
  22. package/dist/coerce.js.map +1 -1
  23. package/dist/config-error.d.ts +71 -0
  24. package/dist/config-error.d.ts.map +1 -0
  25. package/dist/config-error.js +112 -0
  26. package/dist/config-error.js.map +1 -0
  27. package/dist/downloader.d.ts +4 -2
  28. package/dist/downloader.d.ts.map +1 -1
  29. package/dist/downloader.js +41 -20
  30. package/dist/downloader.js.map +1 -1
  31. package/dist/durable-log.d.ts +7 -0
  32. package/dist/durable-log.d.ts.map +1 -1
  33. package/dist/durable-log.js +47 -4
  34. package/dist/durable-log.js.map +1 -1
  35. package/dist/feature-config.d.ts +192 -0
  36. package/dist/feature-config.d.ts.map +1 -0
  37. package/dist/feature-config.js +657 -0
  38. package/dist/feature-config.js.map +1 -0
  39. package/dist/index.d.ts +17 -7
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +17 -6
  42. package/dist/index.js.map +1 -1
  43. package/dist/message-detach.d.ts +25 -0
  44. package/dist/message-detach.d.ts.map +1 -0
  45. package/dist/message-detach.js +34 -0
  46. package/dist/message-detach.js.map +1 -0
  47. package/dist/migration-notices.d.ts +29 -0
  48. package/dist/migration-notices.d.ts.map +1 -0
  49. package/dist/migration-notices.js +114 -0
  50. package/dist/migration-notices.js.map +1 -0
  51. package/dist/migration.d.ts +1 -1
  52. package/dist/migration.d.ts.map +1 -1
  53. package/dist/migration.js +1 -1
  54. package/dist/migration.js.map +1 -1
  55. package/dist/npm-resolver.d.ts +1 -1
  56. package/dist/npm-resolver.d.ts.map +1 -1
  57. package/dist/npm-resolver.js +1 -1
  58. package/dist/npm-resolver.js.map +1 -1
  59. package/dist/onnx-probe.d.ts +44 -0
  60. package/dist/onnx-probe.d.ts.map +1 -0
  61. package/dist/onnx-probe.js +167 -0
  62. package/dist/onnx-probe.js.map +1 -0
  63. package/dist/onnx-runtime.d.ts +19 -4
  64. package/dist/onnx-runtime.d.ts.map +1 -1
  65. package/dist/onnx-runtime.js +146 -34
  66. package/dist/onnx-runtime.js.map +1 -1
  67. package/dist/path-lookup.d.ts +22 -0
  68. package/dist/path-lookup.d.ts.map +1 -0
  69. package/dist/path-lookup.js +51 -0
  70. package/dist/path-lookup.js.map +1 -0
  71. package/dist/resolver.d.ts +47 -21
  72. package/dist/resolver.d.ts.map +1 -1
  73. package/dist/resolver.js +254 -159
  74. package/dist/resolver.js.map +1 -1
  75. package/dist/revivable-transport.d.ts.map +1 -1
  76. package/dist/revivable-transport.js +12 -0
  77. package/dist/revivable-transport.js.map +1 -1
  78. package/dist/semantic-status.d.ts +10 -1
  79. package/dist/semantic-status.d.ts.map +1 -1
  80. package/dist/semantic-status.js +50 -2
  81. package/dist/semantic-status.js.map +1 -1
  82. package/dist/storage-paths.d.ts +9 -0
  83. package/dist/storage-paths.d.ts.map +1 -1
  84. package/dist/storage-paths.js +18 -1
  85. package/dist/storage-paths.js.map +1 -1
  86. package/dist/subc-transport.d.ts +58 -1
  87. package/dist/subc-transport.d.ts.map +1 -1
  88. package/dist/subc-transport.js +291 -35
  89. package/dist/subc-transport.js.map +1 -1
  90. package/dist/tar-executable.js +1 -1
  91. package/dist/tar-executable.js.map +1 -1
  92. package/dist/transport-factory.d.ts +14 -2
  93. package/dist/transport-factory.d.ts.map +1 -1
  94. package/dist/transport-factory.js +24 -16
  95. package/dist/transport-factory.js.map +1 -1
  96. package/dist/version-probe.d.ts +33 -0
  97. package/dist/version-probe.d.ts.map +1 -0
  98. package/dist/version-probe.js +108 -0
  99. package/dist/version-probe.js.map +1 -0
  100. package/package.json +1 -1
  101. package/dist/bash-timeout.d.ts +0 -19
  102. package/dist/bash-timeout.d.ts.map +0 -1
  103. package/dist/bash-timeout.js +0 -26
  104. 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;
@@ -174,13 +256,21 @@ class BgSubscription {
174
256
  dispatchProbeIntervalMs;
175
257
  nudgeRef;
176
258
  isCurrent;
259
+ noteClientAlive;
260
+ noteUnansweredCall;
177
261
  stopped = false;
178
262
  /** The live subscription handle, read by stop() to wake the loop's `await closed`. */
179
263
  current = null;
264
+ /** Client carrying the live subscription route, counted when that client is dropped. */
265
+ attached = null;
180
266
  loop;
181
267
  lifecycleLogState = new Map();
182
268
  nudgeReceiptLogState = null;
183
- constructor(identity, acquireClient, dropClient, consumerIdentity, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, dispatchProbeIntervalMs, nudgeRef, isCurrent = () => true) {
269
+ constructor(identity, acquireClient, dropClient, consumerIdentity, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, dispatchProbeIntervalMs, nudgeRef, isCurrent = () => true,
270
+ /** Reports a reply seen on the client, which proves its socket still delivers. */
271
+ noteClientAlive = () => undefined,
272
+ /** Reports a call on the client that got no answer; enough in a row start a liveness probe. */
273
+ noteUnansweredCall = () => undefined) {
184
274
  this.identity = identity;
185
275
  this.acquireClient = acquireClient;
186
276
  this.dropClient = dropClient;
@@ -193,8 +283,14 @@ class BgSubscription {
193
283
  this.dispatchProbeIntervalMs = dispatchProbeIntervalMs;
194
284
  this.nudgeRef = nudgeRef;
195
285
  this.isCurrent = isCurrent;
286
+ this.noteClientAlive = noteClientAlive;
287
+ this.noteUnansweredCall = noteUnansweredCall;
196
288
  this.loop = this.run();
197
289
  }
290
+ /** The client whose route currently carries this subscription, if any. */
291
+ get attachedClient() {
292
+ return this.attached;
293
+ }
198
294
  async stop() {
199
295
  this.stopped = true;
200
296
  // Wake a live `await sub.closed`; the loop's `finally` remains the sole
@@ -325,8 +421,23 @@ class BgSubscription {
325
421
  this.onDormant();
326
422
  return;
327
423
  }
328
- if (isConsumerReconnectRequired(err))
329
- this.dropClient(client);
424
+ if (isSocketDeathError(err)) {
425
+ this.dropClient(client, {
426
+ error: err,
427
+ trigger: "bg_events route.open",
428
+ session: this.identity.session,
429
+ });
430
+ }
431
+ else if (isDaemonAnswerError(err)) {
432
+ this.noteClientAlive(client);
433
+ }
434
+ else if (isUnansweredCallError(err)) {
435
+ this.noteUnansweredCall(client, {
436
+ error: err,
437
+ trigger: "bg_events route.open",
438
+ session: this.identity.session,
439
+ });
440
+ }
330
441
  if (!reconnecting)
331
442
  beginReconnect();
332
443
  this.info("reconnect-error", `reconnect error attempt=${reconnectAttempt} error=${this.errorText(err)}`);
@@ -341,6 +452,7 @@ class BgSubscription {
341
452
  giveUp(this.stopped ? "stopped" : "stale-session");
342
453
  return;
343
454
  }
455
+ this.noteClientAlive(client);
344
456
  const subscribedAt = Date.now();
345
457
  const routeId = this.routeId(route);
346
458
  let stopDispatchProbe = () => undefined;
@@ -351,12 +463,14 @@ class BgSubscription {
351
463
  return;
352
464
  }
353
465
  this.recordNudgeReceipt(routeId);
466
+ this.noteClientAlive(client);
354
467
  if (!this.isCurrent()) {
355
468
  this.info("nudge-stale-carrier", `nudge carried by stale subscription; checking current session channel=${routeId}`);
356
469
  }
357
470
  this.onNudge();
358
471
  });
359
472
  this.current = sub;
473
+ this.attached = client;
360
474
  stopDispatchProbe = this.startDispatchProbe(client, routeId);
361
475
  this.info("subscription-open", `subscription open channel=${routeId}`);
362
476
  if (reconnecting) {
@@ -391,8 +505,14 @@ class BgSubscription {
391
505
  giveUp("stopped");
392
506
  return;
393
507
  }
394
- if (isConsumerReconnectRequired(err))
395
- this.dropClient(client);
508
+ if (isSocketDeathError(err)) {
509
+ this.dropClient(client, {
510
+ error: err,
511
+ trigger: "bg_events stream",
512
+ session: this.identity.session,
513
+ route: routeId,
514
+ });
515
+ }
396
516
  if (Date.now() - subscribedAt >= BG_STABLE_MS)
397
517
  backoffAttempt = 0;
398
518
  beginReconnect();
@@ -400,6 +520,7 @@ class BgSubscription {
400
520
  finally {
401
521
  stopDispatchProbe();
402
522
  this.current = null;
523
+ this.attached = null;
403
524
  safeCloseRoute(client, route);
404
525
  }
405
526
  await this.backoff(backoffAttempt++);
@@ -617,6 +738,7 @@ export class SubcTransportPool {
617
738
  bgBackoffSleep;
618
739
  routeRetrySleep;
619
740
  bgDispatchProbeIntervalMs;
741
+ livenessProbeTimeoutMs;
620
742
  lifecycleDemandCheck;
621
743
  onLifecycleEvent;
622
744
  onBgNudgeRejected;
@@ -642,8 +764,15 @@ export class SubcTransportPool {
642
764
  rootIndex = new Map();
643
765
  /** Roots whose route binds must stay dormant until their directories return. */
644
766
  dormantRoots = new Map();
645
- /** Consecutive non-transient failures on the current pool-local client. */
767
+ /** Consecutive unanswered calls on the current pool-local client. */
646
768
  transportFailures = 0;
769
+ /** The in-flight liveness probe of the current client; at most one runs at a time. */
770
+ livenessProbe = null;
771
+ /**
772
+ * Bumped whenever the current client delivers any reply. A liveness probe that
773
+ * times out while this moved has been outrun by other traffic, not by a dead socket.
774
+ */
775
+ clientLivenessMark = 0;
647
776
  /** Concrete per-root facades, including their captured root generation. */
648
777
  transports = new Map();
649
778
  generationRejections = new Set();
@@ -666,6 +795,7 @@ export class SubcTransportPool {
666
795
  options.routeRetrySleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
667
796
  this.bgDispatchProbeIntervalMs =
668
797
  options.bgDispatchProbeIntervalMs ?? BG_DISPATCH_PROBE_INTERVAL_MS;
798
+ this.livenessProbeTimeoutMs = options.livenessProbeTimeoutMs ?? LIVENESS_PROBE_TIMEOUT_MS;
669
799
  const lifecycle = options.lifecycle;
670
800
  const demandCheck = options.lifecycleDemandCheck ?? options.demandCheck ?? lifecycle?.demandCheck;
671
801
  this.lifecycleDemandCheck = demandCheck;
@@ -1170,10 +1300,25 @@ export class SubcTransportPool {
1170
1300
  throw this.annotateReapError(error, record);
1171
1301
  if (error instanceof RouteTornDownError)
1172
1302
  throw error;
1173
- if (isConsumerReconnectRequired(error) &&
1174
- this.isCurrentSession(key, record) &&
1175
- this.client === client) {
1176
- this.dropClient(client);
1303
+ const ownsClient = this.isCurrentSession(key, record) && this.client === client;
1304
+ if (ownsClient && isSocketDeathError(error)) {
1305
+ this.dropClient(client, {
1306
+ error,
1307
+ trigger: "route.open",
1308
+ session: identity.session,
1309
+ });
1310
+ }
1311
+ else if (isDaemonAnswerError(error)) {
1312
+ // A refusal (cap reached, target unavailable, ...) fails this route
1313
+ // only, and proves the shared socket still delivers replies.
1314
+ this.noteClientAlive(client);
1315
+ }
1316
+ else if (ownsClient && isUnansweredCallError(error)) {
1317
+ this.noteUnansweredCall(client, {
1318
+ error,
1319
+ trigger: "route.open",
1320
+ session: identity.session,
1321
+ });
1177
1322
  }
1178
1323
  throw error;
1179
1324
  }
@@ -1215,23 +1360,33 @@ export class SubcTransportPool {
1215
1360
  safeCloseRoute(entry.client, entry.handle);
1216
1361
  };
1217
1362
  const handleRequestFailure = (error, entry) => {
1363
+ const aborted = error instanceof Error && error.name === "AbortError";
1364
+ const ownsClient = this.isCurrentSession(key, record) &&
1365
+ this.client === client &&
1366
+ !this.isReapInduced(record);
1367
+ if (!aborted && ownsClient && isSocketDeathError(error)) {
1368
+ // Dropped before the route is cleared so the drop log counts it.
1369
+ this.dropClient(client, {
1370
+ error,
1371
+ trigger: "request",
1372
+ session: identity.session,
1373
+ route: routeLabel(entry.handle),
1374
+ });
1375
+ }
1218
1376
  clearRouteEntry(entry);
1219
- if (error instanceof Error && error.name === "AbortError")
1377
+ if (aborted || !ownsClient || this.client !== client)
1220
1378
  return;
1221
- if (!this.isCurrentSession(key, record) ||
1222
- this.client !== client ||
1223
- this.isReapInduced(record))
1379
+ if (isRouteProvenAbsentError(error))
1224
1380
  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
- }
1381
+ // Anything else is this call's own outcome: a timeout, a GOODBYE, a
1382
+ // refusal, or a managed not_sent/outcome_unknown verdict. It fails this
1383
+ // call and route only; a run of them just checks the connection.
1384
+ this.noteUnansweredCall(client, {
1385
+ error,
1386
+ trigger: "request",
1387
+ session: identity.session,
1388
+ route: routeLabel(entry.handle),
1389
+ });
1235
1390
  };
1236
1391
  const requestOnRoute = async (route, entry) => {
1237
1392
  this.assertRecordLive(record);
@@ -1262,6 +1417,7 @@ export class SubcTransportPool {
1262
1417
  this.assertGeneration(record.canonicalRoot, record.generation, "request_completion");
1263
1418
  if (this.client === client)
1264
1419
  this.transportFailures = 0;
1420
+ this.noteClientAlive(client);
1265
1421
  this.ensureBgSubscription(identity, record);
1266
1422
  return reply;
1267
1423
  }
@@ -1425,6 +1581,7 @@ export class SubcTransportPool {
1425
1581
  }
1426
1582
  entry.handle = route;
1427
1583
  entry.opening = null;
1584
+ this.noteClientAlive(client);
1428
1585
  return route;
1429
1586
  })
1430
1587
  .catch((error) => {
@@ -1487,13 +1644,13 @@ export class SubcTransportPool {
1487
1644
  if (sub && record.bgSub === sub)
1488
1645
  record.bgSub = null;
1489
1646
  };
1490
- sub = new BgSubscription(identity, () => this.ensureClient(), (client) => this.dropClient(client), this.consumerIdentity, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
1647
+ sub = new BgSubscription(identity, () => this.ensureClient(), (client, cause) => this.dropClient(client, cause), this.consumerIdentity, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
1491
1648
  if (!isAbsentRootRouteError(error))
1492
1649
  return false;
1493
1650
  this.markRootDormant(record.canonicalRoot, error);
1494
1651
  return true;
1495
1652
  }, clearDormantSubscription, this.bgDispatchProbeIntervalMs, nudgeRef, () => this.isCurrentSession(record.identityKey, record) &&
1496
- this.isCurrentLiveGeneration(record.canonicalRoot, record.generation));
1653
+ this.isCurrentLiveGeneration(record.canonicalRoot, record.generation), (client) => this.noteClientAlive(client), (client, cause) => this.noteUnansweredCall(client, cause));
1497
1654
  record.bgSub = sub;
1498
1655
  if (!this.rootCanAttach(record.canonicalRoot)) {
1499
1656
  record.bgSub = null;
@@ -1503,10 +1660,30 @@ export class SubcTransportPool {
1503
1660
  /**
1504
1661
  * Invalidate only routes owned by a dead client. Sessions remain indexed so a
1505
1662
  * replacement client can reconnect the same identity and its bg subscription.
1663
+ *
1664
+ * Only socket death reaches here (see {@link isSocketDeathError}), or a run of
1665
+ * unanswered calls that a liveness probe then confirmed. Every drop takes all
1666
+ * sessions' routes down with it, so each one logs its cause and its reach.
1506
1667
  */
1507
- dropClient(client) {
1668
+ dropClient(client, cause) {
1508
1669
  if (this.client !== client)
1509
1670
  return;
1671
+ let sessions = 0;
1672
+ let routes = 0;
1673
+ for (const record of this.sessions.values()) {
1674
+ const toolRoute = record.routeEntry?.client === client ? 1 : 0;
1675
+ const bgRoute = record.bgSub?.attachedClient === client ? 1 : 0;
1676
+ if (toolRoute + bgRoute > 0)
1677
+ sessions += 1;
1678
+ routes += toolRoute + bgRoute;
1679
+ }
1680
+ const message = cause.error instanceof Error ? cause.error.message : String(cause.error);
1681
+ warn(`subc pool: dropping shared client cause=${describeFailure(cause.error)} trigger=${cause.trigger}` +
1682
+ (cause.session ? ` session=${cause.session}` : "") +
1683
+ (cause.route ? ` route=${cause.route}` : "") +
1684
+ ` sessions=${sessions} routes=${routes}` +
1685
+ (cause.detail ? ` ${cause.detail}` : "") +
1686
+ ` message=${JSON.stringify(message)}`);
1510
1687
  this.client = null;
1511
1688
  for (const record of this.sessions.values()) {
1512
1689
  const entry = record.routeEntry;
@@ -1524,6 +1701,85 @@ export class SubcTransportPool {
1524
1701
  // A dead socket is already released by the peer.
1525
1702
  }
1526
1703
  }
1704
+ /** Record that the current client delivered a reply, which proves its socket is alive. */
1705
+ noteClientAlive(client) {
1706
+ if (this.client === client)
1707
+ this.clientLivenessMark += 1;
1708
+ }
1709
+ /**
1710
+ * Count a call on `client` that failed without proving the socket dead. After
1711
+ * MAX_CONSECUTIVE_TRANSPORT_FAILURES in a row the connection is checked with a
1712
+ * liveness probe; the count alone never closes it.
1713
+ */
1714
+ noteUnansweredCall(client, cause) {
1715
+ if (this.client !== client)
1716
+ return;
1717
+ if (++this.transportFailures < MAX_CONSECUTIVE_TRANSPORT_FAILURES)
1718
+ return;
1719
+ this.transportFailures = 0;
1720
+ this.probeClientLiveness(client, cause);
1721
+ }
1722
+ /**
1723
+ * Decide whether a connection that stopped answering calls is dead. A channel-0
1724
+ * catalog.list round trip is sent on the same connection; the client is dropped
1725
+ * only when that probe fails with socket death, or goes unanswered for the
1726
+ * probe window while no other reply arrived on the connection either. Any
1727
+ * reply, including a refusal, keeps the client.
1728
+ */
1729
+ probeClientLiveness(client, suspicion) {
1730
+ if (this.livenessProbe || this.client !== client)
1731
+ return;
1732
+ const suspicionText = `after ${MAX_CONSECUTIVE_TRANSPORT_FAILURES} consecutive unanswered calls; last ${suspicion.trigger} failed with ${describeFailure(suspicion.error)}`;
1733
+ if (typeof client.catalogList !== "function") {
1734
+ log(`subc pool: keeping shared client ${suspicionText}; the client offers no liveness probe`);
1735
+ return;
1736
+ }
1737
+ const mark = this.clientLivenessMark;
1738
+ const windowMs = this.livenessProbeTimeoutMs;
1739
+ let timer;
1740
+ const timedOut = new Promise((resolve) => {
1741
+ timer = setTimeout(() => resolve("timeout"), windowMs);
1742
+ timer.unref?.();
1743
+ });
1744
+ const probe = (async () => {
1745
+ let deathEvidence = null;
1746
+ try {
1747
+ const reply = Promise.resolve().then(() => client.catalogList?.(AFT_MODULE_ID));
1748
+ const outcome = await Promise.race([reply.then(() => "answered"), timedOut]);
1749
+ if (outcome === "timeout")
1750
+ deathEvidence = new SubcLivenessProbeTimeoutError(windowMs);
1751
+ }
1752
+ catch (error) {
1753
+ // Only socket death or the probe's own deadline convicts. Any other
1754
+ // rejection came back over the connection, which is proof it works.
1755
+ if (isSocketDeathError(error) ||
1756
+ (error instanceof SubcError && error.code === REQUEST_DEADLINE_CODE)) {
1757
+ deathEvidence = error;
1758
+ }
1759
+ }
1760
+ finally {
1761
+ clearTimeout(timer);
1762
+ }
1763
+ if (this.client !== client)
1764
+ return;
1765
+ if (deathEvidence !== null && this.clientLivenessMark === mark) {
1766
+ this.dropClient(client, {
1767
+ error: deathEvidence,
1768
+ trigger: "liveness probe",
1769
+ session: suspicion.session,
1770
+ route: suspicion.route,
1771
+ detail: suspicionText,
1772
+ });
1773
+ return;
1774
+ }
1775
+ this.transportFailures = 0;
1776
+ log(`subc pool: keeping shared client ${suspicionText}; the connection answered`);
1777
+ })().finally(() => {
1778
+ if (this.livenessProbe === probe)
1779
+ this.livenessProbe = null;
1780
+ });
1781
+ this.livenessProbe = probe;
1782
+ }
1527
1783
  /** Synchronous concrete-facade eviction used by the registry coordinator. */
1528
1784
  evictConcreteFacade(root, generation) {
1529
1785
  const facade = this.transports.get(root);