@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/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 briefly unavailable at daemon
79
- // restart recovers without a misleading terminal error.
80
- // Sized against the daemon's route.bind relay timeout (12s default): a single
81
- // load-stalled bind relay consumes ~12s of this budget before the daemon even
82
- // rejects with module_timeout, so the deadline must leave room for MULTIPLE
83
- // full relay waits or one slow bind exhausts the whole retry clock. 30s allows
84
- // ~2 full relay timeouts plus backoff before surfacing not_sent.
85
- export const ROUTE_OPEN_RETRY_DEADLINE_MS = 30_000;
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 is the ONLY binder on those retries —
361
- * module reloads legitimately take tens of seconds (the daemon's drain alone
362
- * defaults to 30s), so the default matches the daemon's drain ceiling.
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
- /** Open a route and return its connection-bound immutable handle. */
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 = err.code === "unknown_channel" || err.code === "stale_route_epoch";
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
- cached.opening = this.openCachedRoute(cached).finally(() => {
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
- return cached.opening;
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
- const handle = await this.routeOpen(cached.target, cached.identity, {
1229
- consumerIdentity: cached.consumerIdentity ?? null,
1230
- reverseRequests: cached.reverseRequests,
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
- } catch (error) {
1240
- if (error instanceof SubcCallError && error.code === "route_closed") throw error;
1241
- if (!this.closeStarted && isConsumerReconnectTransient(error)) {
1242
- try {
1243
- await this.reconnectAfterDrop(error);
1244
- } catch (reconnectError) {
1245
- throw this.notSentRecoveryError("route.open was not sent and reconnect failed", reconnectError);
1246
- }
1247
- continue;
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
- if (!this.closeStarted && error instanceof SubcError && isRetryableRouteOpenCode(error.code)) {
1250
- // The deadline is the ONLY binder here. An attempt cap used to share
1251
- // this condition, and because the capped backoff sums to ~3.1s it
1252
- // strictly dominated the 30s deadline — the advertised reload
1253
- // patience was never delivered, so every module restart whose reload
1254
- // exceeded ~3s failed managed callers with module_reloading. Module
1255
- // reloads legitimately take tens of seconds (drain alone defaults to
1256
- // 30s); the backoff cap below bounds retry pressure instead.
1257
- routeRetryAttempt += 1;
1258
- if (Date.now() < routeRetryDeadline) {
1259
- await this.opts.sleep(routeRetryDelay);
1260
- routeRetryDelay = Math.min(routeRetryDelay * 2, this.opts.reconnectBackoff.capMs);
1261
- continue;
1262
- }
1263
- throw this.notSentCallError(
1264
- `route.open failed for module ${cached.moduleId}: ${error.code} (retry deadline exhausted after ${routeRetryAttempt} attempts)`,
1265
- error,
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.terminalCallError(`route.open failed for module ${cached.moduleId}`, error);
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
- return new SubcError(parsed.message ?? "subc error", parsed.code, parsed.detail);
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
@@ -2,6 +2,10 @@ export {
2
2
  SubcClient,
3
3
  SubcError,
4
4
  SubcCallError,
5
+ UNKNOWN_CHANNEL,
6
+ STALE_ROUTE_EPOCH,
7
+ isEstablishedRouteDead,
8
+ isRetryableRouteOpenCode,
5
9
  DEFAULT_RECONNECT_BACKOFF,
6
10
  SUBC_MODULE_ID_ENV,
7
11
  SUBC_LAUNCH_NONCE_ENV,
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
- body.code === "stale_route_epoch" || body.code === "unknown_channel" ? "not_sent" : "terminal",
1329
+ isEstablishedRouteDead(frame.header.flags, body.code) ? "not_sent" : "terminal",
1329
1330
  body.detail,
1330
1331
  );
1331
1332
  } catch {