@cortexkit/aft-bridge 0.57.1 → 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 (108) 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/error-contract.d.ts +19 -0
  36. package/dist/error-contract.d.ts.map +1 -1
  37. package/dist/error-contract.js +24 -0
  38. package/dist/error-contract.js.map +1 -1
  39. package/dist/feature-config.d.ts +192 -0
  40. package/dist/feature-config.d.ts.map +1 -0
  41. package/dist/feature-config.js +657 -0
  42. package/dist/feature-config.js.map +1 -0
  43. package/dist/index.d.ts +17 -7
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +17 -6
  46. package/dist/index.js.map +1 -1
  47. package/dist/message-detach.d.ts +25 -0
  48. package/dist/message-detach.d.ts.map +1 -0
  49. package/dist/message-detach.js +34 -0
  50. package/dist/message-detach.js.map +1 -0
  51. package/dist/migration-notices.d.ts +29 -0
  52. package/dist/migration-notices.d.ts.map +1 -0
  53. package/dist/migration-notices.js +114 -0
  54. package/dist/migration-notices.js.map +1 -0
  55. package/dist/migration.d.ts +1 -1
  56. package/dist/migration.d.ts.map +1 -1
  57. package/dist/migration.js +1 -1
  58. package/dist/migration.js.map +1 -1
  59. package/dist/npm-resolver.d.ts +1 -1
  60. package/dist/npm-resolver.d.ts.map +1 -1
  61. package/dist/npm-resolver.js +1 -1
  62. package/dist/npm-resolver.js.map +1 -1
  63. package/dist/onnx-probe.d.ts +44 -0
  64. package/dist/onnx-probe.d.ts.map +1 -0
  65. package/dist/onnx-probe.js +167 -0
  66. package/dist/onnx-probe.js.map +1 -0
  67. package/dist/onnx-runtime.d.ts +19 -4
  68. package/dist/onnx-runtime.d.ts.map +1 -1
  69. package/dist/onnx-runtime.js +146 -34
  70. package/dist/onnx-runtime.js.map +1 -1
  71. package/dist/path-lookup.d.ts +22 -0
  72. package/dist/path-lookup.d.ts.map +1 -0
  73. package/dist/path-lookup.js +51 -0
  74. package/dist/path-lookup.js.map +1 -0
  75. package/dist/resolver.d.ts +47 -21
  76. package/dist/resolver.d.ts.map +1 -1
  77. package/dist/resolver.js +254 -159
  78. package/dist/resolver.js.map +1 -1
  79. package/dist/revivable-transport.d.ts.map +1 -1
  80. package/dist/revivable-transport.js +12 -0
  81. package/dist/revivable-transport.js.map +1 -1
  82. package/dist/semantic-status.d.ts +10 -1
  83. package/dist/semantic-status.d.ts.map +1 -1
  84. package/dist/semantic-status.js +50 -2
  85. package/dist/semantic-status.js.map +1 -1
  86. package/dist/storage-paths.d.ts +9 -0
  87. package/dist/storage-paths.d.ts.map +1 -1
  88. package/dist/storage-paths.js +18 -1
  89. package/dist/storage-paths.js.map +1 -1
  90. package/dist/subc-transport.d.ts +60 -1
  91. package/dist/subc-transport.d.ts.map +1 -1
  92. package/dist/subc-transport.js +382 -69
  93. package/dist/subc-transport.js.map +1 -1
  94. package/dist/tar-executable.js +1 -1
  95. package/dist/tar-executable.js.map +1 -1
  96. package/dist/transport-factory.d.ts +14 -2
  97. package/dist/transport-factory.d.ts.map +1 -1
  98. package/dist/transport-factory.js +24 -16
  99. package/dist/transport-factory.js.map +1 -1
  100. package/dist/version-probe.d.ts +33 -0
  101. package/dist/version-probe.d.ts.map +1 -0
  102. package/dist/version-probe.js +108 -0
  103. package/dist/version-probe.js.map +1 -0
  104. package/package.json +2 -2
  105. package/dist/bash-timeout.d.ts +0 -19
  106. package/dist/bash-timeout.d.ts.map +0 -1
  107. package/dist/bash-timeout.js +0 -26
  108. package/dist/bash-timeout.js.map +0 -1
@@ -18,8 +18,8 @@
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";
22
- import { isRouteOpenReloadWindowError } from "./error-contract.js";
21
+ import { log, warn } from "./active-logger.js";
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";
25
25
  /** The subc pool is closing and cannot carry another request. */
@@ -33,26 +33,129 @@ 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;
53
- /** Maximum total restart-burst delay a fresh route.open may absorb. */
54
- const ROUTE_OPEN_RELOAD_WAIT_CAP_MS = 15_000;
55
- const ROUTE_OPEN_RELOAD_WAIT_EXHAUSTED_SUFFIX = " The AFT daemon module did not return within the 15s reload window.";
135
+ /**
136
+ * Deadline of a route request that passes no explicit timeout. It mirrors
137
+ * subc-client's `DEFAULT_REQUEST_TIMEOUT_MS`, which the client applies to such a
138
+ * request and does not export, so the reload wait and the request share one budget.
139
+ */
140
+ const SUBC_DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
141
+ /**
142
+ * Longest a call waits out route.open refusals while the AFT module reloads,
143
+ * however long the call's own timeout is. A restart drains the old module for at
144
+ * most the daemon's 30 s drain ceiling (reported as `drain_timeout_ms` by
145
+ * `ck module status aft`), then starts the new module (normally well under a
146
+ * second or two); 45 s covers that worst case with margin. The ceiling exists
147
+ * because a module that is genuinely down must reach the caller's fallback (for
148
+ * example bash host fallback) promptly instead of after a long transport timeout.
149
+ */
150
+ const ROUTE_OPEN_RELOAD_WAIT_CEILING_MS = 45_000;
151
+ function reloadWaitExhaustedSuffix(callDeadlineMs) {
152
+ if (callDeadlineMs > ROUTE_OPEN_RELOAD_WAIT_CEILING_MS) {
153
+ const ceilingSeconds = ROUTE_OPEN_RELOAD_WAIT_CEILING_MS / 1000;
154
+ return ` The AFT daemon module did not return within the ${ceilingSeconds}s reload-wait ceiling.`;
155
+ }
156
+ const seconds = Math.round(callDeadlineMs / 100) / 10;
157
+ return ` The AFT daemon module did not return within this call's ${seconds}s deadline.`;
158
+ }
56
159
  /**
57
160
  * A bg subscription that stayed up at least this long before dropping is treated
58
161
  * as "stable", so its reconnect backoff resets to zero. A subscription that fails
@@ -153,13 +256,21 @@ class BgSubscription {
153
256
  dispatchProbeIntervalMs;
154
257
  nudgeRef;
155
258
  isCurrent;
259
+ noteClientAlive;
260
+ noteUnansweredCall;
156
261
  stopped = false;
157
262
  /** The live subscription handle, read by stop() to wake the loop's `await closed`. */
158
263
  current = null;
264
+ /** Client carrying the live subscription route, counted when that client is dropped. */
265
+ attached = null;
159
266
  loop;
160
267
  lifecycleLogState = new Map();
161
268
  nudgeReceiptLogState = null;
162
- 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) {
163
274
  this.identity = identity;
164
275
  this.acquireClient = acquireClient;
165
276
  this.dropClient = dropClient;
@@ -172,8 +283,14 @@ class BgSubscription {
172
283
  this.dispatchProbeIntervalMs = dispatchProbeIntervalMs;
173
284
  this.nudgeRef = nudgeRef;
174
285
  this.isCurrent = isCurrent;
286
+ this.noteClientAlive = noteClientAlive;
287
+ this.noteUnansweredCall = noteUnansweredCall;
175
288
  this.loop = this.run();
176
289
  }
290
+ /** The client whose route currently carries this subscription, if any. */
291
+ get attachedClient() {
292
+ return this.attached;
293
+ }
177
294
  async stop() {
178
295
  this.stopped = true;
179
296
  // Wake a live `await sub.closed`; the loop's `finally` remains the sole
@@ -304,8 +421,23 @@ class BgSubscription {
304
421
  this.onDormant();
305
422
  return;
306
423
  }
307
- if (isConsumerReconnectRequired(err))
308
- 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
+ }
309
441
  if (!reconnecting)
310
442
  beginReconnect();
311
443
  this.info("reconnect-error", `reconnect error attempt=${reconnectAttempt} error=${this.errorText(err)}`);
@@ -320,6 +452,7 @@ class BgSubscription {
320
452
  giveUp(this.stopped ? "stopped" : "stale-session");
321
453
  return;
322
454
  }
455
+ this.noteClientAlive(client);
323
456
  const subscribedAt = Date.now();
324
457
  const routeId = this.routeId(route);
325
458
  let stopDispatchProbe = () => undefined;
@@ -330,12 +463,14 @@ class BgSubscription {
330
463
  return;
331
464
  }
332
465
  this.recordNudgeReceipt(routeId);
466
+ this.noteClientAlive(client);
333
467
  if (!this.isCurrent()) {
334
468
  this.info("nudge-stale-carrier", `nudge carried by stale subscription; checking current session channel=${routeId}`);
335
469
  }
336
470
  this.onNudge();
337
471
  });
338
472
  this.current = sub;
473
+ this.attached = client;
339
474
  stopDispatchProbe = this.startDispatchProbe(client, routeId);
340
475
  this.info("subscription-open", `subscription open channel=${routeId}`);
341
476
  if (reconnecting) {
@@ -370,8 +505,14 @@ class BgSubscription {
370
505
  giveUp("stopped");
371
506
  return;
372
507
  }
373
- if (isConsumerReconnectRequired(err))
374
- 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
+ }
375
516
  if (Date.now() - subscribedAt >= BG_STABLE_MS)
376
517
  backoffAttempt = 0;
377
518
  beginReconnect();
@@ -379,6 +520,7 @@ class BgSubscription {
379
520
  finally {
380
521
  stopDispatchProbe();
381
522
  this.current = null;
523
+ this.attached = null;
382
524
  safeCloseRoute(client, route);
383
525
  }
384
526
  await this.backoff(backoffAttempt++);
@@ -412,17 +554,18 @@ function absentRootError(root) {
412
554
  return new SubcError(`invalid route project root: project root does not exist: ${root}`, "config_divergence");
413
555
  }
414
556
  /** Preserve the daemon's final refusal while making the reload timeout actionable. */
415
- function reloadWindowExhaustedError(error) {
557
+ function reloadWindowExhaustedError(error, callDeadlineMs) {
558
+ const suffix = reloadWaitExhaustedSuffix(callDeadlineMs);
416
559
  if (error instanceof Error) {
417
560
  try {
418
- error.message += ROUTE_OPEN_RELOAD_WAIT_EXHAUSTED_SUFFIX;
561
+ error.message += suffix;
419
562
  return error;
420
563
  }
421
564
  catch {
422
565
  // A frozen SDK error cannot carry the suffix; retain its message in a wrapper.
423
566
  }
424
567
  }
425
- return new Error(`${String(error)}${ROUTE_OPEN_RELOAD_WAIT_EXHAUSTED_SUFFIX}`);
568
+ return new Error(`${String(error)}${suffix}`);
426
569
  }
427
570
  /**
428
571
  * Fire-and-forget route close that can never throw — neither synchronously (a
@@ -595,6 +738,7 @@ export class SubcTransportPool {
595
738
  bgBackoffSleep;
596
739
  routeRetrySleep;
597
740
  bgDispatchProbeIntervalMs;
741
+ livenessProbeTimeoutMs;
598
742
  lifecycleDemandCheck;
599
743
  onLifecycleEvent;
600
744
  onBgNudgeRejected;
@@ -620,8 +764,15 @@ export class SubcTransportPool {
620
764
  rootIndex = new Map();
621
765
  /** Roots whose route binds must stay dormant until their directories return. */
622
766
  dormantRoots = new Map();
623
- /** Consecutive non-transient failures on the current pool-local client. */
767
+ /** Consecutive unanswered calls on the current pool-local client. */
624
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;
625
776
  /** Concrete per-root facades, including their captured root generation. */
626
777
  transports = new Map();
627
778
  generationRejections = new Set();
@@ -644,6 +795,7 @@ export class SubcTransportPool {
644
795
  options.routeRetrySleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
645
796
  this.bgDispatchProbeIntervalMs =
646
797
  options.bgDispatchProbeIntervalMs ?? BG_DISPATCH_PROBE_INTERVAL_MS;
798
+ this.livenessProbeTimeoutMs = options.livenessProbeTimeoutMs ?? LIVENESS_PROBE_TIMEOUT_MS;
647
799
  const lifecycle = options.lifecycle;
648
800
  const demandCheck = options.lifecycleDemandCheck ?? options.demandCheck ?? lifecycle?.demandCheck;
649
801
  this.lifecycleDemandCheck = demandCheck;
@@ -1148,16 +1300,39 @@ export class SubcTransportPool {
1148
1300
  throw this.annotateReapError(error, record);
1149
1301
  if (error instanceof RouteTornDownError)
1150
1302
  throw error;
1151
- if (isConsumerReconnectRequired(error) &&
1152
- this.isCurrentSession(key, record) &&
1153
- this.client === client) {
1154
- 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
+ });
1155
1322
  }
1156
1323
  throw error;
1157
1324
  }
1158
1325
  };
1326
+ // A module reload (drain, restart, warm-up) refuses route.open for as long
1327
+ // as it lasts. The call waits it out for as long as its own deadline allows,
1328
+ // but never longer than ROUTE_OPEN_RELOAD_WAIT_CEILING_MS, and then surfaces
1329
+ // the refusal (which lets a caller such as bash fall back). Time spent
1330
+ // waiting comes out of the request's budget, so the call never outlives the
1331
+ // deadline it was given.
1332
+ const callDeadlineMs = timeoutMs ?? SUBC_DEFAULT_REQUEST_TIMEOUT_MS;
1333
+ const reloadWaitBudgetMs = Math.min(callDeadlineMs, ROUTE_OPEN_RELOAD_WAIT_CEILING_MS);
1334
+ let reloadWaitedMs = 0;
1159
1335
  const openRouteAfterReloadWindow = async () => {
1160
- let reloadWaitedMs = 0;
1161
1336
  while (true) {
1162
1337
  try {
1163
1338
  return await openRoute();
@@ -1165,10 +1340,12 @@ export class SubcTransportPool {
1165
1340
  catch (error) {
1166
1341
  if (!isRouteOpenReloadWindowError(error))
1167
1342
  throw error;
1168
- const { delayMs, wait } = this.waitForRouteReopenBackoff();
1169
- if (reloadWaitedMs + delayMs > ROUTE_OPEN_RELOAD_WAIT_CAP_MS) {
1170
- throw reloadWindowExhaustedError(error);
1343
+ // Check the budget before starting (or joining) the shared retry
1344
+ // timer, so a call that gives up leaves no orphan timer behind.
1345
+ if (reloadWaitedMs + this.nextRouteReopenDelayMs() >= reloadWaitBudgetMs) {
1346
+ throw reloadWindowExhaustedError(error, callDeadlineMs);
1171
1347
  }
1348
+ const { delayMs, wait } = this.waitForRouteReopenBackoff();
1172
1349
  reloadWaitedMs += delayMs;
1173
1350
  await wait;
1174
1351
  }
@@ -1183,23 +1360,33 @@ export class SubcTransportPool {
1183
1360
  safeCloseRoute(entry.client, entry.handle);
1184
1361
  };
1185
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
+ }
1186
1376
  clearRouteEntry(entry);
1187
- if (error instanceof Error && error.name === "AbortError")
1188
- return;
1189
- if (!this.isCurrentSession(key, record) ||
1190
- this.client !== client ||
1191
- this.isReapInduced(record))
1377
+ if (aborted || !ownsClient || this.client !== client)
1192
1378
  return;
1193
- if (isConsumerReconnectRequired(error)) {
1194
- this.transportFailures = 0;
1195
- this.dropClient(client);
1379
+ if (isRouteProvenAbsentError(error))
1196
1380
  return;
1197
- }
1198
- if (!isRouteProvenAbsentError(error) &&
1199
- ++this.transportFailures >= MAX_CONSECUTIVE_TRANSPORT_FAILURES) {
1200
- this.transportFailures = 0;
1201
- this.dropClient(client);
1202
- }
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
+ });
1203
1390
  };
1204
1391
  const requestOnRoute = async (route, entry) => {
1205
1392
  this.assertRecordLive(record);
@@ -1217,7 +1404,8 @@ export class SubcTransportPool {
1217
1404
  };
1218
1405
  abortSignal?.addEventListener("abort", onAbort, { once: true });
1219
1406
  try {
1220
- const request = client.request(route, body, { timeoutMs, onProgress });
1407
+ const requestTimeoutMs = reloadWaitedMs > 0 ? callDeadlineMs - reloadWaitedMs : timeoutMs;
1408
+ const request = client.request(route, body, { timeoutMs: requestTimeoutMs, onProgress });
1221
1409
  const reply = abortSignal ? await Promise.race([request, aborted]) : await request;
1222
1410
  // A legacy closeSession may intentionally let an already-delivered reply
1223
1411
  // settle. It must not mutate shared state or recreate a subscription.
@@ -1229,6 +1417,7 @@ export class SubcTransportPool {
1229
1417
  this.assertGeneration(record.canonicalRoot, record.generation, "request_completion");
1230
1418
  if (this.client === client)
1231
1419
  this.transportFailures = 0;
1420
+ this.noteClientAlive(client);
1232
1421
  this.ensureBgSubscription(identity, record);
1233
1422
  return reply;
1234
1423
  }
@@ -1237,32 +1426,50 @@ export class SubcTransportPool {
1237
1426
  }
1238
1427
  };
1239
1428
  let routeAndEntry = await openRouteAfterReloadWindow();
1240
- try {
1241
- return await requestOnRoute(routeAndEntry.route, routeAndEntry.entry);
1242
- }
1243
- catch (error) {
1244
- if (this.isReapInduced(record))
1245
- throw this.annotateReapError(error, record);
1246
- if (isRouteProvenAbsentError(error) &&
1247
- this.isCurrentSession(key, record) &&
1248
- this.client === client) {
1249
- clearRouteEntry(routeAndEntry.entry);
1250
- await this.waitForRouteReopenBackoff().wait;
1251
- routeAndEntry = await openRouteAfterReloadWindow();
1252
- try {
1253
- const reply = await requestOnRoute(routeAndEntry.route, routeAndEntry.entry);
1429
+ let reopened = false;
1430
+ let retriedAbsentRoute = false;
1431
+ while (true) {
1432
+ try {
1433
+ const reply = await requestOnRoute(routeAndEntry.route, routeAndEntry.entry);
1434
+ if (reopened)
1254
1435
  this.resetRouteReopenBackoff();
1255
- return reply;
1436
+ return reply;
1437
+ }
1438
+ catch (error) {
1439
+ if (this.isReapInduced(record))
1440
+ throw this.annotateReapError(error, record);
1441
+ const ownsRoute = this.isCurrentSession(key, record) && this.client === client;
1442
+ // A route that was bound before a module reload started gets its
1443
+ // requests refused by the daemon for as long as the drain lasts. The
1444
+ // refusal happens before the request is forwarded, so it is handled
1445
+ // like a refused route.open: drop the stale route, then reopen and
1446
+ // resend within the same reload-wait budget. Only once that budget is
1447
+ // spent does the refusal surface (and let bash fall back to the host).
1448
+ // A healthy connection answered, so it does not count as a transport
1449
+ // failure.
1450
+ if (ownsRoute && isRouteRequestReloadRefusal(error)) {
1451
+ clearRouteEntry(routeAndEntry.entry);
1452
+ if (reloadWaitedMs + this.nextRouteReopenDelayMs() >= reloadWaitBudgetMs) {
1453
+ throw reloadWindowExhaustedError(error, callDeadlineMs);
1454
+ }
1455
+ const { delayMs, wait } = this.waitForRouteReopenBackoff();
1456
+ reloadWaitedMs += delayMs;
1457
+ await wait;
1458
+ routeAndEntry = await openRouteAfterReloadWindow();
1459
+ reopened = true;
1460
+ continue;
1256
1461
  }
1257
- catch (retryError) {
1258
- if (this.isReapInduced(record))
1259
- throw this.annotateReapError(retryError, record);
1260
- handleRequestFailure(retryError, routeAndEntry.entry);
1261
- throw retryError;
1462
+ if (ownsRoute && !retriedAbsentRoute && isRouteProvenAbsentError(error)) {
1463
+ retriedAbsentRoute = true;
1464
+ clearRouteEntry(routeAndEntry.entry);
1465
+ await this.waitForRouteReopenBackoff().wait;
1466
+ routeAndEntry = await openRouteAfterReloadWindow();
1467
+ reopened = true;
1468
+ continue;
1262
1469
  }
1470
+ handleRequestFailure(error, routeAndEntry.entry);
1471
+ throw error;
1263
1472
  }
1264
- handleRequestFailure(error, routeAndEntry.entry);
1265
- throw error;
1266
1473
  }
1267
1474
  }
1268
1475
  catch (error) {
@@ -1300,6 +1507,12 @@ export class SubcTransportPool {
1300
1507
  this.routeReopenRetryMs = delayMs;
1301
1508
  return { delayMs, wait: retry };
1302
1509
  }
1510
+ /** The delay the next `waitForRouteReopenBackoff` call would wait, without starting it. */
1511
+ nextRouteReopenDelayMs() {
1512
+ if (this.routeReopenRetry && this.routeReopenRetryMs !== null)
1513
+ return this.routeReopenRetryMs;
1514
+ return this.routeReopenRetryDelayMs;
1515
+ }
1303
1516
  resetRouteReopenBackoff() {
1304
1517
  this.routeReopenRetryDelayMs = RECONNECT_RETRY_FLOOR_MS;
1305
1518
  }
@@ -1368,6 +1581,7 @@ export class SubcTransportPool {
1368
1581
  }
1369
1582
  entry.handle = route;
1370
1583
  entry.opening = null;
1584
+ this.noteClientAlive(client);
1371
1585
  return route;
1372
1586
  })
1373
1587
  .catch((error) => {
@@ -1430,13 +1644,13 @@ export class SubcTransportPool {
1430
1644
  if (sub && record.bgSub === sub)
1431
1645
  record.bgSub = null;
1432
1646
  };
1433
- 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) => {
1434
1648
  if (!isAbsentRootRouteError(error))
1435
1649
  return false;
1436
1650
  this.markRootDormant(record.canonicalRoot, error);
1437
1651
  return true;
1438
1652
  }, clearDormantSubscription, this.bgDispatchProbeIntervalMs, nudgeRef, () => this.isCurrentSession(record.identityKey, record) &&
1439
- this.isCurrentLiveGeneration(record.canonicalRoot, record.generation));
1653
+ this.isCurrentLiveGeneration(record.canonicalRoot, record.generation), (client) => this.noteClientAlive(client), (client, cause) => this.noteUnansweredCall(client, cause));
1440
1654
  record.bgSub = sub;
1441
1655
  if (!this.rootCanAttach(record.canonicalRoot)) {
1442
1656
  record.bgSub = null;
@@ -1446,10 +1660,30 @@ export class SubcTransportPool {
1446
1660
  /**
1447
1661
  * Invalidate only routes owned by a dead client. Sessions remain indexed so a
1448
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.
1449
1667
  */
1450
- dropClient(client) {
1668
+ dropClient(client, cause) {
1451
1669
  if (this.client !== client)
1452
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)}`);
1453
1687
  this.client = null;
1454
1688
  for (const record of this.sessions.values()) {
1455
1689
  const entry = record.routeEntry;
@@ -1467,6 +1701,85 @@ export class SubcTransportPool {
1467
1701
  // A dead socket is already released by the peer.
1468
1702
  }
1469
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
+ }
1470
1783
  /** Synchronous concrete-facade eviction used by the registry coordinator. */
1471
1784
  evictConcreteFacade(root, generation) {
1472
1785
  const facade = this.transports.get(root);