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