@cortexkit/subc-client 0.16.0 → 0.17.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/client.d.ts +43 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +280 -46
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +2 -2
- package/dist/provider.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +318 -50
- package/src/index.ts +4 -0
- package/src/provider.ts +2 -1
package/src/client.ts
CHANGED
|
@@ -75,14 +75,22 @@ const DEADLINE_NO_DROP_CODE = "deadline_exceeded_no_drop_observed";
|
|
|
75
75
|
// A retryable route.open rejection (target booting / reloading / momentarily
|
|
76
76
|
// absent) is retried in-place against the same connection up to this deadline
|
|
77
77
|
// before it is surfaced as not_sent. Mirrors subc-client-rs
|
|
78
|
-
// DEFAULT_ROUTE_RETRY_DEADLINE so a target that is
|
|
79
|
-
//
|
|
80
|
-
// Sized against
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
78
|
+
// DEFAULT_ROUTE_RETRY_DEADLINE so a target that is unavailable while it restarts
|
|
79
|
+
// recovers without a misleading terminal error.
|
|
80
|
+
// Sized against a real module restart, not a single bind: the daemon drains the
|
|
81
|
+
// module's routes (up to 30s), waits for the old process to stop (up to its stop
|
|
82
|
+
// budget, 25s by default), then boots the new one. A measured prefrontal-core
|
|
83
|
+
// restart kept route.open refused for 62.5s end to end, which a 30s budget gave
|
|
84
|
+
// up on halfway through. 90s covers that window with room for a slow boot. A
|
|
85
|
+
// call that sets its own timeoutMs still stops retrying at that timeout.
|
|
86
|
+
export const ROUTE_OPEN_RETRY_DEADLINE_MS = 90_000;
|
|
87
|
+
// The most route.open requests this client keeps outstanding on one connection.
|
|
88
|
+
// Mirrors the daemon's MAX_PENDING_ROUTE_OPENS_PER_CONNECTION
|
|
89
|
+
// (crates/subc-daemon/src/server.rs): the daemon refuses every open beyond that
|
|
90
|
+
// many with a retryable target_unavailable, so sending more at once only
|
|
91
|
+
// manufactures refusals. Extra opens wait here, first come first served, and the
|
|
92
|
+
// wait counts against the same retry deadline as the refusals do.
|
|
93
|
+
export const MAX_ROUTE_OPENS_IN_FLIGHT = 8;
|
|
86
94
|
// Once a header arrives, its body must follow promptly; bound it so a truncated
|
|
87
95
|
// frame cannot wedge the read loop forever.
|
|
88
96
|
const BODY_READ_TIMEOUT_MS = 30_000;
|
|
@@ -320,6 +328,10 @@ export class SubcError extends Error {
|
|
|
320
328
|
}
|
|
321
329
|
}
|
|
322
330
|
|
|
331
|
+
// The wire flags belong to the frame, not the public error API. Keep them with
|
|
332
|
+
// that error across managed-call wrapping so Phase 2 can enforce origin here.
|
|
333
|
+
const errorFrameFlags = new WeakMap<SubcError, number>();
|
|
334
|
+
|
|
323
335
|
function requireBinaryBody(body: unknown): Uint8Array {
|
|
324
336
|
if (body instanceof Uint8Array) return body;
|
|
325
337
|
const type = body === null ? "null" : Array.isArray(body) ? "array" : typeof body;
|
|
@@ -354,12 +366,23 @@ export interface ConnectOptions {
|
|
|
354
366
|
reconnectBackoff?: ReconnectBackoff;
|
|
355
367
|
/** Injectable sleep for timer-free reconnect tests. */
|
|
356
368
|
sleep?: (ms: number) => Promise<void>;
|
|
369
|
+
/**
|
|
370
|
+
* Injectable clock (milliseconds) for the route.open retry deadline. Tests pair
|
|
371
|
+
* it with `sleep` so a minute-long module restart runs in fake time.
|
|
372
|
+
*/
|
|
373
|
+
now?: () => number;
|
|
374
|
+
/**
|
|
375
|
+
* Injectable random source in [0, 1) for route.open retry jitter, so tests can
|
|
376
|
+
* seed it. Defaults to Math.random.
|
|
377
|
+
*/
|
|
378
|
+
random?: () => number;
|
|
357
379
|
/**
|
|
358
380
|
* How long managed calls keep retrying route.open in-place on retryable
|
|
359
381
|
* refusals (module_reloading, module_warming, target_unavailable, …) before
|
|
360
|
-
* settling not_sent. This deadline
|
|
361
|
-
*
|
|
362
|
-
*
|
|
382
|
+
* settling not_sent. This deadline and the call's own timeoutMs (when set) are
|
|
383
|
+
* the only binders on those retries: a module restart legitimately keeps its
|
|
384
|
+
* routes refused for about a minute (drain, stop, boot), so the default is
|
|
385
|
+
* ROUTE_OPEN_RETRY_DEADLINE_MS.
|
|
363
386
|
*/
|
|
364
387
|
routeOpenRetryDeadlineMs?: number;
|
|
365
388
|
/**
|
|
@@ -461,6 +484,8 @@ interface NormalizedConnectOptions {
|
|
|
461
484
|
targetKind: ManagedRouteKind;
|
|
462
485
|
reconnectBackoff: ReconnectBackoff;
|
|
463
486
|
sleep: (ms: number) => Promise<void>;
|
|
487
|
+
now: () => number;
|
|
488
|
+
random: () => number;
|
|
464
489
|
routeOpenRetryDeadlineMs: number;
|
|
465
490
|
timeoutArbitrationGraceMs: number;
|
|
466
491
|
livenessProbeWindowMs: number;
|
|
@@ -472,6 +497,82 @@ interface OpenedConnection {
|
|
|
472
497
|
conn: ConnectionInfo;
|
|
473
498
|
}
|
|
474
499
|
|
|
500
|
+
/**
|
|
501
|
+
* First-come-first-served limit on the route.open requests one client has
|
|
502
|
+
* outstanding. A slot is held from just before the open is written until its
|
|
503
|
+
* answer, or its failure, settles. The client holds one connection at a time,
|
|
504
|
+
* and replacing it fails every open still in flight on the old one, so one gate
|
|
505
|
+
* per client is a per-connection limit.
|
|
506
|
+
*/
|
|
507
|
+
class RouteOpenGate {
|
|
508
|
+
private inFlight = 0;
|
|
509
|
+
private readonly waiters: Array<(release: () => void) => void> = [];
|
|
510
|
+
|
|
511
|
+
constructor(private readonly limit: number) {}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Resolve with a release function once a slot is free, or with null when
|
|
515
|
+
* `timeoutMs` passes first. Without a timeout the caller waits until a slot
|
|
516
|
+
* frees, which happens as each open in flight settles.
|
|
517
|
+
*/
|
|
518
|
+
acquire(timeoutMs?: number): Promise<(() => void) | null> {
|
|
519
|
+
if (this.inFlight < this.limit && this.waiters.length === 0) {
|
|
520
|
+
this.inFlight += 1;
|
|
521
|
+
return Promise.resolve(this.releaser());
|
|
522
|
+
}
|
|
523
|
+
if (timeoutMs !== undefined && timeoutMs <= 0) return Promise.resolve(null);
|
|
524
|
+
return new Promise((resolve) => {
|
|
525
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
526
|
+
const waiter = (release: () => void): void => {
|
|
527
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
528
|
+
resolve(release);
|
|
529
|
+
};
|
|
530
|
+
this.waiters.push(waiter);
|
|
531
|
+
if (timeoutMs !== undefined) {
|
|
532
|
+
timer = setTimeout(() => {
|
|
533
|
+
const index = this.waiters.indexOf(waiter);
|
|
534
|
+
if (index === -1) return;
|
|
535
|
+
this.waiters.splice(index, 1);
|
|
536
|
+
resolve(null);
|
|
537
|
+
}, timeoutMs);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private releaser(): () => void {
|
|
543
|
+
let released = false;
|
|
544
|
+
return () => {
|
|
545
|
+
if (released) return;
|
|
546
|
+
released = true;
|
|
547
|
+
// Hand the slot straight to the oldest waiter, so a newcomer cannot take
|
|
548
|
+
// it between this release and that waiter resuming.
|
|
549
|
+
const next = this.waiters.shift();
|
|
550
|
+
if (next) next(this.releaser());
|
|
551
|
+
else this.inFlight -= 1;
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The route.open refusals one retry loop has seen: the most recent one, and the
|
|
558
|
+
* one that best explains why the target was unreachable. When the deadline runs
|
|
559
|
+
* out, the error describes the best one. After a module restart the most recent
|
|
560
|
+
* refusal is often admission pressure ("8 binds in flight"), which points a
|
|
561
|
+
* reader at the client's concurrency when the module was simply down.
|
|
562
|
+
*/
|
|
563
|
+
class RouteOpenRefusals {
|
|
564
|
+
best: SubcError | undefined;
|
|
565
|
+
last: SubcError | undefined;
|
|
566
|
+
|
|
567
|
+
record(refusal: SubcError): void {
|
|
568
|
+
this.last = refusal;
|
|
569
|
+
// At equal rank the newer refusal wins: it describes the target's latest state.
|
|
570
|
+
if (!this.best || routeOpenRefusalRank(refusal) >= routeOpenRefusalRank(this.best)) {
|
|
571
|
+
this.best = refusal;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
475
576
|
interface CachedRoute {
|
|
476
577
|
key: string;
|
|
477
578
|
moduleId: string;
|
|
@@ -504,6 +605,7 @@ export class SubcClient {
|
|
|
504
605
|
private closeStarted = false;
|
|
505
606
|
private reconnecting: Promise<void> | null = null;
|
|
506
607
|
private generation = 1;
|
|
608
|
+
private routeOpenGateInstance: RouteOpenGate | undefined;
|
|
507
609
|
// True while the read loop is actively reading/dispatching a frame off the
|
|
508
610
|
// current socket (between reading a header and finishing its dispatch). The
|
|
509
611
|
// timeout arbitration reads it to decide whether a just-fired timeout should
|
|
@@ -522,6 +624,12 @@ export class SubcClient {
|
|
|
522
624
|
return this.currentConn;
|
|
523
625
|
}
|
|
524
626
|
|
|
627
|
+
// Created on first use, so a client assembled without running its constructor
|
|
628
|
+
// (as the unit tests do) still paces its route.opens.
|
|
629
|
+
private get routeOpenGate(): RouteOpenGate {
|
|
630
|
+
return (this.routeOpenGateInstance ??= new RouteOpenGate(MAX_ROUTE_OPENS_IN_FLIGHT));
|
|
631
|
+
}
|
|
632
|
+
|
|
525
633
|
/** Read the connection file, connect, authenticate, and start the read loop. */
|
|
526
634
|
static async connect(opts: ConnectOptions): Promise<SubcClient> {
|
|
527
635
|
const normalized = normalizeConnectOptions(opts);
|
|
@@ -574,8 +682,27 @@ export class SubcClient {
|
|
|
574
682
|
.sort();
|
|
575
683
|
}
|
|
576
684
|
|
|
577
|
-
/**
|
|
685
|
+
/**
|
|
686
|
+
* Open a route and return its connection-bound immutable handle. At most
|
|
687
|
+
* MAX_ROUTE_OPENS_IN_FLIGHT opens are outstanding at once; further calls wait
|
|
688
|
+
* their turn.
|
|
689
|
+
*/
|
|
578
690
|
async routeOpen(target: RouteTarget, identity: BindIdentity, opts: RouteOpenOptions = {}): Promise<RouteHandle> {
|
|
691
|
+
// Without a timeout the gate always grants a slot eventually.
|
|
692
|
+
const release = (await this.routeOpenGate.acquire())!;
|
|
693
|
+
try {
|
|
694
|
+
return await this.sendRouteOpen(target, identity, opts);
|
|
695
|
+
} finally {
|
|
696
|
+
release();
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** One route.open exchange. The caller holds a route-open gate slot. */
|
|
701
|
+
private async sendRouteOpen(
|
|
702
|
+
target: RouteTarget,
|
|
703
|
+
identity: BindIdentity,
|
|
704
|
+
opts: RouteOpenOptions = {},
|
|
705
|
+
): Promise<RouteHandle> {
|
|
579
706
|
const consumerIdentity = routeOpenConsumerIdentity(opts);
|
|
580
707
|
const reverseRequests = opts.reverseRequests ?? new ReverseRequestRegistry();
|
|
581
708
|
reverseRequests.seal();
|
|
@@ -692,7 +819,10 @@ export class SubcClient {
|
|
|
692
819
|
// request was in flight. The daemon's contract for the code is
|
|
693
820
|
// NOT-FORWARDED — dropped before delivery — so the retry is safe by
|
|
694
821
|
// construction, and the remedy is identical: evict, re-open, resend once.
|
|
695
|
-
const deadBindCode =
|
|
822
|
+
const deadBindCode = isEstablishedRouteDead(
|
|
823
|
+
err.cause instanceof SubcError ? (errorFrameFlags.get(err.cause) ?? 0) : 0,
|
|
824
|
+
err.code,
|
|
825
|
+
);
|
|
696
826
|
if (deadBindCode && !retriedUnknownChannel && !this.closeStarted) {
|
|
697
827
|
retriedUnknownChannel = true;
|
|
698
828
|
this.evictRouteHandle(routeHandle);
|
|
@@ -1203,18 +1333,48 @@ export class SubcClient {
|
|
|
1203
1333
|
this.routes.set(key, cached);
|
|
1204
1334
|
}
|
|
1205
1335
|
if (cached.handle && this.isLiveHandle(cached.handle)) return cached.handle;
|
|
1336
|
+
// The retries end at the retry deadline or at the call's own timeoutMs,
|
|
1337
|
+
// whichever comes first. Without timeoutMs the call has no overall deadline
|
|
1338
|
+
// here: the default request timeout bounds only the response wait.
|
|
1339
|
+
const startedAt = this.opts.now();
|
|
1340
|
+
const callDeadline = opts.timeoutMs === undefined ? undefined : startedAt + opts.timeoutMs;
|
|
1206
1341
|
if (!cached.opening) {
|
|
1207
|
-
|
|
1342
|
+
const retryDeadline = startedAt + this.opts.routeOpenRetryDeadlineMs;
|
|
1343
|
+
const deadline = callDeadline === undefined ? retryDeadline : Math.min(retryDeadline, callDeadline);
|
|
1344
|
+
cached.opening = this.openCachedRoute(cached, deadline).finally(() => {
|
|
1208
1345
|
cached.opening = null;
|
|
1209
1346
|
});
|
|
1347
|
+
return cached.opening;
|
|
1210
1348
|
}
|
|
1211
|
-
|
|
1349
|
+
// Another call is already opening this route, on its own deadline. Share its
|
|
1350
|
+
// result, but never wait past this call's own timeout for it.
|
|
1351
|
+
if (callDeadline === undefined) return cached.opening;
|
|
1352
|
+
return this.awaitOpeningUntil(cached.opening, callDeadline);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
private awaitOpeningUntil(opening: Promise<RouteHandle>, deadline: number): Promise<RouteHandle> {
|
|
1356
|
+
return new Promise<RouteHandle>((resolve, reject) => {
|
|
1357
|
+
const timer = setTimeout(
|
|
1358
|
+
() => reject(this.notSentCallError("call deadline elapsed waiting for another call's route.open")),
|
|
1359
|
+
Math.max(0, deadline - this.opts.now()),
|
|
1360
|
+
);
|
|
1361
|
+
opening.then(
|
|
1362
|
+
(handle) => {
|
|
1363
|
+
clearTimeout(timer);
|
|
1364
|
+
resolve(handle);
|
|
1365
|
+
},
|
|
1366
|
+
(error: unknown) => {
|
|
1367
|
+
clearTimeout(timer);
|
|
1368
|
+
reject(error);
|
|
1369
|
+
},
|
|
1370
|
+
);
|
|
1371
|
+
});
|
|
1212
1372
|
}
|
|
1213
1373
|
|
|
1214
|
-
private async openCachedRoute(cached: CachedRoute): Promise<RouteHandle> {
|
|
1215
|
-
const routeRetryDeadline = Date.now() + this.opts.routeOpenRetryDeadlineMs;
|
|
1374
|
+
private async openCachedRoute(cached: CachedRoute, deadline: number): Promise<RouteHandle> {
|
|
1216
1375
|
let routeRetryDelay = this.opts.reconnectBackoff.baseMs;
|
|
1217
1376
|
let routeRetryAttempt = 0;
|
|
1377
|
+
const refusals = new RouteOpenRefusals();
|
|
1218
1378
|
for (;;) {
|
|
1219
1379
|
if (cached.closed) throw this.routeClosedDuringOpen();
|
|
1220
1380
|
try {
|
|
@@ -1224,11 +1384,25 @@ export class SubcClient {
|
|
|
1224
1384
|
}
|
|
1225
1385
|
if (cached.handle && this.isLiveHandle(cached.handle)) return cached.handle;
|
|
1226
1386
|
|
|
1387
|
+
// Waiting for a free slot spends the same deadline as retrying refusals.
|
|
1388
|
+
const release = await this.routeOpenGate.acquire(Math.max(0, deadline - this.opts.now()));
|
|
1389
|
+
if (!release) throw this.routeOpenRetryExhausted(cached.moduleId, routeRetryAttempt, refusals);
|
|
1390
|
+
let outcome: { handle: RouteHandle } | { error: unknown };
|
|
1227
1391
|
try {
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1392
|
+
outcome = {
|
|
1393
|
+
handle: await this.sendRouteOpen(cached.target, cached.identity, {
|
|
1394
|
+
consumerIdentity: cached.consumerIdentity ?? null,
|
|
1395
|
+
reverseRequests: cached.reverseRequests,
|
|
1396
|
+
}),
|
|
1397
|
+
};
|
|
1398
|
+
} catch (error) {
|
|
1399
|
+
outcome = { error };
|
|
1400
|
+
} finally {
|
|
1401
|
+
release();
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
if ("handle" in outcome) {
|
|
1405
|
+
const { handle } = outcome;
|
|
1232
1406
|
if (cached.closed) {
|
|
1233
1407
|
this.liveRoutes.delete(handle.channel);
|
|
1234
1408
|
this.sendRouteGoodbye(handle);
|
|
@@ -1236,40 +1410,65 @@ export class SubcClient {
|
|
|
1236
1410
|
}
|
|
1237
1411
|
cached.handle = handle;
|
|
1238
1412
|
return handle;
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const { error } = outcome;
|
|
1416
|
+
if (error instanceof SubcCallError && error.code === "route_closed") throw error;
|
|
1417
|
+
if (!this.closeStarted && isConsumerReconnectTransient(error)) {
|
|
1418
|
+
try {
|
|
1419
|
+
await this.reconnectAfterDrop(error);
|
|
1420
|
+
} catch (reconnectError) {
|
|
1421
|
+
throw this.notSentRecoveryError("route.open was not sent and reconnect failed", reconnectError);
|
|
1248
1422
|
}
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
);
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
if (!this.closeStarted && error instanceof SubcError && isRetryableRouteOpenCode(error.code)) {
|
|
1426
|
+
// The deadline is the ONLY binder here. An attempt cap used to share
|
|
1427
|
+
// this condition, and because the capped backoff sums to ~3.1s it
|
|
1428
|
+
// strictly dominated the deadline — the advertised reload patience was
|
|
1429
|
+
// never delivered, so every module restart whose reload exceeded ~3s
|
|
1430
|
+
// failed managed callers with module_reloading. A restart legitimately
|
|
1431
|
+
// keeps routes refused for about a minute; the backoff cap below bounds
|
|
1432
|
+
// retry pressure instead.
|
|
1433
|
+
routeRetryAttempt += 1;
|
|
1434
|
+
refusals.record(error);
|
|
1435
|
+
const remaining = deadline - this.opts.now();
|
|
1436
|
+
if (remaining > 0) {
|
|
1437
|
+
// Every route on a connection is closed together when its module
|
|
1438
|
+
// drains, so without jitter they all retry in lock step and arrive at
|
|
1439
|
+
// the daemon as one burst each round.
|
|
1440
|
+
await this.opts.sleep(Math.min(equalJitter(routeRetryDelay, this.opts.random), remaining));
|
|
1441
|
+
routeRetryDelay = Math.min(routeRetryDelay * 2, this.opts.reconnectBackoff.capMs);
|
|
1442
|
+
continue;
|
|
1267
1443
|
}
|
|
1268
|
-
throw this.
|
|
1444
|
+
throw this.routeOpenRetryExhausted(cached.moduleId, routeRetryAttempt, refusals);
|
|
1269
1445
|
}
|
|
1446
|
+
throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, error);
|
|
1270
1447
|
}
|
|
1271
1448
|
}
|
|
1272
1449
|
|
|
1450
|
+
/**
|
|
1451
|
+
* The not_sent error for a route.open whose retry deadline ran out. Its code
|
|
1452
|
+
* and cause are the most recent refusal, as they always were; its message
|
|
1453
|
+
* describes the most informative refusal seen, so a module that was reloading
|
|
1454
|
+
* is not reported as admission pressure on the client's own connection.
|
|
1455
|
+
*/
|
|
1456
|
+
private routeOpenRetryExhausted(moduleId: string, attempts: number, refusals: RouteOpenRefusals): SubcCallError {
|
|
1457
|
+
const { best, last } = refusals;
|
|
1458
|
+
if (!best || !last) {
|
|
1459
|
+
return this.notSentCallError(
|
|
1460
|
+
`route.open for module ${moduleId} waited until its deadline for one of ${MAX_ROUTE_OPENS_IN_FLIGHT} route.open slots on this connection`,
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
const recent = last === best ? "" : `; the most recent refusal was ${describeRouteOpenRefusal(last)}`;
|
|
1464
|
+
return new SubcCallError(
|
|
1465
|
+
"not_sent",
|
|
1466
|
+
`route.open failed for module ${moduleId}: ${describeRouteOpenRefusal(best)} (retry deadline exhausted after ${attempts} attempts${recent})`,
|
|
1467
|
+
last.code,
|
|
1468
|
+
last,
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1273
1472
|
// Stamped by dispatch() on every inbound frame; read by the liveness probe.
|
|
1274
1473
|
private lastInboundAtMs = 0;
|
|
1275
1474
|
// Single-flight guard: overlapping deadline-no-drop settles share one probe.
|
|
@@ -1776,7 +1975,9 @@ export class SubcClient {
|
|
|
1776
1975
|
message?: string;
|
|
1777
1976
|
detail?: unknown;
|
|
1778
1977
|
};
|
|
1779
|
-
|
|
1978
|
+
const error = new SubcError(parsed.message ?? "subc error", parsed.code, parsed.detail);
|
|
1979
|
+
errorFrameFlags.set(error, frame.header.flags);
|
|
1980
|
+
return error;
|
|
1780
1981
|
} catch {
|
|
1781
1982
|
return new SubcError(Buffer.from(frame.body).toString("utf8") || "subc error");
|
|
1782
1983
|
}
|
|
@@ -1940,6 +2141,71 @@ export function isRetryableRouteOpenCode(code: string | undefined): boolean {
|
|
|
1940
2141
|
);
|
|
1941
2142
|
}
|
|
1942
2143
|
|
|
2144
|
+
/**
|
|
2145
|
+
* "Equal jitter": half the delay is kept and the other half is random, so
|
|
2146
|
+
* retries spread out while still backing off. `random` returns a value in [0, 1).
|
|
2147
|
+
*/
|
|
2148
|
+
function equalJitter(delayMs: number, random: () => number): number {
|
|
2149
|
+
const half = delayMs / 2;
|
|
2150
|
+
return half + random() * half;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
// The daemon's reasons for refusing a route.open because too many binds are
|
|
2154
|
+
// already in flight: on the client's connection, or towards the target module
|
|
2155
|
+
// (crates/subc-daemon/src/control.rs, route_open_capacity_refusal and
|
|
2156
|
+
// route_open_target_capacity_refusal). Both are sent as target_unavailable.
|
|
2157
|
+
const ROUTE_OPEN_ADMISSION_PRESSURE_REASONS = new Set(["open_admission_full", "target_binds_full"]);
|
|
2158
|
+
// Matches the messages of those same admission-pressure refusals. The daemon
|
|
2159
|
+
// currently logs the reason but leaves it out of the refusal's detail, so the
|
|
2160
|
+
// message is the only way to recognise them.
|
|
2161
|
+
const ROUTE_OPEN_ADMISSION_PRESSURE_MESSAGE = /route\.open binds in flight|route\.bind relays in flight/;
|
|
2162
|
+
|
|
2163
|
+
function refusalReason(refusal: SubcError): string | undefined {
|
|
2164
|
+
const detail = refusal.detail;
|
|
2165
|
+
if (typeof detail !== "object" || detail === null || !("reason" in detail)) return undefined;
|
|
2166
|
+
const reason = (detail as { reason?: unknown }).reason;
|
|
2167
|
+
return typeof reason === "string" ? reason : undefined;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* How much a route.open refusal says about why the target was unreachable,
|
|
2172
|
+
* highest first: the module was changing state (reloading or warming); a
|
|
2173
|
+
* delegation was not registered; the daemon was refusing for admission pressure
|
|
2174
|
+
* (too many binds in flight); anything else.
|
|
2175
|
+
*/
|
|
2176
|
+
function routeOpenRefusalRank(refusal: SubcError): number {
|
|
2177
|
+
if (refusal.code === "module_reloading" || refusal.code === "module_warming") return 3;
|
|
2178
|
+
const reason = refusalReason(refusal);
|
|
2179
|
+
if (refusal.code === "delegation_not_registered" || reason === "delegation_not_registered") return 2;
|
|
2180
|
+
if (refusal.code === "target_unavailable") {
|
|
2181
|
+
const admissionPressure =
|
|
2182
|
+
reason === undefined
|
|
2183
|
+
? ROUTE_OPEN_ADMISSION_PRESSURE_MESSAGE.test(refusal.message)
|
|
2184
|
+
: ROUTE_OPEN_ADMISSION_PRESSURE_REASONS.has(reason);
|
|
2185
|
+
if (admissionPressure) return 1;
|
|
2186
|
+
}
|
|
2187
|
+
return 0;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
function describeRouteOpenRefusal(refusal: SubcError): string {
|
|
2191
|
+
const reason = refusalReason(refusal);
|
|
2192
|
+
return `${refusal.code ?? "refused"}${reason === undefined ? "" : ` (reason ${reason})`}: ${refusal.message}`;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
export const UNKNOWN_CHANNEL = "unknown_channel";
|
|
2196
|
+
export const STALE_ROUTE_EPOCH = "stale_route_epoch";
|
|
2197
|
+
|
|
2198
|
+
/** Whether to evict this route, reopen it, and resend once. A consumer deriving
|
|
2199
|
+
* custody, provider, or suspect verdicts from route death owns its own code list.
|
|
2200
|
+
* Kept byte-identical to subc-protocol error_codes::is_established_route_dead.
|
|
2201
|
+
* The origin bit is recorded but not enforced until every daemon consumers can
|
|
2202
|
+
* meet sets it on these frames (landed and deployed); requiring it sooner would
|
|
2203
|
+
* stop a new SDK from evicting genuine stale routes on older daemons.
|
|
2204
|
+
*/
|
|
2205
|
+
export function isEstablishedRouteDead(_flags: number, code: string | undefined): boolean {
|
|
2206
|
+
return code === UNKNOWN_CHANNEL || code === STALE_ROUTE_EPOCH;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
1943
2209
|
export async function connectionFileExists(path: string): Promise<boolean> {
|
|
1944
2210
|
try {
|
|
1945
2211
|
await fs.access(path);
|
|
@@ -1957,6 +2223,8 @@ function normalizeConnectOptions(opts: ConnectOptions): NormalizedConnectOptions
|
|
|
1957
2223
|
targetKind: opts.targetKind ?? DEFAULT_MANAGED_TARGET_KIND,
|
|
1958
2224
|
reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
|
|
1959
2225
|
sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
2226
|
+
now: opts.now ?? Date.now,
|
|
2227
|
+
random: opts.random ?? Math.random,
|
|
1960
2228
|
routeOpenRetryDeadlineMs: opts.routeOpenRetryDeadlineMs ?? ROUTE_OPEN_RETRY_DEADLINE_MS,
|
|
1961
2229
|
timeoutArbitrationGraceMs: opts.timeoutArbitrationGraceMs ?? TIMEOUT_ARBITRATION_GRACE_MS,
|
|
1962
2230
|
livenessProbeWindowMs: opts.livenessProbeWindowMs ?? LIVENESS_PROBE_WINDOW_MS,
|
package/src/index.ts
CHANGED
package/src/provider.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Buffer } from "node:buffer";
|
|
|
3
3
|
import { AuthError, authenticateClient } from "./auth.js";
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_RECONNECT_BACKOFF,
|
|
6
|
+
isEstablishedRouteDead,
|
|
6
7
|
SUBC_LAUNCH_NONCE_ENV,
|
|
7
8
|
SUBC_MODULE_ID_ENV,
|
|
8
9
|
type BindIdentity,
|
|
@@ -1325,7 +1326,7 @@ function providerErrorFromFrame(frame: Frame): SubcProviderError {
|
|
|
1325
1326
|
return new SubcProviderError(
|
|
1326
1327
|
body.message ?? "subc error",
|
|
1327
1328
|
body.code,
|
|
1328
|
-
|
|
1329
|
+
isEstablishedRouteDead(frame.header.flags, body.code) ? "not_sent" : "terminal",
|
|
1329
1330
|
body.detail,
|
|
1330
1331
|
);
|
|
1331
1332
|
} catch {
|