@cortexkit/subc-client 0.19.0 → 0.21.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/README.md +48 -1
- package/dist/client.d.ts +38 -11
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +140 -20
- package/dist/client.js.map +1 -1
- package/dist/connection-file.d.ts +6 -1
- package/dist/connection-file.d.ts.map +1 -1
- package/dist/connection-file.js +32 -10
- package/dist/connection-file.js.map +1 -1
- package/dist/envelope.d.ts +11 -1
- package/dist/envelope.d.ts.map +1 -1
- package/dist/envelope.js +23 -7
- package/dist/envelope.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/provider.d.ts +3 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +17 -8
- package/dist/provider.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +164 -25
- package/src/connection-file.ts +37 -10
- package/src/envelope.ts +32 -13
- package/src/index.ts +2 -0
- package/src/provider.ts +19 -7
package/src/client.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type Frame,
|
|
27
27
|
} from "./envelope.js";
|
|
28
28
|
import { launchNonceOrUndefined } from "./launch-nonce.js";
|
|
29
|
+
import type { Principal } from "./provider.js";
|
|
29
30
|
import {
|
|
30
31
|
belongsToConnection,
|
|
31
32
|
createRouteHandle,
|
|
@@ -118,7 +119,19 @@ export interface ConsumerIdentity {
|
|
|
118
119
|
launch_nonce: string;
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/** A scope selector; scopeEpoch is required even for the scope's owner. */
|
|
123
|
+
export interface RouteScope {
|
|
124
|
+
/** Owner principal, passed verbatim. The daemon decides which principal kinds may own scopes. */
|
|
125
|
+
owner: Principal;
|
|
126
|
+
/** Opaque, non-empty ref within the owner. */
|
|
127
|
+
ref: string;
|
|
128
|
+
/** Safe non-negative integer, including zero; serialized as scope_epoch. */
|
|
129
|
+
scopeEpoch: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
121
132
|
export interface RouteOpenOptions {
|
|
133
|
+
/** Open under this scope. The caller must be its owner or a listed carrier; use a daemon advertising scopes/v1. Serialized as { owner, ref, scope_epoch }. */
|
|
134
|
+
scope?: RouteScope;
|
|
122
135
|
/** Optional override for the consumer identity; by default the SUBC_MODULE_ID environment variable and the process's launch nonce (see `launchNonce`) are used when both are non-empty; a refused launch-nonce descriptor counts as no nonce. Set null to send route.open without consumer_identity. */
|
|
123
136
|
consumerIdentity?: ConsumerIdentity | null;
|
|
124
137
|
/**
|
|
@@ -202,6 +215,8 @@ export interface RequestOptions {
|
|
|
202
215
|
}
|
|
203
216
|
|
|
204
217
|
export interface ManagedCallOptions extends RequestOptions {
|
|
218
|
+
/** Scope for the cached route and its reconnect reopens. The caller must be the scope's owner or a listed carrier; use a daemon advertising scopes/v1. Each owner/ref/epoch has a separate cache entry. */
|
|
219
|
+
scope?: RouteScope;
|
|
205
220
|
/** Overrides the per-client identity used for route.open before this call. */
|
|
206
221
|
identity?: BindIdentity;
|
|
207
222
|
/** Defaults to management_surface, matching the store/host management APIs. */
|
|
@@ -236,6 +251,8 @@ export interface CloseRouteOptions {
|
|
|
236
251
|
}
|
|
237
252
|
|
|
238
253
|
export interface ManagedCloseRouteOptions extends CloseRouteOptions {
|
|
254
|
+
/** Scope selector used by the cached managed route; omitted selects only an unscoped route. */
|
|
255
|
+
scope?: RouteScope;
|
|
239
256
|
/** Consumer identity used by the cached managed route. */
|
|
240
257
|
consumerIdentity?: ConsumerIdentity | null;
|
|
241
258
|
/** The registry whose derived capability set identifies the cached route. */
|
|
@@ -297,6 +314,12 @@ export class SubcCallError extends Error {
|
|
|
297
314
|
get detail(): unknown {
|
|
298
315
|
return this.cause instanceof SubcError ? this.cause.detail : undefined;
|
|
299
316
|
}
|
|
317
|
+
|
|
318
|
+
/** Why the route ended. A reason never makes an in-flight call safe to resend. */
|
|
319
|
+
get closeReason(): RouteEndReason | undefined {
|
|
320
|
+
return this.cause instanceof SubcError || this.cause instanceof SubcCallError
|
|
321
|
+
? this.cause.closeReason : undefined;
|
|
322
|
+
}
|
|
300
323
|
}
|
|
301
324
|
|
|
302
325
|
/**
|
|
@@ -324,6 +347,7 @@ export class SubcError extends Error {
|
|
|
324
347
|
* those reasons at the transport boundary.
|
|
325
348
|
*/
|
|
326
349
|
readonly detail?: unknown,
|
|
350
|
+
readonly closeReason?: RouteEndReason,
|
|
327
351
|
) {
|
|
328
352
|
super(message);
|
|
329
353
|
}
|
|
@@ -404,15 +428,11 @@ export interface ConnectOptions {
|
|
|
404
428
|
*/
|
|
405
429
|
timeoutArbitrationGraceMs?: number;
|
|
406
430
|
/**
|
|
407
|
-
* Observer for daemon
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
* what they don't know, per the MUST-ignore clause), a push that fails to
|
|
413
|
-
* parse as JSON is dropped without surfacing, and an observer throw is
|
|
414
|
-
* swallowed like every other caller callback so it cannot fail the read loop
|
|
415
|
-
* or unrelated requests.
|
|
431
|
+
* Observer for parsed daemon control pushes, including unrecognized ops which
|
|
432
|
+
* callers should ignore. Malformed JSON is dropped and observer exceptions are
|
|
433
|
+
* swallowed so they cannot fail unrelated requests. The client records route
|
|
434
|
+
* reasons before notifying the observer, but closes routes only on the separate
|
|
435
|
+
* GOODBYE frame, never on a control push.
|
|
416
436
|
*/
|
|
417
437
|
onControlPush?: (push: ControlPush) => void;
|
|
418
438
|
}
|
|
@@ -431,11 +451,18 @@ export type KnownRouteCloseReason =
|
|
|
431
451
|
| "restart"
|
|
432
452
|
| "disable"
|
|
433
453
|
| "crash"
|
|
434
|
-
| "capability_denied"
|
|
454
|
+
| "capability_denied"
|
|
455
|
+
| "scope_ended"
|
|
456
|
+
| "scope_carrier_removed"
|
|
457
|
+
| "scope_delegation_changed"
|
|
458
|
+
| "scope_parent_ended";
|
|
435
459
|
|
|
436
460
|
/** A route-close reason decoded from a daemon control push. */
|
|
437
461
|
export type RouteCloseReason = KnownRouteCloseReason | "unknown";
|
|
438
462
|
|
|
463
|
+
/** Why a route ended. Even a planned closure can leave a call's outcome unknown. */
|
|
464
|
+
export type RouteEndReason = RouteCloseReason | "closed_by_caller" | "connection_lost";
|
|
465
|
+
|
|
439
466
|
/** Whether the close reason alone permits reopening a route automatically. */
|
|
440
467
|
export type RouteCloseDisposition = "may_reopen" | "must_not_reopen";
|
|
441
468
|
|
|
@@ -452,6 +479,10 @@ export function parseRouteCloseReason(reason: unknown): RouteCloseReason {
|
|
|
452
479
|
case "disable":
|
|
453
480
|
case "crash":
|
|
454
481
|
case "capability_denied":
|
|
482
|
+
case "scope_ended":
|
|
483
|
+
case "scope_carrier_removed":
|
|
484
|
+
case "scope_delegation_changed":
|
|
485
|
+
case "scope_parent_ended":
|
|
455
486
|
return reason;
|
|
456
487
|
default:
|
|
457
488
|
return "unknown";
|
|
@@ -467,6 +498,10 @@ export function classifyRouteCloseReason(reason: unknown): RouteCloseDisposition
|
|
|
467
498
|
case "disable":
|
|
468
499
|
case "crash":
|
|
469
500
|
case "capability_denied":
|
|
501
|
+
case "scope_ended":
|
|
502
|
+
case "scope_carrier_removed":
|
|
503
|
+
case "scope_delegation_changed":
|
|
504
|
+
case "scope_parent_ended":
|
|
470
505
|
case "unknown":
|
|
471
506
|
return "must_not_reopen";
|
|
472
507
|
}
|
|
@@ -579,6 +614,9 @@ interface CachedRoute {
|
|
|
579
614
|
moduleId: string;
|
|
580
615
|
target: Extract<RouteTarget, { kind: ManagedRouteKind }>;
|
|
581
616
|
identity: BindIdentity;
|
|
617
|
+
scope?: RouteScope;
|
|
618
|
+
/** A terminal daemon close must survive connection replacement, not become a reopen. */
|
|
619
|
+
endReason?: RouteCloseReason;
|
|
582
620
|
consumerIdentity?: ConsumerIdentity;
|
|
583
621
|
reverseRequests: ReverseRequestRegistry;
|
|
584
622
|
handle: RouteHandle | null;
|
|
@@ -600,6 +638,9 @@ export class SubcClient {
|
|
|
600
638
|
private readonly lateResponses = new Map<string, (frame: Frame) => void>();
|
|
601
639
|
private readonly routes = new Map<string, CachedRoute>();
|
|
602
640
|
private readonly liveRoutes = new Map<number, RouteHandle>();
|
|
641
|
+
private readonly routeModules = new Map<number, string>();
|
|
642
|
+
private readonly routeEndReasons = new Map<number, { reason: RouteCloseReason; final: boolean }>();
|
|
643
|
+
private readonly legacyChannelReasons = new Map<number, RouteCloseReason>();
|
|
603
644
|
private connectionToken = newConnectionToken();
|
|
604
645
|
private ingressEpochDropCount = 0;
|
|
605
646
|
private closedErr: Error | null = null;
|
|
@@ -687,12 +728,14 @@ export class SubcClient {
|
|
|
687
728
|
* Open a route and return its connection-bound immutable handle. At most
|
|
688
729
|
* MAX_ROUTE_OPENS_IN_FLIGHT opens are outstanding at once; further calls wait
|
|
689
730
|
* their turn.
|
|
731
|
+
* A scoped open requires the caller to be the scope's owner or a listed carrier.
|
|
690
732
|
*/
|
|
691
733
|
async routeOpen(target: RouteTarget, identity: BindIdentity, opts: RouteOpenOptions = {}): Promise<RouteHandle> {
|
|
734
|
+
const scope = validatedRouteScope(opts.scope);
|
|
692
735
|
// Without a timeout the gate always grants a slot eventually.
|
|
693
736
|
const release = (await this.routeOpenGate.acquire())!;
|
|
694
737
|
try {
|
|
695
|
-
return await this.sendRouteOpen(target, identity, opts);
|
|
738
|
+
return await this.sendRouteOpen(target, identity, { ...opts, scope });
|
|
696
739
|
} finally {
|
|
697
740
|
release();
|
|
698
741
|
}
|
|
@@ -704,6 +747,7 @@ export class SubcClient {
|
|
|
704
747
|
identity: BindIdentity,
|
|
705
748
|
opts: RouteOpenOptions = {},
|
|
706
749
|
): Promise<RouteHandle> {
|
|
750
|
+
const scope = validatedRouteScope(opts.scope);
|
|
707
751
|
const consumerIdentity = routeOpenConsumerIdentity(opts);
|
|
708
752
|
const reverseRequests = opts.reverseRequests ?? new ReverseRequestRegistry();
|
|
709
753
|
reverseRequests.seal();
|
|
@@ -714,6 +758,7 @@ export class SubcClient {
|
|
|
714
758
|
identity,
|
|
715
759
|
...(consumerIdentity ? { consumer_identity: consumerIdentity } : {}),
|
|
716
760
|
...(consumerCapabilities.length > 0 ? { consumer_capabilities: consumerCapabilities } : {}),
|
|
761
|
+
...(scope ? { scope: { owner: scope.owner, ref: scope.ref, scope_epoch: scope.scopeEpoch } } : {}),
|
|
717
762
|
});
|
|
718
763
|
|
|
719
764
|
let installed: RouteHandle | null = null;
|
|
@@ -724,6 +769,7 @@ export class SubcClient {
|
|
|
724
769
|
throw new SubcError(`route.open returned no route handle: ${JSON.stringify(parsed)}`);
|
|
725
770
|
}
|
|
726
771
|
installed = this.installRoute(parsed.route_channel, parsed.route_epoch, reverseRequests);
|
|
772
|
+
this.routeModules.set(parsed.route_channel, target.module_id);
|
|
727
773
|
return true;
|
|
728
774
|
};
|
|
729
775
|
const closeLateRoute = (frame: Frame): void => {
|
|
@@ -732,7 +778,7 @@ export class SubcClient {
|
|
|
732
778
|
const parsed = this.parseJson(frame) as { route_channel?: number; route_epoch?: number };
|
|
733
779
|
if (typeof parsed.route_channel !== "number" || typeof parsed.route_epoch !== "number") return;
|
|
734
780
|
const lateHandle = this.installRoute(parsed.route_channel, parsed.route_epoch);
|
|
735
|
-
this.failHandle(lateHandle, new SubcError("late route.open was closed", "route_closed"));
|
|
781
|
+
this.failHandle(lateHandle, new SubcError("late route.open was closed", "route_closed", undefined, "closed_by_caller"));
|
|
736
782
|
this.liveRoutes.delete(lateHandle.channel);
|
|
737
783
|
this.sendRouteGoodbye(lateHandle, true);
|
|
738
784
|
} catch {
|
|
@@ -770,6 +816,8 @@ export class SubcClient {
|
|
|
770
816
|
/**
|
|
771
817
|
* Managed route + request convenience. Opens and caches a route for the module,
|
|
772
818
|
* reconnecting and re-opening cached routes after connection drops.
|
|
819
|
+
* Scoped routes are isolated by owner/ref/epoch and require owner or carrier authority.
|
|
820
|
+
* Terminal scope close reasons prevent automatic reopens under the same selector.
|
|
773
821
|
*/
|
|
774
822
|
async call<Response = unknown>(
|
|
775
823
|
moduleId: string,
|
|
@@ -1018,7 +1066,7 @@ export class SubcClient {
|
|
|
1018
1066
|
}
|
|
1019
1067
|
}
|
|
1020
1068
|
if (opts.drain) await this.drainUnaryOnHandle(handle);
|
|
1021
|
-
this.failHandle(handle, new SubcError("route closed by closeRoute", "route_closed"));
|
|
1069
|
+
this.failHandle(handle, new SubcError("route closed by closeRoute", "route_closed", undefined, "closed_by_caller"));
|
|
1022
1070
|
if (this.liveRoutes.get(handle.channel) === handle) this.liveRoutes.delete(handle.channel);
|
|
1023
1071
|
this.sendRouteGoodbye(handle);
|
|
1024
1072
|
}
|
|
@@ -1034,6 +1082,7 @@ export class SubcClient {
|
|
|
1034
1082
|
identity,
|
|
1035
1083
|
routeOpenConsumerIdentity(opts),
|
|
1036
1084
|
(opts.reverseRequests ?? new ReverseRequestRegistry()).capabilities(),
|
|
1085
|
+
validatedRouteScope(opts.scope),
|
|
1037
1086
|
);
|
|
1038
1087
|
const cached = this.routes.get(key);
|
|
1039
1088
|
if (!cached) return;
|
|
@@ -1161,11 +1210,13 @@ export class SubcClient {
|
|
|
1161
1210
|
};
|
|
1162
1211
|
pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, channel, corr, ms), ms);
|
|
1163
1212
|
this.pending.set(key, pending);
|
|
1164
|
-
if (signal) this.attachCancelSignal(signal, key, handle, corr, priority);
|
|
1165
1213
|
writeBorrowed(this.sock, encodeFrame(frame), Date.now() + ms).catch((error) => {
|
|
1166
1214
|
const current = this.pending.get(key);
|
|
1167
1215
|
if (current) this.rejectPending(key, current, error instanceof Error ? error : new SubcError(String(error)));
|
|
1168
1216
|
});
|
|
1217
|
+
// Queue REQUEST first: an already-aborted signal emits CANCEL immediately,
|
|
1218
|
+
// and the daemon can only cancel a correlation it has already received.
|
|
1219
|
+
if (signal) this.attachCancelSignal(signal, key, handle, corr, priority);
|
|
1169
1220
|
});
|
|
1170
1221
|
}
|
|
1171
1222
|
|
|
@@ -1292,17 +1343,19 @@ export class SubcClient {
|
|
|
1292
1343
|
};
|
|
1293
1344
|
pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, handle.channel, corr, ms), ms);
|
|
1294
1345
|
this.pending.set(key, pending);
|
|
1295
|
-
if (signal) this.attachCancelSignal(signal, key, handle, corr, priority);
|
|
1296
1346
|
const write = writeTrackedBorrowed(this.sock, encodeFrame(frame), Date.now() + ms);
|
|
1297
1347
|
handedToSocket = write.queued;
|
|
1298
1348
|
write.completed.catch((error) => {
|
|
1299
1349
|
const current = this.pending.get(key);
|
|
1300
1350
|
if (current) this.rejectPending(key, current, error instanceof Error ? error : new SubcError(String(error)));
|
|
1301
1351
|
});
|
|
1352
|
+
// Preserve REQUEST/CANCEL order even when the signal was already aborted.
|
|
1353
|
+
if (signal && write.queued) this.attachCancelSignal(signal, key, handle, corr, priority);
|
|
1302
1354
|
});
|
|
1303
1355
|
}
|
|
1304
1356
|
|
|
1305
1357
|
private async cachedRouteHandle(moduleId: string, opts: ManagedCallOptions): Promise<RouteHandle> {
|
|
1358
|
+
const scope = validatedRouteScope(opts.scope);
|
|
1306
1359
|
const identity = opts.identity ?? this.opts.identity;
|
|
1307
1360
|
if (!identity) {
|
|
1308
1361
|
throw new SubcCallError(
|
|
@@ -1318,7 +1371,7 @@ export class SubcClient {
|
|
|
1318
1371
|
const consumerIdentity = routeOpenConsumerIdentity(opts);
|
|
1319
1372
|
const reverseRequests = opts.reverseRequests ?? new ReverseRequestRegistry();
|
|
1320
1373
|
reverseRequests.seal();
|
|
1321
|
-
const key = routeCacheKey(target, identity, consumerIdentity, reverseRequests.capabilities());
|
|
1374
|
+
const key = routeCacheKey(target, identity, consumerIdentity, reverseRequests.capabilities(), scope);
|
|
1322
1375
|
let cached = this.routes.get(key);
|
|
1323
1376
|
if (!cached) {
|
|
1324
1377
|
cached = {
|
|
@@ -1326,6 +1379,7 @@ export class SubcClient {
|
|
|
1326
1379
|
moduleId,
|
|
1327
1380
|
target,
|
|
1328
1381
|
identity,
|
|
1382
|
+
scope,
|
|
1329
1383
|
consumerIdentity,
|
|
1330
1384
|
reverseRequests,
|
|
1331
1385
|
handle: null,
|
|
@@ -1333,6 +1387,7 @@ export class SubcClient {
|
|
|
1333
1387
|
};
|
|
1334
1388
|
this.routes.set(key, cached);
|
|
1335
1389
|
}
|
|
1390
|
+
if (cached.endReason !== undefined) throw this.endedCachedRoute(cached.endReason);
|
|
1336
1391
|
if (cached.handle && this.isLiveHandle(cached.handle)) return cached.handle;
|
|
1337
1392
|
// The retries end at the retry deadline or at the call's own timeoutMs,
|
|
1338
1393
|
// whichever comes first. Without timeoutMs the call has no overall deadline
|
|
@@ -1378,6 +1433,7 @@ export class SubcClient {
|
|
|
1378
1433
|
const refusals = new RouteOpenRefusals();
|
|
1379
1434
|
for (;;) {
|
|
1380
1435
|
if (cached.closed) throw this.routeClosedDuringOpen();
|
|
1436
|
+
if (cached.endReason !== undefined) throw this.endedCachedRoute(cached.endReason);
|
|
1381
1437
|
try {
|
|
1382
1438
|
await this.ensureConnectedForManaged();
|
|
1383
1439
|
} catch (error) {
|
|
@@ -1392,6 +1448,7 @@ export class SubcClient {
|
|
|
1392
1448
|
try {
|
|
1393
1449
|
outcome = {
|
|
1394
1450
|
handle: await this.sendRouteOpen(cached.target, cached.identity, {
|
|
1451
|
+
scope: cached.scope,
|
|
1395
1452
|
consumerIdentity: cached.consumerIdentity ?? null,
|
|
1396
1453
|
reverseRequests: cached.reverseRequests,
|
|
1397
1454
|
}),
|
|
@@ -1650,9 +1707,10 @@ export class SubcClient {
|
|
|
1650
1707
|
}
|
|
1651
1708
|
for (const key of routeKeys) {
|
|
1652
1709
|
const cached = this.routes.get(key);
|
|
1653
|
-
if (!cached || cached.closed) continue;
|
|
1710
|
+
if (!cached || cached.closed || cached.endReason !== undefined) continue;
|
|
1654
1711
|
try {
|
|
1655
1712
|
const handle = await this.routeOpen(cached.target, cached.identity, {
|
|
1713
|
+
scope: cached.scope,
|
|
1656
1714
|
consumerIdentity: cached.consumerIdentity ?? null,
|
|
1657
1715
|
reverseRequests: cached.reverseRequests,
|
|
1658
1716
|
});
|
|
@@ -1683,7 +1741,13 @@ export class SubcClient {
|
|
|
1683
1741
|
}
|
|
1684
1742
|
|
|
1685
1743
|
private routeClosedDuringOpen(): SubcCallError {
|
|
1686
|
-
|
|
1744
|
+
const cause = new SubcError("route was closed before route.open completed", "route_closed", undefined, "closed_by_caller");
|
|
1745
|
+
return new SubcCallError("not_sent", cause.message, cause.code, cause);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
private endedCachedRoute(reason: RouteCloseReason): SubcCallError {
|
|
1749
|
+
const cause = new SubcError(`cached route closed by subc (${reason}); must not reopen`, "route_closed", undefined, reason);
|
|
1750
|
+
return this.terminalCallError(cause.message, cause);
|
|
1687
1751
|
}
|
|
1688
1752
|
|
|
1689
1753
|
private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
|
|
@@ -1726,12 +1790,12 @@ export class SubcClient {
|
|
|
1726
1790
|
if (frame.header.channel === 0 && frame.header.ty === FrameType.Push) {
|
|
1727
1791
|
// Daemon-originated control push (route.closing / route.closed / future
|
|
1728
1792
|
// ops). Never matches a pending (corr is daemon-chosen), never an error
|
|
1729
|
-
// path: unparseable bodies
|
|
1730
|
-
//
|
|
1793
|
+
// path: unparseable bodies drop silently. Reasons are recorded even without
|
|
1794
|
+
// an observer, and an observer throw is
|
|
1731
1795
|
// swallowed for the same reason as onProgress below -- a caller callback
|
|
1732
1796
|
// must not fail the read loop.
|
|
1733
1797
|
const observer = this.opts.onControlPush;
|
|
1734
|
-
|
|
1798
|
+
{
|
|
1735
1799
|
let parsed: ControlPush | null = null;
|
|
1736
1800
|
try {
|
|
1737
1801
|
const body = this.parseJson(frame) as Record<string, unknown>;
|
|
@@ -1740,8 +1804,9 @@ export class SubcClient {
|
|
|
1740
1804
|
// Unparseable control push: ignored by contract.
|
|
1741
1805
|
}
|
|
1742
1806
|
if (parsed) {
|
|
1807
|
+
this.recordRouteEnd(parsed);
|
|
1743
1808
|
try {
|
|
1744
|
-
observer(parsed);
|
|
1809
|
+
observer?.(parsed);
|
|
1745
1810
|
} catch {
|
|
1746
1811
|
// Observer's own throw, on its own stack; the stream must survive.
|
|
1747
1812
|
}
|
|
@@ -1823,7 +1888,8 @@ export class SubcClient {
|
|
|
1823
1888
|
// so it stays kind=outcome_unknown -- the same class as a mid-flight socket
|
|
1824
1889
|
// drop, and never the not_sent/unknown_channel class that call() retries.
|
|
1825
1890
|
// The code says WHICH route ended, not that it is safe to send again.
|
|
1826
|
-
this.failHandle(handle, new SubcError("route closed by subc (GOODBYE)", "route_closed"));
|
|
1891
|
+
this.failHandle(handle, new SubcError("route closed by subc (GOODBYE)", "route_closed", undefined, this.routeEndReason(handle.channel)));
|
|
1892
|
+
this.preventRouteReopen(handle.channel, this.routeEndReason(handle.channel));
|
|
1827
1893
|
if (this.liveRoutes.get(handle.channel) === handle) this.liveRoutes.delete(handle.channel);
|
|
1828
1894
|
this.evictRouteHandle(handle);
|
|
1829
1895
|
return;
|
|
@@ -1990,6 +2056,43 @@ export class SubcClient {
|
|
|
1990
2056
|
}
|
|
1991
2057
|
}
|
|
1992
2058
|
|
|
2059
|
+
private recordRouteEnd(push: ControlPush): void {
|
|
2060
|
+
const reason = routeCloseReason(push);
|
|
2061
|
+
if (reason === undefined) return;
|
|
2062
|
+
if ("channels" in push.body) {
|
|
2063
|
+
if (Array.isArray(push.body.channels)) {
|
|
2064
|
+
for (const channel of push.body.channels) {
|
|
2065
|
+
if (typeof channel !== "number" || !Number.isInteger(channel) || channel < 0 || channel > 65535) continue;
|
|
2066
|
+
const final = push.op === "route.closed";
|
|
2067
|
+
if (final || !this.routeEndReasons.get(channel)?.final) {
|
|
2068
|
+
this.routeEndReasons.set(channel, { reason, final });
|
|
2069
|
+
if (final) this.preventRouteReopen(channel, reason);
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
} else if (typeof push.body.module_id === "string") {
|
|
2074
|
+
for (const [channel, module] of this.routeModules) {
|
|
2075
|
+
if (module === push.body.module_id) {
|
|
2076
|
+
this.legacyChannelReasons.set(channel, reason);
|
|
2077
|
+
if (push.op === "route.closed") this.preventRouteReopen(channel, reason);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
private preventRouteReopen(channel: number, reason: RouteCloseReason): void {
|
|
2084
|
+
if (classifyRouteCloseReason(reason) !== "must_not_reopen") return;
|
|
2085
|
+
for (const cached of this.routes.values()) {
|
|
2086
|
+
if (cached.handle?.channel === channel) cached.endReason = reason;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
private routeEndReason(channel: number): RouteCloseReason {
|
|
2091
|
+
const specific = this.routeEndReasons.get(channel);
|
|
2092
|
+
if (specific) return specific.reason;
|
|
2093
|
+
return this.legacyChannelReasons.get(channel) ?? "unknown";
|
|
2094
|
+
}
|
|
2095
|
+
|
|
1993
2096
|
private failHandle(handle: RouteHandle, error: Error): void {
|
|
1994
2097
|
for (const [key, pending] of this.pending) {
|
|
1995
2098
|
if (pending.handle && sameRouteHandle(pending.handle, handle)) this.rejectPending(key, pending, error);
|
|
@@ -1997,9 +2100,15 @@ export class SubcClient {
|
|
|
1997
2100
|
}
|
|
1998
2101
|
|
|
1999
2102
|
private fail(err: Error): void {
|
|
2103
|
+
const routeError = err instanceof SubcError && err.closeReason ? err
|
|
2104
|
+
: new SubcError(err.message, errorCode(err), undefined,
|
|
2105
|
+
this.closeStarted ? "closed_by_caller" : "connection_lost");
|
|
2000
2106
|
if (!this.closedErr) this.closedErr = err;
|
|
2107
|
+
this.routeEndReasons.clear();
|
|
2108
|
+
this.legacyChannelReasons.clear();
|
|
2109
|
+
this.routeModules.clear();
|
|
2001
2110
|
for (const [key, pending] of this.pending) {
|
|
2002
|
-
this.rejectPending(key, pending, err);
|
|
2111
|
+
this.rejectPending(key, pending, pending.handle ? routeError : err);
|
|
2003
2112
|
}
|
|
2004
2113
|
}
|
|
2005
2114
|
|
|
@@ -2033,6 +2142,9 @@ export class SubcClient {
|
|
|
2033
2142
|
reverseRequests = new ReverseRequestRegistry(),
|
|
2034
2143
|
): RouteHandle {
|
|
2035
2144
|
const handle = createRouteHandle(channel, epoch, this.connectionToken, reverseRequests);
|
|
2145
|
+
this.routeEndReasons.delete(channel);
|
|
2146
|
+
this.legacyChannelReasons.delete(channel);
|
|
2147
|
+
this.routeModules.delete(channel);
|
|
2036
2148
|
this.liveRoutes.set(channel, handle);
|
|
2037
2149
|
return handle;
|
|
2038
2150
|
}
|
|
@@ -2134,6 +2246,8 @@ function isRouteOpenRefusal(err: unknown): err is SubcError & { code: string } {
|
|
|
2134
2246
|
export function isRetryableRouteOpenCode(code: string | undefined): boolean {
|
|
2135
2247
|
// A capability deny is policy, never a transient target-availability failure.
|
|
2136
2248
|
if (code === "capability_forbidden") return false;
|
|
2249
|
+
// Decoding a flow stamp alone does not promise flow behaviour.
|
|
2250
|
+
if (code === "target_flow_unsupported") return false;
|
|
2137
2251
|
return (
|
|
2138
2252
|
code === "module_reloading" ||
|
|
2139
2253
|
code === "module_warming" ||
|
|
@@ -2258,12 +2372,37 @@ function routeCacheKey(
|
|
|
2258
2372
|
identity: BindIdentity,
|
|
2259
2373
|
consumerIdentity: ConsumerIdentity | undefined,
|
|
2260
2374
|
consumerCapabilities: readonly string[],
|
|
2375
|
+
scope?: RouteScope,
|
|
2261
2376
|
): string {
|
|
2262
2377
|
const consumerPart = consumerIdentity
|
|
2263
2378
|
? `${consumerIdentity.module_id}\0${consumerIdentity.launch_nonce}`
|
|
2264
2379
|
: "";
|
|
2265
2380
|
const capabilitiesPart = [...consumerCapabilities].sort().join("\0");
|
|
2266
|
-
|
|
2381
|
+
// A JSON tuple keeps opaque refs (including embedded separators) distinct.
|
|
2382
|
+
const scopePart = scope
|
|
2383
|
+
? JSON.stringify([scope.owner.kind, scope.owner.kind === "reserved" ? scope.owner.module_id : null, scope.ref, scope.scopeEpoch])
|
|
2384
|
+
: "";
|
|
2385
|
+
return `${target.kind}\0${target.module_id}\0${identity.project_root}\0${identity.harness}\0${identity.session}\0${consumerPart}\0${capabilitiesPart}\0${scopePart}`;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
/** Validate before any route-open I/O, and snapshot caller-owned mutable values for reopens. */
|
|
2389
|
+
function validatedRouteScope(scope: RouteScope | undefined): RouteScope | undefined {
|
|
2390
|
+
if (scope === undefined) return undefined;
|
|
2391
|
+
if (!scope || typeof scope.owner !== "object" || !scope.owner ||
|
|
2392
|
+
!["reserved", "direct", "unverified"].includes(scope.owner.kind) ||
|
|
2393
|
+
(scope.owner.kind === "reserved" && (typeof scope.owner.module_id !== "string" || scope.owner.module_id.length === 0))) {
|
|
2394
|
+
throw new SubcError("scope.owner must be a Principal with a non-empty reserved module_id", "invalid_scope");
|
|
2395
|
+
}
|
|
2396
|
+
if (typeof scope.ref !== "string" || scope.ref.length === 0) {
|
|
2397
|
+
throw new SubcError("scope.ref must be a non-empty string", "invalid_scope");
|
|
2398
|
+
}
|
|
2399
|
+
if (scope.scopeEpoch === undefined) {
|
|
2400
|
+
throw new SubcError("scope.scopeEpoch is required", "scope_epoch_required");
|
|
2401
|
+
}
|
|
2402
|
+
if (!Number.isSafeInteger(scope.scopeEpoch) || scope.scopeEpoch < 0) {
|
|
2403
|
+
throw new SubcError("scope.scopeEpoch must be a safe non-negative integer", "invalid_scope");
|
|
2404
|
+
}
|
|
2405
|
+
return { owner: { ...scope.owner }, ref: scope.ref, scopeEpoch: scope.scopeEpoch };
|
|
2267
2406
|
}
|
|
2268
2407
|
|
|
2269
2408
|
function routeOpenConsumerIdentity(opts: RouteOpenOptions = {}): ConsumerIdentity | undefined {
|
package/src/connection-file.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Port of subc-transport's connection_file.rs reader. The connection file is
|
|
2
2
|
// the daemon's published rendezvous record; its `key` is the shared transport
|
|
3
|
-
// secret. We refuse to trust a key from a file
|
|
4
|
-
// exactly as the Rust reader does, so a
|
|
5
|
-
// loud failure rather than a silent downgrade.
|
|
3
|
+
// secret. We refuse to trust a key from a file another local user owns or can
|
|
4
|
+
// read, exactly as the Rust reader does, so a substituted or leaked
|
|
5
|
+
// (group/world-readable) key is a loud failure rather than a silent downgrade.
|
|
6
6
|
|
|
7
7
|
import { promises as fs } from "node:fs";
|
|
8
8
|
|
|
@@ -64,14 +64,22 @@ function validate(info: ConnectionInfo): void {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* On unix,
|
|
68
|
-
*
|
|
67
|
+
* On unix, refuse a file owned by anyone but `expectedUid`, then any
|
|
68
|
+
* group/other permission bit. A file another user owns is untrusted even at
|
|
69
|
+
* 0600: its owner could have written their own endpoint and key, and this
|
|
70
|
+
* client would then authenticate to them. A wider mode on our own file means the
|
|
71
|
+
* secret has leaked. `expectedUid` is undefined where the platform has no
|
|
72
|
+
* effective uid (Windows), and the owner check is skipped there. On Windows the
|
|
69
73
|
* file inherits the per-user profile directory ACL at create time and there are
|
|
70
74
|
* no portable mode bits to re-check, matching the Rust no-op.
|
|
71
75
|
*/
|
|
72
|
-
|
|
76
|
+
function verifyOwnerOnly(path: string, stat: { uid: number; mode: number }, expectedUid: number | undefined): void {
|
|
73
77
|
if (process.platform === "win32") return;
|
|
74
|
-
|
|
78
|
+
if (expectedUid !== undefined && stat.uid !== expectedUid) {
|
|
79
|
+
throw new ConnectionFileError(
|
|
80
|
+
`connection file ${path} is owned by uid ${stat.uid}, expected effective uid ${expectedUid}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
75
83
|
const mode = stat.mode & 0o777;
|
|
76
84
|
if ((mode & 0o077) !== 0) {
|
|
77
85
|
throw new ConnectionFileError(
|
|
@@ -80,10 +88,29 @@ async function verifyOwnerOnly(path: string): Promise<void> {
|
|
|
80
88
|
}
|
|
81
89
|
}
|
|
82
90
|
|
|
83
|
-
/** Read, permission-check, and validate a connection file. */
|
|
91
|
+
/** Read, ownership- and permission-check, and validate a connection file. */
|
|
84
92
|
export async function readConnectionFile(path: string): Promise<ConnectionInfo> {
|
|
85
|
-
|
|
86
|
-
|
|
93
|
+
return readConnectionFileForUid(path, process.geteuid?.());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @internal `readConnectionFile` with the expected owner uid supplied by the
|
|
98
|
+
* caller, so tests can present a foreign owner without changing the process uid.
|
|
99
|
+
*/
|
|
100
|
+
export async function readConnectionFileForUid(
|
|
101
|
+
path: string,
|
|
102
|
+
expectedUid: number | undefined,
|
|
103
|
+
): Promise<ConnectionInfo> {
|
|
104
|
+
// Check the opened file, not a second lookup of the path: if the path were
|
|
105
|
+
// replaced between a stat and the read, an unchecked key would get through.
|
|
106
|
+
const handle = await fs.open(path, "r");
|
|
107
|
+
let raw: string;
|
|
108
|
+
try {
|
|
109
|
+
verifyOwnerOnly(path, await handle.stat(), expectedUid);
|
|
110
|
+
raw = await handle.readFile({ encoding: "utf8" });
|
|
111
|
+
} finally {
|
|
112
|
+
await handle.close();
|
|
113
|
+
}
|
|
87
114
|
let parsed: Record<string, unknown>;
|
|
88
115
|
try {
|
|
89
116
|
parsed = JSON.parse(raw) as Record<string, unknown>;
|
package/src/envelope.ts
CHANGED
|
@@ -172,18 +172,19 @@ function validateHeaderFields(header: EnvelopeHeader): void {
|
|
|
172
172
|
"sheddable_illegal_frame_type",
|
|
173
173
|
);
|
|
174
174
|
}
|
|
175
|
-
|
|
176
|
-
throw new DecodeError(
|
|
177
|
-
`control channel carried nonzero epoch ${epoch}`,
|
|
178
|
-
"nonzero_epoch_on_control_channel",
|
|
179
|
-
);
|
|
180
|
-
}
|
|
175
|
+
// Match the wire decoder's precedence when a header violates both rules.
|
|
181
176
|
if (isPureHeader(ty) && len !== 0) {
|
|
182
177
|
throw new DecodeError(
|
|
183
178
|
`pure-header frame ${FrameType[ty]} declared non-zero body length ${len}`,
|
|
184
179
|
"pure_header_frame_with_body",
|
|
185
180
|
);
|
|
186
181
|
}
|
|
182
|
+
if (channel === 0 && epoch !== 0) {
|
|
183
|
+
throw new DecodeError(
|
|
184
|
+
`control channel carried nonzero epoch ${epoch}`,
|
|
185
|
+
"nonzero_epoch_on_control_channel",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
187
188
|
}
|
|
188
189
|
|
|
189
190
|
/** Decode and validate a header from the front of `bytes`. */
|
|
@@ -233,18 +234,19 @@ export function decodeHeader(bytes: Uint8Array): EnvelopeHeader {
|
|
|
233
234
|
}
|
|
234
235
|
const channel = view.getUint16(7, true);
|
|
235
236
|
const epoch = view.getUint32(9, true);
|
|
236
|
-
|
|
237
|
-
throw new DecodeError(
|
|
238
|
-
`control channel carried nonzero epoch ${epoch}`,
|
|
239
|
-
"nonzero_epoch_on_control_channel",
|
|
240
|
-
);
|
|
241
|
-
}
|
|
237
|
+
// Body length is checked before the control epoch in Rust and Swift too.
|
|
242
238
|
if (isPureHeader(ty) && len !== 0) {
|
|
243
239
|
throw new DecodeError(
|
|
244
240
|
`pure-header frame ${FrameType[ty]} declared non-zero body length ${len}`,
|
|
245
241
|
"pure_header_frame_with_body",
|
|
246
242
|
);
|
|
247
243
|
}
|
|
244
|
+
if (channel === 0 && epoch !== 0) {
|
|
245
|
+
throw new DecodeError(
|
|
246
|
+
`control channel carried nonzero epoch ${epoch}`,
|
|
247
|
+
"nonzero_epoch_on_control_channel",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
248
250
|
return { len, ver, ty, flags, channel, epoch, corr: view.getBigUint64(13, true) };
|
|
249
251
|
}
|
|
250
252
|
|
|
@@ -278,7 +280,17 @@ export function buildFrameWithVersion(
|
|
|
278
280
|
return { header, body };
|
|
279
281
|
}
|
|
280
282
|
|
|
281
|
-
/**
|
|
283
|
+
/**
|
|
284
|
+
* Encode a frame to wire bytes: header followed by exactly `len` body bytes.
|
|
285
|
+
*
|
|
286
|
+
* Refuses, before producing any bytes, a frame the peer's decoder would reject:
|
|
287
|
+
* a header that fails any wire decode rule, or a body over MAX_FRAME_BODY_LEN.
|
|
288
|
+
* A `Frame` can be assembled by hand without going through `buildFrame`, and
|
|
289
|
+
* once a rejected header is on the socket the peer tears the whole connection
|
|
290
|
+
* down, so the refusal has to happen here rather than on the far side. The
|
|
291
|
+
* header check runs the encoded bytes through `decodeHeader` itself, so the
|
|
292
|
+
* writer cannot drift from the reader's rules.
|
|
293
|
+
*/
|
|
282
294
|
export function encodeFrame(frame: Frame): Uint8Array {
|
|
283
295
|
if (frame.header.len !== frame.body.length) {
|
|
284
296
|
throw new DecodeError(
|
|
@@ -286,7 +298,14 @@ export function encodeFrame(frame: Frame): Uint8Array {
|
|
|
286
298
|
"frame_length_mismatch",
|
|
287
299
|
);
|
|
288
300
|
}
|
|
301
|
+
if (frame.body.length > MAX_FRAME_BODY_LEN) {
|
|
302
|
+
throw new DecodeError(
|
|
303
|
+
`frame body ${frame.body.length} exceeds max ${MAX_FRAME_BODY_LEN}`,
|
|
304
|
+
"frame_body_too_large",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
289
307
|
const header = encodeHeader(frame.header);
|
|
308
|
+
decodeHeader(header);
|
|
290
309
|
const output = new Uint8Array(header.length + frame.body.length);
|
|
291
310
|
output.set(header, 0);
|
|
292
311
|
output.set(frame.body, header.length);
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export {
|
|
|
19
19
|
type RouteTarget,
|
|
20
20
|
type ManagedRouteKind,
|
|
21
21
|
type ConsumerIdentity,
|
|
22
|
+
type RouteScope,
|
|
22
23
|
type RouteOpenOptions,
|
|
23
24
|
type CatalogCapabilities,
|
|
24
25
|
type CatalogCapabilityRequirement,
|
|
@@ -29,6 +30,7 @@ export {
|
|
|
29
30
|
type ControlPush,
|
|
30
31
|
type KnownRouteCloseReason,
|
|
31
32
|
type RouteCloseReason,
|
|
33
|
+
type RouteEndReason,
|
|
32
34
|
type RouteCloseDisposition,
|
|
33
35
|
type ReconnectBackoff,
|
|
34
36
|
type SubcCallErrorKind,
|