@cortexkit/subc-client 0.8.0 → 0.11.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 +134 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +197 -21
- package/dist/client.js.map +1 -1
- package/dist/envelope.d.ts +4 -0
- package/dist/envelope.d.ts.map +1 -1
- package/dist/envelope.js +9 -1
- package/dist/envelope.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/provider.d.ts +5 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +6 -2
- package/dist/provider.js.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +293 -19
- package/src/envelope.ts +11 -1
- package/src/index.ts +12 -0
- package/src/provider.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/subc-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "TypeScript client for the subc daemon. Wire-compatible (byte-for-byte) with the Rust subc-transport handshake and subc-protocol envelope.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -30,4 +30,4 @@
|
|
|
30
30
|
"bun-types": "^1.3.14",
|
|
31
31
|
"typescript": "^5.6.0"
|
|
32
32
|
}
|
|
33
|
-
}
|
|
33
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
buildFlags,
|
|
21
21
|
encodeFrame,
|
|
22
22
|
FrameType,
|
|
23
|
+
hasBinary,
|
|
23
24
|
Priority,
|
|
24
25
|
type Frame,
|
|
25
26
|
} from "./envelope.js";
|
|
@@ -112,16 +113,59 @@ export interface RouteOpenOptions {
|
|
|
112
113
|
consumerCapabilities?: string[];
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
export interface CatalogCapabilityRequirement {
|
|
117
|
+
capability: string;
|
|
118
|
+
need: "required" | "optional";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface CatalogCapabilities {
|
|
122
|
+
provides: string[];
|
|
123
|
+
requires: CatalogCapabilityRequirement[];
|
|
124
|
+
must_never_reach: string[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A self-signal's anchor relative to the external surface it shapes. */
|
|
128
|
+
export type CatalogSignalAnchor =
|
|
129
|
+
| "fixed_interval"
|
|
130
|
+
| { event: { event: string } };
|
|
131
|
+
|
|
132
|
+
/** The source of a self-signal's effective cadence. */
|
|
133
|
+
export type CatalogSignalCadence =
|
|
134
|
+
| { literal: { interval_ms: number } }
|
|
135
|
+
| { derived: { source: string } };
|
|
136
|
+
|
|
137
|
+
/** A self-signal declaration mirrored from a registered module manifest. */
|
|
138
|
+
export interface CatalogSelfSignalDeclaration {
|
|
139
|
+
name: string;
|
|
140
|
+
/** Open string for forward-compatible self-signal kinds. */
|
|
141
|
+
kind: string;
|
|
142
|
+
effect: "observe" | "mutate";
|
|
143
|
+
anchored_to: CatalogSignalAnchor;
|
|
144
|
+
cadence?: CatalogSignalCadence | null;
|
|
145
|
+
domain?: string | null;
|
|
146
|
+
note?: string | null;
|
|
147
|
+
}
|
|
148
|
+
|
|
115
149
|
export interface CatalogEntry {
|
|
116
150
|
module_id: string;
|
|
117
151
|
roles: unknown[];
|
|
118
152
|
control_ops: string[];
|
|
153
|
+
/** Static capability claims mirrored from the registered module manifest. */
|
|
154
|
+
capabilities?: CatalogCapabilities | null;
|
|
155
|
+
/** Self-signal declarations mirrored verbatim from the registered manifest. */
|
|
156
|
+
self_signals?: CatalogSelfSignalDeclaration[] | null;
|
|
119
157
|
}
|
|
120
158
|
|
|
121
159
|
export interface RequestOptions {
|
|
122
160
|
priority?: Priority;
|
|
123
161
|
admissionClass?: AdmissionClass;
|
|
124
162
|
timeoutMs?: number;
|
|
163
|
+
/**
|
|
164
|
+
* Set when the request body is opaque bytes rather than JSON. This flag is the
|
|
165
|
+
* receiver's only signal about the body representation; reply decoding follows
|
|
166
|
+
* the response frame's flag instead of this request option.
|
|
167
|
+
*/
|
|
168
|
+
binary?: boolean;
|
|
125
169
|
/** Called for each interim PUSH / StreamData frame before the terminal reply. */
|
|
126
170
|
onProgress?: (body: Uint8Array) => void;
|
|
127
171
|
}
|
|
@@ -208,6 +252,16 @@ export class SubcCallError extends Error {
|
|
|
208
252
|
super(message);
|
|
209
253
|
this.name = "SubcCallError";
|
|
210
254
|
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Machine-parsable payload from the wire `ErrorBody.detail`, surfaced from
|
|
258
|
+
* the underlying Error frame when one caused this failure. Typed refusal
|
|
259
|
+
* reasons (for example certification refusals) ride this field; consumers
|
|
260
|
+
* classify on it and must not have to reach into `cause`.
|
|
261
|
+
*/
|
|
262
|
+
get detail(): unknown {
|
|
263
|
+
return this.cause instanceof SubcError ? this.cause.detail : undefined;
|
|
264
|
+
}
|
|
211
265
|
}
|
|
212
266
|
|
|
213
267
|
/**
|
|
@@ -228,11 +282,24 @@ export class SubcError extends Error {
|
|
|
228
282
|
constructor(
|
|
229
283
|
message: string,
|
|
230
284
|
readonly code?: string,
|
|
285
|
+
/**
|
|
286
|
+
* Machine-parsable payload from the wire `ErrorBody.detail` field, carried
|
|
287
|
+
* verbatim. Providers use it for typed refusal reasons (for example
|
|
288
|
+
* synapse's certification-refusal reasons); dropping it here would strand
|
|
289
|
+
* those reasons at the transport boundary.
|
|
290
|
+
*/
|
|
291
|
+
readonly detail?: unknown,
|
|
231
292
|
) {
|
|
232
293
|
super(message);
|
|
233
294
|
}
|
|
234
295
|
}
|
|
235
296
|
|
|
297
|
+
function requireBinaryBody(body: unknown): Uint8Array {
|
|
298
|
+
if (body instanceof Uint8Array) return body;
|
|
299
|
+
const type = body === null ? "null" : Array.isArray(body) ? "array" : typeof body;
|
|
300
|
+
throw new SubcError(`binary request body must be a Uint8Array; got ${type}`, "binary_body_required");
|
|
301
|
+
}
|
|
302
|
+
|
|
236
303
|
interface Pending {
|
|
237
304
|
handle: RouteHandle | null;
|
|
238
305
|
resolve: (frame: Frame) => void;
|
|
@@ -261,6 +328,14 @@ export interface ConnectOptions {
|
|
|
261
328
|
reconnectBackoff?: ReconnectBackoff;
|
|
262
329
|
/** Injectable sleep for timer-free reconnect tests. */
|
|
263
330
|
sleep?: (ms: number) => Promise<void>;
|
|
331
|
+
/**
|
|
332
|
+
* How long managed calls keep retrying route.open in-place on retryable
|
|
333
|
+
* refusals (module_reloading, module_warming, target_unavailable, …) before
|
|
334
|
+
* settling not_sent. This deadline is the ONLY binder on those retries —
|
|
335
|
+
* module reloads legitimately take tens of seconds (the daemon's drain alone
|
|
336
|
+
* defaults to 30s), so the default matches the daemon's drain ceiling.
|
|
337
|
+
*/
|
|
338
|
+
routeOpenRetryDeadlineMs?: number;
|
|
264
339
|
/**
|
|
265
340
|
* Window the post-deadline liveness probe waits for ANY inbound frame after
|
|
266
341
|
* sending its channel-0 Ping before convicting the socket as half-open.
|
|
@@ -300,6 +375,59 @@ export interface ControlPush {
|
|
|
300
375
|
body: Record<string, unknown>;
|
|
301
376
|
}
|
|
302
377
|
|
|
378
|
+
/** A known route-close reason, excluding forward-compatible unknown values. */
|
|
379
|
+
export type KnownRouteCloseReason =
|
|
380
|
+
| "reload"
|
|
381
|
+
| "restart"
|
|
382
|
+
| "disable"
|
|
383
|
+
| "crash"
|
|
384
|
+
| "capability_denied";
|
|
385
|
+
|
|
386
|
+
/** A route-close reason decoded from a daemon control push. */
|
|
387
|
+
export type RouteCloseReason = KnownRouteCloseReason | "unknown";
|
|
388
|
+
|
|
389
|
+
/** Whether the close reason alone permits reopening a route automatically. */
|
|
390
|
+
export type RouteCloseDisposition = "may_reopen" | "must_not_reopen";
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Decode a daemon close reason without rejecting a future wire value.
|
|
394
|
+
*
|
|
395
|
+
* An unknown reason remains observable as `unknown`; callers must use the
|
|
396
|
+
* strict disposition instead of treating an unrecognized policy change as safe.
|
|
397
|
+
*/
|
|
398
|
+
export function parseRouteCloseReason(reason: unknown): RouteCloseReason {
|
|
399
|
+
switch (reason) {
|
|
400
|
+
case "reload":
|
|
401
|
+
case "restart":
|
|
402
|
+
case "disable":
|
|
403
|
+
case "crash":
|
|
404
|
+
case "capability_denied":
|
|
405
|
+
return reason;
|
|
406
|
+
default:
|
|
407
|
+
return "unknown";
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Unknown close reasons take the strictest action and must not trigger a reopen. */
|
|
412
|
+
export function classifyRouteCloseReason(reason: unknown): RouteCloseDisposition {
|
|
413
|
+
switch (parseRouteCloseReason(reason)) {
|
|
414
|
+
case "reload":
|
|
415
|
+
case "restart":
|
|
416
|
+
return "may_reopen";
|
|
417
|
+
case "disable":
|
|
418
|
+
case "crash":
|
|
419
|
+
case "capability_denied":
|
|
420
|
+
case "unknown":
|
|
421
|
+
return "must_not_reopen";
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Decode a close reason from a route lifecycle control push, if present. */
|
|
426
|
+
export function routeCloseReason(push: ControlPush): RouteCloseReason | undefined {
|
|
427
|
+
if (push.op !== "route.closing" && push.op !== "route.closed") return undefined;
|
|
428
|
+
return parseRouteCloseReason(push.body.reason);
|
|
429
|
+
}
|
|
430
|
+
|
|
303
431
|
interface NormalizedConnectOptions {
|
|
304
432
|
connectionFile: string;
|
|
305
433
|
handshakeTimeoutMs?: number;
|
|
@@ -307,6 +435,7 @@ interface NormalizedConnectOptions {
|
|
|
307
435
|
targetKind: ManagedRouteKind;
|
|
308
436
|
reconnectBackoff: ReconnectBackoff;
|
|
309
437
|
sleep: (ms: number) => Promise<void>;
|
|
438
|
+
routeOpenRetryDeadlineMs: number;
|
|
310
439
|
timeoutArbitrationGraceMs: number;
|
|
311
440
|
livenessProbeWindowMs: number;
|
|
312
441
|
onControlPush?: (push: ControlPush) => void;
|
|
@@ -383,7 +512,42 @@ export class SubcClient {
|
|
|
383
512
|
return parsed.modules ?? [];
|
|
384
513
|
}
|
|
385
514
|
|
|
386
|
-
|
|
515
|
+
/**
|
|
516
|
+
* Resolve the sole catalog claimant for a capability.
|
|
517
|
+
*
|
|
518
|
+
* This expresses singular intent even when the fleet permits plural claims;
|
|
519
|
+
* ambiguity is returned instead of selecting an arbitrary module.
|
|
520
|
+
*/
|
|
521
|
+
async resolveProvider(capability: string): Promise<string> {
|
|
522
|
+
const claimants = await this.resolveProviders(capability);
|
|
523
|
+
if (claimants.length === 0) {
|
|
524
|
+
throw new SubcError(`no catalog claimant for capability ${capability}`, "capability_unprovided");
|
|
525
|
+
}
|
|
526
|
+
if (claimants.length > 1) {
|
|
527
|
+
throw new SubcError(
|
|
528
|
+
`multiple catalog claimants for capability ${capability}: ${claimants.join(", ")}`,
|
|
529
|
+
"capability_ambiguous",
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
return claimants[0]!;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Resolve every catalog claimant in deterministic module-id order. */
|
|
536
|
+
async resolveProviders(capability: string): Promise<string[]> {
|
|
537
|
+
if (!isValidCapabilityIdentifier(capability)) {
|
|
538
|
+
throw new SubcError(
|
|
539
|
+
`malformed capability identifier ${JSON.stringify(capability)}`,
|
|
540
|
+
"invalid_capability_identifier",
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
const modules = await this.catalogList();
|
|
544
|
+
return modules
|
|
545
|
+
.filter((module) => module.capabilities?.provides.includes(capability) ?? false)
|
|
546
|
+
.map((module) => module.module_id)
|
|
547
|
+
.sort();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Open a route and return its connection-bound immutable handle. */
|
|
387
551
|
async routeOpen(target: RouteTarget, identity: BindIdentity, opts: RouteOpenOptions = {}): Promise<RouteHandle> {
|
|
388
552
|
const consumerIdentity = routeOpenConsumerIdentity(opts);
|
|
389
553
|
const consumerCapabilities = opts.consumerCapabilities;
|
|
@@ -424,14 +588,25 @@ export class SubcClient {
|
|
|
424
588
|
return installed;
|
|
425
589
|
}
|
|
426
590
|
|
|
427
|
-
|
|
591
|
+
/** Send a data-plane request on exactly the supplied route generation. */
|
|
428
592
|
async request(handle: RouteHandle, body: unknown, opts: RequestOptions = {}): Promise<unknown> {
|
|
429
593
|
this.assertLiveHandle(handle);
|
|
430
|
-
const
|
|
594
|
+
const binary = opts.binary ?? false;
|
|
595
|
+
const bytes = binary ? requireBinaryBody(body) : body instanceof Uint8Array ? body : this.encode(body);
|
|
431
596
|
const priority = opts.priority ?? Priority.Interactive;
|
|
432
597
|
const admission = opts.admissionClass ?? AdmissionClass.Normal;
|
|
433
|
-
const reply = await this.send(
|
|
434
|
-
|
|
598
|
+
const reply = await this.send(
|
|
599
|
+
handle,
|
|
600
|
+
bytes,
|
|
601
|
+
priority,
|
|
602
|
+
admission,
|
|
603
|
+
opts.timeoutMs,
|
|
604
|
+
opts.onProgress,
|
|
605
|
+
undefined,
|
|
606
|
+
undefined,
|
|
607
|
+
binary,
|
|
608
|
+
);
|
|
609
|
+
return this.decodeReply(reply);
|
|
435
610
|
}
|
|
436
611
|
|
|
437
612
|
/**
|
|
@@ -444,13 +619,37 @@ export class SubcClient {
|
|
|
444
619
|
params?: unknown,
|
|
445
620
|
opts: ManagedCallOptions = {},
|
|
446
621
|
): Promise<Response> {
|
|
622
|
+
if (opts.binary) {
|
|
623
|
+
throw new SubcError(
|
|
624
|
+
"call() builds a JSON body and cannot send a binary request; use callBinary(moduleId, body, opts) with a Uint8Array",
|
|
625
|
+
"binary_call_requires_call_binary",
|
|
626
|
+
);
|
|
627
|
+
}
|
|
447
628
|
const body = params === undefined ? { method } : { method, params };
|
|
629
|
+
return (await this.managedCall(moduleId, body, opts)) as Response;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Managed route + raw opaque-body request convenience. Unlike call(), this
|
|
634
|
+
* sends bytes without a JSON method envelope because the BINARY flag is the
|
|
635
|
+
* receiver's only signal that the body is not JSON. A BINARY reply resolves
|
|
636
|
+
* to Uint8Array; a non-binary reply is decoded as JSON.
|
|
637
|
+
*/
|
|
638
|
+
async callBinary(
|
|
639
|
+
moduleId: string,
|
|
640
|
+
body: Uint8Array,
|
|
641
|
+
opts: ManagedCallOptions = {},
|
|
642
|
+
): Promise<Uint8Array | unknown> {
|
|
643
|
+
const bytes = requireBinaryBody(body);
|
|
644
|
+
return this.managedCall(moduleId, bytes, { ...opts, binary: true });
|
|
645
|
+
}
|
|
448
646
|
|
|
647
|
+
private async managedCall(moduleId: string, body: unknown, opts: ManagedCallOptions): Promise<unknown> {
|
|
449
648
|
let retriedUnknownChannel = false;
|
|
450
649
|
for (;;) {
|
|
451
650
|
const routeHandle = await this.cachedRouteHandle(moduleId, opts);
|
|
452
651
|
try {
|
|
453
|
-
return
|
|
652
|
+
return await this.managedRequest(routeHandle, body, opts);
|
|
454
653
|
} catch (err) {
|
|
455
654
|
if (!(err instanceof SubcCallError)) throw this.terminalCallError("managed call failed", err);
|
|
456
655
|
// unknown_channel is the daemon ROUTER refusing an unrouted channel — the
|
|
@@ -699,6 +898,7 @@ export class SubcClient {
|
|
|
699
898
|
onProgress: ((body: Uint8Array) => void) | undefined,
|
|
700
899
|
acceptFrame?: (frame: Frame) => boolean,
|
|
701
900
|
onLateResponse?: (frame: Frame) => void,
|
|
901
|
+
binary = false,
|
|
702
902
|
): Promise<Frame> {
|
|
703
903
|
if (handle) this.assertLiveHandle(handle);
|
|
704
904
|
if (this.closedErr) return Promise.reject(this.closedErr);
|
|
@@ -713,7 +913,7 @@ export class SubcClient {
|
|
|
713
913
|
const epoch = handle?.epoch ?? 0;
|
|
714
914
|
const frame = buildFrame(
|
|
715
915
|
FrameType.Request,
|
|
716
|
-
buildFlags(
|
|
916
|
+
buildFlags(binary, priority, false, admission),
|
|
717
917
|
channel,
|
|
718
918
|
epoch,
|
|
719
919
|
corr,
|
|
@@ -778,12 +978,21 @@ export class SubcClient {
|
|
|
778
978
|
}
|
|
779
979
|
|
|
780
980
|
private async managedRequest(handle: RouteHandle, body: unknown, opts: ManagedCallOptions): Promise<unknown> {
|
|
781
|
-
const
|
|
981
|
+
const binary = opts.binary ?? false;
|
|
982
|
+
const bytes = binary ? requireBinaryBody(body) : body instanceof Uint8Array ? body : this.encode(body);
|
|
782
983
|
const priority = opts.priority ?? Priority.Interactive;
|
|
783
984
|
const admission = opts.admissionClass ?? AdmissionClass.Normal;
|
|
784
985
|
try {
|
|
785
|
-
const reply = await this.sendManaged(
|
|
786
|
-
|
|
986
|
+
const reply = await this.sendManaged(
|
|
987
|
+
handle,
|
|
988
|
+
bytes,
|
|
989
|
+
priority,
|
|
990
|
+
admission,
|
|
991
|
+
opts.timeoutMs,
|
|
992
|
+
opts.onProgress,
|
|
993
|
+
binary,
|
|
994
|
+
);
|
|
995
|
+
return this.decodeReply(reply);
|
|
787
996
|
} catch (error) {
|
|
788
997
|
if (error instanceof SubcCallError) throw error;
|
|
789
998
|
throw this.terminalCallError("managed call failed", error);
|
|
@@ -797,6 +1006,7 @@ export class SubcClient {
|
|
|
797
1006
|
admission: AdmissionClass,
|
|
798
1007
|
timeoutMs: number | undefined,
|
|
799
1008
|
onProgress: ((body: Uint8Array) => void) | undefined,
|
|
1009
|
+
binary = false,
|
|
800
1010
|
): Promise<Frame> {
|
|
801
1011
|
try {
|
|
802
1012
|
this.assertLiveHandle(handle);
|
|
@@ -818,7 +1028,7 @@ export class SubcClient {
|
|
|
818
1028
|
const key = pendingKey(handle, corr);
|
|
819
1029
|
const frame = buildFrame(
|
|
820
1030
|
FrameType.Request,
|
|
821
|
-
buildFlags(
|
|
1031
|
+
buildFlags(binary, priority, false, admission),
|
|
822
1032
|
handle.channel,
|
|
823
1033
|
handle.epoch,
|
|
824
1034
|
corr,
|
|
@@ -898,7 +1108,7 @@ export class SubcClient {
|
|
|
898
1108
|
}
|
|
899
1109
|
|
|
900
1110
|
private async openCachedRoute(cached: CachedRoute): Promise<RouteHandle> {
|
|
901
|
-
const routeRetryDeadline = Date.now() +
|
|
1111
|
+
const routeRetryDeadline = Date.now() + this.opts.routeOpenRetryDeadlineMs;
|
|
902
1112
|
let routeRetryDelay = this.opts.reconnectBackoff.baseMs;
|
|
903
1113
|
let routeRetryAttempt = 0;
|
|
904
1114
|
for (;;) {
|
|
@@ -932,14 +1142,21 @@ export class SubcClient {
|
|
|
932
1142
|
continue;
|
|
933
1143
|
}
|
|
934
1144
|
if (!this.closeStarted && error instanceof SubcError && isRetryableRouteOpenCode(error.code)) {
|
|
1145
|
+
// The deadline is the ONLY binder here. An attempt cap used to share
|
|
1146
|
+
// this condition, and because the capped backoff sums to ~3.1s it
|
|
1147
|
+
// strictly dominated the 30s deadline — the advertised reload
|
|
1148
|
+
// patience was never delivered, so every module restart whose reload
|
|
1149
|
+
// exceeded ~3s failed managed callers with module_reloading. Module
|
|
1150
|
+
// reloads legitimately take tens of seconds (drain alone defaults to
|
|
1151
|
+
// 30s); the backoff cap below bounds retry pressure instead.
|
|
935
1152
|
routeRetryAttempt += 1;
|
|
936
|
-
if (
|
|
1153
|
+
if (Date.now() < routeRetryDeadline) {
|
|
937
1154
|
await this.opts.sleep(routeRetryDelay);
|
|
938
1155
|
routeRetryDelay = Math.min(routeRetryDelay * 2, this.opts.reconnectBackoff.capMs);
|
|
939
1156
|
continue;
|
|
940
1157
|
}
|
|
941
1158
|
throw this.notSentCallError(
|
|
942
|
-
`route.open failed for module ${cached.moduleId}: ${error.code} (retry
|
|
1159
|
+
`route.open failed for module ${cached.moduleId}: ${error.code} (retry deadline exhausted after ${routeRetryAttempt} attempts)`,
|
|
943
1160
|
error,
|
|
944
1161
|
);
|
|
945
1162
|
}
|
|
@@ -971,13 +1188,35 @@ export class SubcClient {
|
|
|
971
1188
|
* enough to delay the read loop delays this probe's own timer identically,
|
|
972
1189
|
* and when both wake the buffered Pong dispatches before the check runs.
|
|
973
1190
|
*
|
|
1191
|
+
* A PENDING CHANNEL-0 REQUEST SUSPENDS THE PROBE (both at launch and at the
|
|
1192
|
+
* conviction check). The daemon's connection loop handles frames one at a
|
|
1193
|
+
* time, and some channel-0 ops legally park it for seconds — route.open
|
|
1194
|
+
* awaits the module's bind ack inline for up to route_bind_relay_timeout
|
|
1195
|
+
* (~12s in production) — during which OUR Ping sits unread in the daemon's
|
|
1196
|
+
* socket buffer. On an otherwise-quiet connection that silence is fully
|
|
1197
|
+
* explained by our own in-flight control op, and convicting on it would
|
|
1198
|
+
* tear down a healthy connection mid-bind (BROCA's Athena panel flagged
|
|
1199
|
+
* this exact premise; the FIFO fact is verified in subc-core server.rs
|
|
1200
|
+
* connection_loop + control.rs handle_route_open). The client always knows
|
|
1201
|
+
* its own channel-0 pendings, so the gate is local and exact: no probe
|
|
1202
|
+
* while one is in flight, no conviction if one appeared during the window.
|
|
1203
|
+
*
|
|
974
1204
|
* Scope: hooked from the managed-call path only. Control-plane requests
|
|
975
1205
|
* (route.open, catalog.list) are short-lived and their failures already
|
|
976
1206
|
* escalate through reconnect classification; the managed path is where a
|
|
977
1207
|
* plugin lives for hours and where the pin was observed in production.
|
|
978
1208
|
*/
|
|
1209
|
+
/** Any in-flight channel-0 request (pendings keyed without a route handle). */
|
|
1210
|
+
private hasControlPending(): boolean {
|
|
1211
|
+
for (const pending of this.pending.values()) {
|
|
1212
|
+
if (pending.handle === null) return true;
|
|
1213
|
+
}
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
979
1217
|
private probeLivenessAfterDeadline(): void {
|
|
980
1218
|
if (this.livenessProbe || this.closeStarted || this.closedErr) return;
|
|
1219
|
+
if (this.hasControlPending()) return; // silence would be self-explained; re-armed by the next settle
|
|
981
1220
|
const sock = this.sock;
|
|
982
1221
|
const generation = this.generation;
|
|
983
1222
|
let corr: bigint;
|
|
@@ -1003,6 +1242,9 @@ export class SubcClient {
|
|
|
1003
1242
|
await this.opts.sleep(this.opts.livenessProbeWindowMs);
|
|
1004
1243
|
if (this.sock !== sock || this.generation !== generation || this.closeStarted) return;
|
|
1005
1244
|
if (this.lastInboundAtMs >= t0) return; // link proven — keep the socket
|
|
1245
|
+
// A control op that started during the window explains the silence: the
|
|
1246
|
+
// daemon's read loop may be parked in its inline handler, not dead.
|
|
1247
|
+
if (this.hasControlPending()) return;
|
|
1006
1248
|
this.fail(
|
|
1007
1249
|
new SocketClosedError(
|
|
1008
1250
|
`liveness probe convicted a half-open socket: no inbound frame for ${this.opts.livenessProbeWindowMs}ms after a channel-0 Ping (deadline-no-drop settles preceded this); closing so the next call reconnects`,
|
|
@@ -1151,6 +1393,13 @@ export class SubcClient {
|
|
|
1151
1393
|
// post-deadline liveness probe reads this stamp. Stamped after readLoop's
|
|
1152
1394
|
// generation gate, so a stale socket's late frames never vouch for the
|
|
1153
1395
|
// live one.
|
|
1396
|
+
//
|
|
1397
|
+
// PLACEMENT IS LOAD-BEARING: every inbound frame passes this one point
|
|
1398
|
+
// before demux, which is the whole reason a single stamp works. A future
|
|
1399
|
+
// fast path or drain-and-dispatch refactor that routes frames around
|
|
1400
|
+
// dispatch() makes the stamp skippable, and the liveness watermark
|
|
1401
|
+
// quietly stops meaning "the link delivered bytes" -- the cheapest
|
|
1402
|
+
// correctness property here is also the easiest to lose in a refactor.
|
|
1154
1403
|
this.lastInboundAtMs = Date.now();
|
|
1155
1404
|
if (frame.header.channel === 0 && frame.header.ty === FrameType.Push) {
|
|
1156
1405
|
// Daemon-originated control push (route.closing / route.closed / future
|
|
@@ -1295,8 +1544,9 @@ export class SubcClient {
|
|
|
1295
1544
|
const parsed = JSON.parse(Buffer.from(frame.body).toString("utf8")) as {
|
|
1296
1545
|
code?: string;
|
|
1297
1546
|
message?: string;
|
|
1547
|
+
detail?: unknown;
|
|
1298
1548
|
};
|
|
1299
|
-
return new SubcError(parsed.message ?? "subc error", parsed.code);
|
|
1549
|
+
return new SubcError(parsed.message ?? "subc error", parsed.code, parsed.detail);
|
|
1300
1550
|
} catch {
|
|
1301
1551
|
return new SubcError(Buffer.from(frame.body).toString("utf8") || "subc error");
|
|
1302
1552
|
}
|
|
@@ -1388,6 +1638,11 @@ export class SubcClient {
|
|
|
1388
1638
|
return Buffer.from(JSON.stringify(value), "utf8");
|
|
1389
1639
|
}
|
|
1390
1640
|
|
|
1641
|
+
/** Decode a terminal response according to the representation on the wire. */
|
|
1642
|
+
private decodeReply(frame: Frame): unknown {
|
|
1643
|
+
return hasBinary(frame.header.flags) ? frame.body : this.parseJson(frame);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1391
1646
|
private parseJson(frame: Frame): unknown {
|
|
1392
1647
|
const b = frame.body;
|
|
1393
1648
|
return JSON.parse(Buffer.from(b.buffer, b.byteOffset, b.byteLength).toString("utf8"));
|
|
@@ -1420,13 +1675,16 @@ export function isConsumerReconnectTransient(err: unknown): boolean {
|
|
|
1420
1675
|
* is booting, mid-reload, transiently absent, or the bind relay timed out. A
|
|
1421
1676
|
* daemon-rejected route.open is provably pre-send (no data frame ever left the
|
|
1422
1677
|
* client), so these classify as not_sent; the managed path retries them in-place
|
|
1423
|
-
* within ROUTE_OPEN_RETRY_DEADLINE_MS. Permanent rejections (
|
|
1424
|
-
* config_divergence, unknown_target, ...) are excluded — they are pre-send but
|
|
1425
|
-
* would never succeed, so retrying them would only storm the daemon.
|
|
1426
|
-
*
|
|
1678
|
+
* within ROUTE_OPEN_RETRY_DEADLINE_MS. Permanent rejections (module_removed,
|
|
1679
|
+
* bad_consumer_identity, config_divergence, unknown_target, ...) are excluded — they are pre-send but
|
|
1680
|
+
* would never succeed, so retrying them would only storm the daemon. In
|
|
1681
|
+
* particular, capability_forbidden is an explicit non-retryable policy refusal.
|
|
1682
|
+
* Kept byte-identical to subc-client-rs is_retryable_route_open_code for cross-client
|
|
1427
1683
|
* classification parity.
|
|
1428
1684
|
*/
|
|
1429
1685
|
export function isRetryableRouteOpenCode(code: string | undefined): boolean {
|
|
1686
|
+
// A capability deny is policy, never a transient target-availability failure.
|
|
1687
|
+
if (code === "capability_forbidden") return false;
|
|
1430
1688
|
return (
|
|
1431
1689
|
code === "unknown_module" ||
|
|
1432
1690
|
code === "module_reloading" ||
|
|
@@ -1453,12 +1711,28 @@ function normalizeConnectOptions(opts: ConnectOptions): NormalizedConnectOptions
|
|
|
1453
1711
|
targetKind: opts.targetKind ?? DEFAULT_MANAGED_TARGET_KIND,
|
|
1454
1712
|
reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
|
|
1455
1713
|
sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
1714
|
+
routeOpenRetryDeadlineMs: opts.routeOpenRetryDeadlineMs ?? ROUTE_OPEN_RETRY_DEADLINE_MS,
|
|
1456
1715
|
timeoutArbitrationGraceMs: opts.timeoutArbitrationGraceMs ?? TIMEOUT_ARBITRATION_GRACE_MS,
|
|
1457
1716
|
livenessProbeWindowMs: opts.livenessProbeWindowMs ?? LIVENESS_PROBE_WINDOW_MS,
|
|
1458
1717
|
onControlPush: opts.onControlPush,
|
|
1459
1718
|
};
|
|
1460
1719
|
}
|
|
1461
1720
|
|
|
1721
|
+
/** Validate the exact capability-grammar identifier before catalog I/O. */
|
|
1722
|
+
export function isValidCapabilityIdentifier(identifier: string): boolean {
|
|
1723
|
+
if (/\s/u.test(identifier)) return false;
|
|
1724
|
+
const separator = identifier.indexOf("/v");
|
|
1725
|
+
if (separator <= 0 || identifier.indexOf("/v", separator + 1) !== -1) return false;
|
|
1726
|
+
const name = identifier.slice(0, separator);
|
|
1727
|
+
const version = identifier.slice(separator + 2);
|
|
1728
|
+
if (new TextEncoder().encode(name).byteLength > 64 || version.length === 0) return false;
|
|
1729
|
+
if (!/^[a-z][a-z0-9-]*[a-z0-9]$/u.test(name) && !/^[a-z]$/u.test(name)) return false;
|
|
1730
|
+
if (name.includes("--")) return false;
|
|
1731
|
+
if (!/^\d+$/u.test(version) || (version.length > 1 && version.startsWith("0"))) return false;
|
|
1732
|
+
const numericVersion = Number(version);
|
|
1733
|
+
return Number.isSafeInteger(numericVersion) && numericVersion >= 1 && numericVersion <= 0xffff_ffff;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1462
1736
|
function routeCacheKey(
|
|
1463
1737
|
target: Extract<RouteTarget, { kind: ManagedRouteKind }>,
|
|
1464
1738
|
identity: BindIdentity,
|
package/src/envelope.ts
CHANGED
|
@@ -50,7 +50,8 @@ const FLAG_PRIORITY_SHIFT = 1;
|
|
|
50
50
|
const FLAG_LAST = 0b0000_1000;
|
|
51
51
|
const FLAG_ADMISSION_MASK = 0b0011_0000;
|
|
52
52
|
const FLAG_ADMISSION_SHIFT = 4;
|
|
53
|
-
const
|
|
53
|
+
export const DAEMON_ORIGIN_FLAG = 0x40;
|
|
54
|
+
const FLAG_RESERVED_MASK = 0b1000_0000;
|
|
54
55
|
|
|
55
56
|
/** Build flags from typed components. Admission defaults to NORMAL. */
|
|
56
57
|
export function buildFlags(
|
|
@@ -71,6 +72,15 @@ export function admissionClass(flags: number): AdmissionClass {
|
|
|
71
72
|
return ((flags & FLAG_ADMISSION_MASK) >> FLAG_ADMISSION_SHIFT) as AdmissionClass;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/** True when the body is opaque bytes rather than a JSON payload. */
|
|
76
|
+
export function hasBinary(flags: number): boolean {
|
|
77
|
+
return (flags & FLAG_BINARY) !== 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function hasDaemonOrigin(flags: number): boolean {
|
|
81
|
+
return (flags & DAEMON_ORIGIN_FLAG) !== 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
export interface EnvelopeHeader {
|
|
75
85
|
len: number;
|
|
76
86
|
ver: number;
|
package/src/index.ts
CHANGED
|
@@ -6,17 +6,26 @@ export {
|
|
|
6
6
|
SUBC_MODULE_ID_ENV,
|
|
7
7
|
SUBC_LAUNCH_NONCE_ENV,
|
|
8
8
|
isConsumerReconnectTransient,
|
|
9
|
+
parseRouteCloseReason,
|
|
10
|
+
classifyRouteCloseReason,
|
|
11
|
+
routeCloseReason,
|
|
12
|
+
isValidCapabilityIdentifier,
|
|
9
13
|
connectionFileExists,
|
|
10
14
|
type BindIdentity,
|
|
11
15
|
type RouteTarget,
|
|
12
16
|
type ManagedRouteKind,
|
|
13
17
|
type ConsumerIdentity,
|
|
14
18
|
type RouteOpenOptions,
|
|
19
|
+
type CatalogCapabilities,
|
|
20
|
+
type CatalogCapabilityRequirement,
|
|
15
21
|
type CatalogEntry,
|
|
16
22
|
type RequestOptions,
|
|
17
23
|
type ManagedCallOptions,
|
|
18
24
|
type ConnectOptions,
|
|
19
25
|
type ControlPush,
|
|
26
|
+
type KnownRouteCloseReason,
|
|
27
|
+
type RouteCloseReason,
|
|
28
|
+
type RouteCloseDisposition,
|
|
20
29
|
type ReconnectBackoff,
|
|
21
30
|
type SubcCallErrorKind,
|
|
22
31
|
type SubscribeOptions,
|
|
@@ -37,6 +46,7 @@ export {
|
|
|
37
46
|
FrameType,
|
|
38
47
|
Priority,
|
|
39
48
|
AdmissionClass,
|
|
49
|
+
DAEMON_ORIGIN_FLAG,
|
|
40
50
|
PROTOCOL_VERSION,
|
|
41
51
|
HEADER_LEN,
|
|
42
52
|
buildFrame,
|
|
@@ -44,6 +54,8 @@ export {
|
|
|
44
54
|
buildFlags,
|
|
45
55
|
encodeFrame,
|
|
46
56
|
decodeHeader,
|
|
57
|
+
hasBinary,
|
|
58
|
+
hasDaemonOrigin,
|
|
47
59
|
encodeHeader,
|
|
48
60
|
DecodeError,
|
|
49
61
|
type DecodeErrorCode,
|
package/src/provider.ts
CHANGED
|
@@ -352,6 +352,8 @@ export class SubcProviderError extends Error {
|
|
|
352
352
|
message: string,
|
|
353
353
|
readonly code?: string,
|
|
354
354
|
readonly kind: SubcCallErrorKind = "terminal",
|
|
355
|
+
/** Wire `ErrorBody.detail` carried verbatim (typed refusal payloads). */
|
|
356
|
+
readonly detail?: unknown,
|
|
355
357
|
) {
|
|
356
358
|
super(message);
|
|
357
359
|
}
|
|
@@ -1222,11 +1224,12 @@ function routeKey(handle: RouteHandle, corr: bigint): string {
|
|
|
1222
1224
|
|
|
1223
1225
|
function providerErrorFromFrame(frame: Frame): SubcProviderError {
|
|
1224
1226
|
try {
|
|
1225
|
-
const body = parseJson(frame.body) as { code?: string; message?: string };
|
|
1227
|
+
const body = parseJson(frame.body) as { code?: string; message?: string; detail?: unknown };
|
|
1226
1228
|
return new SubcProviderError(
|
|
1227
1229
|
body.message ?? "subc error",
|
|
1228
1230
|
body.code,
|
|
1229
1231
|
body.code === "stale_route_epoch" || body.code === "unknown_channel" ? "not_sent" : "terminal",
|
|
1232
|
+
body.detail,
|
|
1230
1233
|
);
|
|
1231
1234
|
} catch {
|
|
1232
1235
|
return new SubcProviderError(Buffer.from(frame.body).toString("utf8") || "subc error");
|