@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.
- package/dist/bash-hints.d.ts +22 -3
- package/dist/bash-hints.d.ts.map +1 -1
- package/dist/bash-hints.js +28 -9
- package/dist/bash-hints.js.map +1 -1
- package/dist/bash-host-fallback.js +1 -1
- package/dist/bash-host-fallback.js.map +1 -1
- package/dist/binary-identity.d.ts +109 -0
- package/dist/binary-identity.d.ts.map +1 -0
- package/dist/binary-identity.js +295 -0
- package/dist/binary-identity.js.map +1 -0
- package/dist/bridge.d.ts +10 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +50 -14
- package/dist/bridge.js.map +1 -1
- package/dist/child-process.d.ts +37 -0
- package/dist/child-process.d.ts.map +1 -0
- package/dist/child-process.js +62 -0
- package/dist/child-process.js.map +1 -0
- package/dist/coerce.d.ts +2 -0
- package/dist/coerce.d.ts.map +1 -1
- package/dist/coerce.js +29 -0
- package/dist/coerce.js.map +1 -1
- package/dist/config-error.d.ts +71 -0
- package/dist/config-error.d.ts.map +1 -0
- package/dist/config-error.js +112 -0
- package/dist/config-error.js.map +1 -0
- package/dist/downloader.d.ts +4 -2
- package/dist/downloader.d.ts.map +1 -1
- package/dist/downloader.js +41 -20
- package/dist/downloader.js.map +1 -1
- package/dist/durable-log.d.ts +7 -0
- package/dist/durable-log.d.ts.map +1 -1
- package/dist/durable-log.js +47 -4
- package/dist/durable-log.js.map +1 -1
- package/dist/error-contract.d.ts +19 -0
- package/dist/error-contract.d.ts.map +1 -1
- package/dist/error-contract.js +24 -0
- package/dist/error-contract.js.map +1 -1
- package/dist/feature-config.d.ts +192 -0
- package/dist/feature-config.d.ts.map +1 -0
- package/dist/feature-config.js +657 -0
- package/dist/feature-config.js.map +1 -0
- package/dist/index.d.ts +17 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -6
- package/dist/index.js.map +1 -1
- package/dist/message-detach.d.ts +25 -0
- package/dist/message-detach.d.ts.map +1 -0
- package/dist/message-detach.js +34 -0
- package/dist/message-detach.js.map +1 -0
- package/dist/migration-notices.d.ts +29 -0
- package/dist/migration-notices.d.ts.map +1 -0
- package/dist/migration-notices.js +114 -0
- package/dist/migration-notices.js.map +1 -0
- package/dist/migration.d.ts +1 -1
- package/dist/migration.d.ts.map +1 -1
- package/dist/migration.js +1 -1
- package/dist/migration.js.map +1 -1
- package/dist/npm-resolver.d.ts +1 -1
- package/dist/npm-resolver.d.ts.map +1 -1
- package/dist/npm-resolver.js +1 -1
- package/dist/npm-resolver.js.map +1 -1
- package/dist/onnx-probe.d.ts +44 -0
- package/dist/onnx-probe.d.ts.map +1 -0
- package/dist/onnx-probe.js +167 -0
- package/dist/onnx-probe.js.map +1 -0
- package/dist/onnx-runtime.d.ts +19 -4
- package/dist/onnx-runtime.d.ts.map +1 -1
- package/dist/onnx-runtime.js +146 -34
- package/dist/onnx-runtime.js.map +1 -1
- package/dist/path-lookup.d.ts +22 -0
- package/dist/path-lookup.d.ts.map +1 -0
- package/dist/path-lookup.js +51 -0
- package/dist/path-lookup.js.map +1 -0
- package/dist/resolver.d.ts +47 -21
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +254 -159
- package/dist/resolver.js.map +1 -1
- package/dist/revivable-transport.d.ts.map +1 -1
- package/dist/revivable-transport.js +12 -0
- package/dist/revivable-transport.js.map +1 -1
- package/dist/semantic-status.d.ts +10 -1
- package/dist/semantic-status.d.ts.map +1 -1
- package/dist/semantic-status.js +50 -2
- package/dist/semantic-status.js.map +1 -1
- package/dist/storage-paths.d.ts +9 -0
- package/dist/storage-paths.d.ts.map +1 -1
- package/dist/storage-paths.js +18 -1
- package/dist/storage-paths.js.map +1 -1
- package/dist/subc-transport.d.ts +60 -1
- package/dist/subc-transport.d.ts.map +1 -1
- package/dist/subc-transport.js +382 -69
- package/dist/subc-transport.js.map +1 -1
- package/dist/tar-executable.js +1 -1
- package/dist/tar-executable.js.map +1 -1
- package/dist/transport-factory.d.ts +14 -2
- package/dist/transport-factory.d.ts.map +1 -1
- package/dist/transport-factory.js +24 -16
- package/dist/transport-factory.js.map +1 -1
- package/dist/version-probe.d.ts +33 -0
- package/dist/version-probe.d.ts.map +1 -0
- package/dist/version-probe.js +108 -0
- package/dist/version-probe.js.map +1 -0
- package/package.json +2 -2
- package/dist/bash-timeout.d.ts +0 -19
- package/dist/bash-timeout.d.ts.map +0 -1
- package/dist/bash-timeout.js +0 -26
- package/dist/bash-timeout.js.map +0 -1
package/dist/subc-transport.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
43
|
-
* on the SAME client
|
|
44
|
-
* succeed, no response ever arrives
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
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 (
|
|
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 (
|
|
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 +=
|
|
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)}${
|
|
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
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
this.client
|
|
1154
|
-
|
|
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
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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 (
|
|
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 (
|
|
1194
|
-
this.transportFailures = 0;
|
|
1195
|
-
this.dropClient(client);
|
|
1379
|
+
if (isRouteProvenAbsentError(error))
|
|
1196
1380
|
return;
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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);
|