@cortexkit/subc-client 0.5.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +17 -5
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +72 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +193 -15
- package/dist/client.js.map +1 -1
- package/dist/connection-file.d.ts.map +1 -1
- package/dist/connection-file.js +6 -1
- package/dist/connection-file.js.map +1 -1
- package/dist/envelope.d.ts.map +1 -1
- package/dist/envelope.js +36 -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.map +1 -1
- package/dist/provider.d.ts +5 -25
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +27 -30
- package/dist/provider.js.map +1 -1
- package/dist/socket.d.ts +12 -0
- package/dist/socket.d.ts.map +1 -1
- package/dist/socket.js +33 -3
- package/dist/socket.js.map +1 -1
- package/package.json +1 -1
- package/src/auth.ts +21 -9
- package/src/client.ts +233 -21
- package/src/connection-file.ts +6 -1
- package/src/envelope.ts +54 -1
- package/src/index.ts +1 -5
- package/src/provider.ts +34 -57
- package/src/socket.ts +41 -3
package/src/client.ts
CHANGED
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
SocketWriteNotQueuedError,
|
|
38
38
|
SocketWriteQueuedError,
|
|
39
39
|
SubcSocket,
|
|
40
|
+
writeBorrowed,
|
|
41
|
+
writeTrackedBorrowed,
|
|
40
42
|
} from "./socket.js";
|
|
41
43
|
|
|
42
44
|
const debug = debuglog("subc-client");
|
|
@@ -54,6 +56,7 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
|
54
56
|
// NOT a deadline extension: an absent reply still settles right after the check
|
|
55
57
|
// phase. Capped so it can never approach BODY_READ_TIMEOUT_MS.
|
|
56
58
|
const TIMEOUT_ARBITRATION_GRACE_MS = 50;
|
|
59
|
+
const LIVENESS_PROBE_WINDOW_MS = 2000;
|
|
57
60
|
// Internal marker set as the `code` on the SubcError a request-deadline timeout
|
|
58
61
|
// rejects with, so the managed classifier can tell a deadline (reply may simply
|
|
59
62
|
// not have been read in time) from an actual connection drop. Never surfaced to
|
|
@@ -210,13 +213,14 @@ export class SubcCallError extends Error {
|
|
|
210
213
|
/**
|
|
211
214
|
* A live subscription to a provider's event stream, riding a single held-open
|
|
212
215
|
* request. `onEvent` fires for each StreamData frame; `closed` resolves when the
|
|
213
|
-
* provider ends the stream (StreamEnd)
|
|
214
|
-
* GOODBYE. `unsubscribe` cancels the held-open request
|
|
216
|
+
* provider ends the stream (StreamEnd) or the consumer unsubscribes, and rejects on
|
|
217
|
+
* an Error terminal or a route GOODBYE. `unsubscribe` cancels the held-open request
|
|
218
|
+
* so the provider unwinds.
|
|
215
219
|
*/
|
|
216
220
|
export interface Subscription {
|
|
217
221
|
/** Cancel the subscription: sends Cancel on the held-open request; idempotent. */
|
|
218
222
|
unsubscribe(): void;
|
|
219
|
-
/** Resolves on StreamEnd; rejects on an Error terminal or route close. */
|
|
223
|
+
/** Resolves on StreamEnd or local unsubscribe; rejects on an Error terminal or route close. */
|
|
220
224
|
readonly closed: Promise<void>;
|
|
221
225
|
}
|
|
222
226
|
|
|
@@ -257,6 +261,15 @@ export interface ConnectOptions {
|
|
|
257
261
|
reconnectBackoff?: ReconnectBackoff;
|
|
258
262
|
/** Injectable sleep for timer-free reconnect tests. */
|
|
259
263
|
sleep?: (ms: number) => Promise<void>;
|
|
264
|
+
/**
|
|
265
|
+
* Window the post-deadline liveness probe waits for ANY inbound frame after
|
|
266
|
+
* sending its channel-0 Ping before convicting the socket as half-open.
|
|
267
|
+
* Genuine event-loop starvation self-protects at any setting: a loop starved
|
|
268
|
+
* enough to delay the probe's check also delays the read loop, and when it
|
|
269
|
+
* wakes the buffered Pong dispatches first. Exposed for deterministic tests.
|
|
270
|
+
*/
|
|
271
|
+
livenessProbeWindowMs?: number;
|
|
272
|
+
|
|
260
273
|
/**
|
|
261
274
|
* Hard cap on the timeout-arbitration grace window (see
|
|
262
275
|
* TIMEOUT_ARBITRATION_GRACE_MS). A reply whose bytes are actively arriving when
|
|
@@ -265,6 +278,26 @@ export interface ConnectOptions {
|
|
|
265
278
|
* exposed mainly so tests can prove the arbitration deterministically.
|
|
266
279
|
*/
|
|
267
280
|
timeoutArbitrationGraceMs?: number;
|
|
281
|
+
/**
|
|
282
|
+
* Observer for daemon-originated channel-0 control pushes (`route.closing`,
|
|
283
|
+
* `route.closed`, and any op added later). Purely advisory: the daemon's
|
|
284
|
+
* load-bearing route-death signal remains the GOODBYE frame, and nothing in
|
|
285
|
+
* the client's own route lifecycle consumes these. The contract is the wire
|
|
286
|
+
* contract verbatim -- unrecognized ops still arrive here (callers ignore
|
|
287
|
+
* what they don't know, per the MUST-ignore clause), a push that fails to
|
|
288
|
+
* parse as JSON is dropped without surfacing, and an observer throw is
|
|
289
|
+
* swallowed like every other caller callback so it cannot fail the read loop
|
|
290
|
+
* or unrelated requests.
|
|
291
|
+
*/
|
|
292
|
+
onControlPush?: (push: ControlPush) => void;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** A parsed daemon-originated channel-0 control push. */
|
|
296
|
+
export interface ControlPush {
|
|
297
|
+
/** The push discriminator, e.g. "route.closing" | "route.closed". */
|
|
298
|
+
op: string;
|
|
299
|
+
/** The full parsed body, `op` included, for op-specific fields. */
|
|
300
|
+
body: Record<string, unknown>;
|
|
268
301
|
}
|
|
269
302
|
|
|
270
303
|
interface NormalizedConnectOptions {
|
|
@@ -275,6 +308,8 @@ interface NormalizedConnectOptions {
|
|
|
275
308
|
reconnectBackoff: ReconnectBackoff;
|
|
276
309
|
sleep: (ms: number) => Promise<void>;
|
|
277
310
|
timeoutArbitrationGraceMs: number;
|
|
311
|
+
livenessProbeWindowMs: number;
|
|
312
|
+
onControlPush?: (push: ControlPush) => void;
|
|
278
313
|
}
|
|
279
314
|
|
|
280
315
|
interface OpenedConnection {
|
|
@@ -423,12 +458,18 @@ export class SubcClient {
|
|
|
423
458
|
// double-execute anything. The cached bind is dead (module restarted and
|
|
424
459
|
// its route-gone GOODBYE raced or was missed); evict it so the retry
|
|
425
460
|
// re-opens the route instead of resending into the same dead channel.
|
|
426
|
-
|
|
461
|
+
// stale_route_epoch is the same class with a sharper cause (subconscious
|
|
462
|
+
// issue #39): the channel is known but its epoch was released while this
|
|
463
|
+
// request was in flight. The daemon's contract for the code is
|
|
464
|
+
// NOT-FORWARDED — dropped before delivery — so the retry is safe by
|
|
465
|
+
// construction, and the remedy is identical: evict, re-open, resend once.
|
|
466
|
+
const deadBindCode = err.code === "unknown_channel" || err.code === "stale_route_epoch";
|
|
467
|
+
if (deadBindCode && !retriedUnknownChannel && !this.closeStarted) {
|
|
427
468
|
retriedUnknownChannel = true;
|
|
428
469
|
this.evictRouteHandle(routeHandle);
|
|
429
470
|
continue;
|
|
430
471
|
}
|
|
431
|
-
if (
|
|
472
|
+
if (deadBindCode && retriedUnknownChannel) {
|
|
432
473
|
this.evictRouteHandle(routeHandle);
|
|
433
474
|
}
|
|
434
475
|
if (err.kind === "not_sent") {
|
|
@@ -445,6 +486,14 @@ export class SubcClient {
|
|
|
445
486
|
// late under load), so tearing it down would abandon a healthy connection
|
|
446
487
|
// and its other in-flight routes for nothing.
|
|
447
488
|
this.scheduleReconnectAfterDrop(err);
|
|
489
|
+
} else if (err.kind === "outcome_unknown" && err.code === DEADLINE_NO_DROP_CODE) {
|
|
490
|
+
// Keeping the socket is only correct when the socket is actually
|
|
491
|
+
// alive. A HALF-OPEN socket (peer gone with no FIN/RST — the
|
|
492
|
+
// post-sleep/wake shape) produces the identical observable, and
|
|
493
|
+
// keeping it pins every future call to a corpse: this exact keep
|
|
494
|
+
// turned one host sleep into a whole session's tool outage. The
|
|
495
|
+
// probe supplies the discriminator neither timer can.
|
|
496
|
+
this.probeLivenessAfterDeadline();
|
|
448
497
|
}
|
|
449
498
|
throw err;
|
|
450
499
|
}
|
|
@@ -465,19 +514,23 @@ export class SubcClient {
|
|
|
465
514
|
const corr = this.allocateCorr();
|
|
466
515
|
const key = pendingKey(handle, corr);
|
|
467
516
|
|
|
517
|
+
let subscriptionPending: Pending | null = null;
|
|
518
|
+
let resolveClosed: (() => void) | null = null;
|
|
468
519
|
const closed = new Promise<void>((resolve, reject) => {
|
|
469
520
|
if (this.closedErr) {
|
|
470
521
|
reject(this.closedErr);
|
|
471
522
|
return;
|
|
472
523
|
}
|
|
473
|
-
|
|
524
|
+
resolveClosed = resolve;
|
|
525
|
+
subscriptionPending = {
|
|
474
526
|
handle,
|
|
475
527
|
resolve: () => resolve(),
|
|
476
528
|
reject,
|
|
477
529
|
onProgress: onEvent,
|
|
478
530
|
timer: null,
|
|
479
531
|
subscription: true,
|
|
480
|
-
}
|
|
532
|
+
};
|
|
533
|
+
this.pending.set(key, subscriptionPending);
|
|
481
534
|
const frame = buildFrame(
|
|
482
535
|
FrameType.Request,
|
|
483
536
|
buildFlags(false, priority, false, admission),
|
|
@@ -486,7 +539,7 @@ export class SubcClient {
|
|
|
486
539
|
corr,
|
|
487
540
|
bytes,
|
|
488
541
|
);
|
|
489
|
-
this.sock
|
|
542
|
+
writeBorrowed(this.sock, encodeFrame(frame), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch((err) => {
|
|
490
543
|
const pending = this.pending.get(key);
|
|
491
544
|
if (pending) this.rejectPending(key, pending, err instanceof Error ? err : new SubcError(String(err)));
|
|
492
545
|
});
|
|
@@ -496,7 +549,8 @@ export class SubcClient {
|
|
|
496
549
|
const unsubscribe = (): void => {
|
|
497
550
|
if (cancelled) return;
|
|
498
551
|
cancelled = true;
|
|
499
|
-
this.
|
|
552
|
+
if (subscriptionPending && resolveClosed) this.settle(key, subscriptionPending, resolveClosed);
|
|
553
|
+
if (this.isLiveHandle(handle)) this.cancel(handle, corr, priority);
|
|
500
554
|
};
|
|
501
555
|
return { unsubscribe, closed };
|
|
502
556
|
}
|
|
@@ -512,7 +566,7 @@ export class SubcClient {
|
|
|
512
566
|
corr,
|
|
513
567
|
EMPTY_BODY,
|
|
514
568
|
);
|
|
515
|
-
this.sock
|
|
569
|
+
writeBorrowed(this.sock, encodeFrame(cancel), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS).catch(() => undefined);
|
|
516
570
|
}
|
|
517
571
|
|
|
518
572
|
/** Poll status or liveness for exactly the supplied route generation. */
|
|
@@ -607,7 +661,7 @@ export class SubcClient {
|
|
|
607
661
|
0n,
|
|
608
662
|
EMPTY_BODY,
|
|
609
663
|
);
|
|
610
|
-
const write = this.sock
|
|
664
|
+
const write = writeTrackedBorrowed(this.sock, encodeFrame(goodbye), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS);
|
|
611
665
|
if (!write.queued && closeOnQueueFailure) this.closeConnectionAfterCleanupFailure();
|
|
612
666
|
write.completed.catch(() => {
|
|
613
667
|
if (closeOnQueueFailure && !write.queued) this.closeConnectionAfterCleanupFailure();
|
|
@@ -679,7 +733,7 @@ export class SubcClient {
|
|
|
679
733
|
};
|
|
680
734
|
pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, channel, corr, ms), ms);
|
|
681
735
|
this.pending.set(key, pending);
|
|
682
|
-
this.sock
|
|
736
|
+
writeBorrowed(this.sock, encodeFrame(frame), Date.now() + ms).catch((error) => {
|
|
683
737
|
const current = this.pending.get(key);
|
|
684
738
|
if (current) this.rejectPending(key, current, error instanceof Error ? error : new SubcError(String(error)));
|
|
685
739
|
});
|
|
@@ -797,7 +851,7 @@ export class SubcClient {
|
|
|
797
851
|
};
|
|
798
852
|
pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, handle.channel, corr, ms), ms);
|
|
799
853
|
this.pending.set(key, pending);
|
|
800
|
-
const write = this.sock
|
|
854
|
+
const write = writeTrackedBorrowed(this.sock, encodeFrame(frame), Date.now() + ms);
|
|
801
855
|
handedToSocket = write.queued;
|
|
802
856
|
write.completed.catch((error) => {
|
|
803
857
|
const current = this.pending.get(key);
|
|
@@ -894,6 +948,98 @@ export class SubcClient {
|
|
|
894
948
|
}
|
|
895
949
|
}
|
|
896
950
|
|
|
951
|
+
// Stamped by dispatch() on every inbound frame; read by the liveness probe.
|
|
952
|
+
private lastInboundAtMs = 0;
|
|
953
|
+
// Single-flight guard: overlapping deadline-no-drop settles share one probe.
|
|
954
|
+
private livenessProbe: Promise<void> | null = null;
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Discriminate event-loop starvation from a half-open socket after a
|
|
958
|
+
* deadline-no-drop settle. The arbitration deliberately keeps the socket on
|
|
959
|
+
* that verdict (a late-read reply under load must not cost a healthy
|
|
960
|
+
* connection), but a half-open socket — peer vanished with no FIN/RST, as
|
|
961
|
+
* after host sleep/wake — yields the SAME verdict forever, so the keep
|
|
962
|
+
* becomes a permanent pin to a dead transport. The discriminator is
|
|
963
|
+
* socket-level liveness evidence: send a channel-0 Ping (the daemon always
|
|
964
|
+
* answers — subc-core control.rs) and require ANY inbound frame within the
|
|
965
|
+
* window; the Pong suffices, and a busy socket's other traffic proves the
|
|
966
|
+
* link just as well. Silence convicts: the socket is failed with a named
|
|
967
|
+
* cause and closed, so the ordinary drop path (pending rejection now,
|
|
968
|
+
* reconnect on the next call) takes over.
|
|
969
|
+
*
|
|
970
|
+
* Starvation cannot be falsely convicted by a short window: a loop starved
|
|
971
|
+
* enough to delay the read loop delays this probe's own timer identically,
|
|
972
|
+
* and when both wake the buffered Pong dispatches before the check runs.
|
|
973
|
+
*
|
|
974
|
+
* A PENDING CHANNEL-0 REQUEST SUSPENDS THE PROBE (both at launch and at the
|
|
975
|
+
* conviction check). The daemon's connection loop handles frames one at a
|
|
976
|
+
* time, and some channel-0 ops legally park it for seconds — route.open
|
|
977
|
+
* awaits the module's bind ack inline for up to route_bind_relay_timeout
|
|
978
|
+
* (~12s in production) — during which OUR Ping sits unread in the daemon's
|
|
979
|
+
* socket buffer. On an otherwise-quiet connection that silence is fully
|
|
980
|
+
* explained by our own in-flight control op, and convicting on it would
|
|
981
|
+
* tear down a healthy connection mid-bind (BROCA's Athena panel flagged
|
|
982
|
+
* this exact premise; the FIFO fact is verified in subc-core server.rs
|
|
983
|
+
* connection_loop + control.rs handle_route_open). The client always knows
|
|
984
|
+
* its own channel-0 pendings, so the gate is local and exact: no probe
|
|
985
|
+
* while one is in flight, no conviction if one appeared during the window.
|
|
986
|
+
*
|
|
987
|
+
* Scope: hooked from the managed-call path only. Control-plane requests
|
|
988
|
+
* (route.open, catalog.list) are short-lived and their failures already
|
|
989
|
+
* escalate through reconnect classification; the managed path is where a
|
|
990
|
+
* plugin lives for hours and where the pin was observed in production.
|
|
991
|
+
*/
|
|
992
|
+
/** Any in-flight channel-0 request (pendings keyed without a route handle). */
|
|
993
|
+
private hasControlPending(): boolean {
|
|
994
|
+
for (const pending of this.pending.values()) {
|
|
995
|
+
if (pending.handle === null) return true;
|
|
996
|
+
}
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
private probeLivenessAfterDeadline(): void {
|
|
1001
|
+
if (this.livenessProbe || this.closeStarted || this.closedErr) return;
|
|
1002
|
+
if (this.hasControlPending()) return; // silence would be self-explained; re-armed by the next settle
|
|
1003
|
+
const sock = this.sock;
|
|
1004
|
+
const generation = this.generation;
|
|
1005
|
+
let corr: bigint;
|
|
1006
|
+
try {
|
|
1007
|
+
corr = this.allocateCorr();
|
|
1008
|
+
} catch {
|
|
1009
|
+
return; // allocator exhausted: the next reconnect resets it
|
|
1010
|
+
}
|
|
1011
|
+
const t0 = Date.now();
|
|
1012
|
+
const ping = buildFrame(
|
|
1013
|
+
FrameType.Ping,
|
|
1014
|
+
buildFlags(false, Priority.Interactive, false, AdmissionClass.Normal),
|
|
1015
|
+
0,
|
|
1016
|
+
0,
|
|
1017
|
+
corr,
|
|
1018
|
+
new Uint8Array(),
|
|
1019
|
+
);
|
|
1020
|
+
const probe = (async (): Promise<void> => {
|
|
1021
|
+
// A failed WRITE is not exonerating: swallow it and let the window
|
|
1022
|
+
// check run — a socket that cannot carry a Ping will not produce an
|
|
1023
|
+
// inbound frame either, and conviction is the right verdict.
|
|
1024
|
+
await writeBorrowed(sock, encodeFrame(ping), t0 + this.opts.livenessProbeWindowMs).catch(() => {});
|
|
1025
|
+
await this.opts.sleep(this.opts.livenessProbeWindowMs);
|
|
1026
|
+
if (this.sock !== sock || this.generation !== generation || this.closeStarted) return;
|
|
1027
|
+
if (this.lastInboundAtMs >= t0) return; // link proven — keep the socket
|
|
1028
|
+
// A control op that started during the window explains the silence: the
|
|
1029
|
+
// daemon's read loop may be parked in its inline handler, not dead.
|
|
1030
|
+
if (this.hasControlPending()) return;
|
|
1031
|
+
this.fail(
|
|
1032
|
+
new SocketClosedError(
|
|
1033
|
+
`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`,
|
|
1034
|
+
),
|
|
1035
|
+
);
|
|
1036
|
+
sock.close();
|
|
1037
|
+
})().finally(() => {
|
|
1038
|
+
this.livenessProbe = null;
|
|
1039
|
+
});
|
|
1040
|
+
this.livenessProbe = probe;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
897
1043
|
private async ensureConnectedForManaged(): Promise<void> {
|
|
898
1044
|
if (this.closeStarted) throw new SubcError("client closed");
|
|
899
1045
|
if (this.reconnecting) await this.reconnecting;
|
|
@@ -1026,6 +1172,37 @@ export class SubcClient {
|
|
|
1026
1172
|
}
|
|
1027
1173
|
|
|
1028
1174
|
private dispatch(frame: Frame): void {
|
|
1175
|
+
// Every dispatched frame proves the socket delivers inbound bytes; the
|
|
1176
|
+
// post-deadline liveness probe reads this stamp. Stamped after readLoop's
|
|
1177
|
+
// generation gate, so a stale socket's late frames never vouch for the
|
|
1178
|
+
// live one.
|
|
1179
|
+
this.lastInboundAtMs = Date.now();
|
|
1180
|
+
if (frame.header.channel === 0 && frame.header.ty === FrameType.Push) {
|
|
1181
|
+
// Daemon-originated control push (route.closing / route.closed / future
|
|
1182
|
+
// ops). Never matches a pending (corr is daemon-chosen), never an error
|
|
1183
|
+
// path: unparseable bodies and absent observers both drop silently per
|
|
1184
|
+
// the wire contract's MUST-ignore clause, and an observer throw is
|
|
1185
|
+
// swallowed for the same reason as onProgress below -- a caller callback
|
|
1186
|
+
// must not fail the read loop.
|
|
1187
|
+
const observer = this.opts.onControlPush;
|
|
1188
|
+
if (observer) {
|
|
1189
|
+
let parsed: ControlPush | null = null;
|
|
1190
|
+
try {
|
|
1191
|
+
const body = this.parseJson(frame) as Record<string, unknown>;
|
|
1192
|
+
if (body && typeof body.op === "string") parsed = { op: body.op, body };
|
|
1193
|
+
} catch {
|
|
1194
|
+
// Unparseable control push: ignored by contract.
|
|
1195
|
+
}
|
|
1196
|
+
if (parsed) {
|
|
1197
|
+
try {
|
|
1198
|
+
observer(parsed);
|
|
1199
|
+
} catch {
|
|
1200
|
+
// Observer's own throw, on its own stack; the stream must survive.
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1029
1206
|
let handle: RouteHandle | null = null;
|
|
1030
1207
|
if (frame.header.channel !== 0) {
|
|
1031
1208
|
handle = this.liveRoutes.get(frame.header.channel) ?? null;
|
|
@@ -1039,11 +1216,32 @@ export class SubcClient {
|
|
|
1039
1216
|
const pending = this.pending.get(key);
|
|
1040
1217
|
if (pending) {
|
|
1041
1218
|
if (pending.acceptFrame && !pending.acceptFrame(frame)) return;
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1219
|
+
switch (frame.header.ty) {
|
|
1220
|
+
case FrameType.Push:
|
|
1221
|
+
case FrameType.StreamData:
|
|
1222
|
+
// A THROW FROM CALLER CODE MUST NOT REACH THE READ LOOP.
|
|
1223
|
+
//
|
|
1224
|
+
// `onProgress` (and `onEvent`, which is the same slot) is supplied by
|
|
1225
|
+
// the caller and runs INSIDE readLoop's frame dispatch. An escaping
|
|
1226
|
+
// throw unwinds into readLoop's catch, which treats it as a socket
|
|
1227
|
+
// failure: it calls fail(), rejecting EVERY in-flight request on this
|
|
1228
|
+
// connection -- other routes included -- with the caller's own error as
|
|
1229
|
+
// the reported cause, and stops reading. One consumer's bad event
|
|
1230
|
+
// handler therefore drops the whole connection, and the surfaced error
|
|
1231
|
+
// names the handler rather than the transport, so the failure reads as
|
|
1232
|
+
// a subc fault.
|
|
1233
|
+
//
|
|
1234
|
+
// Swallowing is deliberate and is NOT hiding a fault from the caller:
|
|
1235
|
+
// the throw happened in their own callback, on their own stack, where
|
|
1236
|
+
// they are the party able to catch it. What the client owes them is
|
|
1237
|
+
// that the stream and every sibling route keep working.
|
|
1238
|
+
try {
|
|
1239
|
+
pending.onProgress?.(frame.body);
|
|
1240
|
+
} catch {
|
|
1241
|
+
// Swallow the caller's own handler error so one bad callback cannot
|
|
1242
|
+
// abort the read loop or fail unrelated requests. See above.
|
|
1243
|
+
}
|
|
1244
|
+
return;
|
|
1047
1245
|
case FrameType.Response:
|
|
1048
1246
|
case FrameType.StreamEnd:
|
|
1049
1247
|
this.settle(key, pending, () => pending.resolve(frame));
|
|
@@ -1064,7 +1262,17 @@ export class SubcClient {
|
|
|
1064
1262
|
}
|
|
1065
1263
|
|
|
1066
1264
|
if (frame.header.ty === FrameType.Goodbye && handle) {
|
|
1067
|
-
|
|
1265
|
+
// Carries `route_closed` like the other two route-close sites (closeRoute,
|
|
1266
|
+
// late route.open). It was the only one without a code, so a consumer
|
|
1267
|
+
// branching on `route_closed` to recognise a gone route saw this one as an
|
|
1268
|
+
// uncoded generic failure and fell through to whatever its default was.
|
|
1269
|
+
//
|
|
1270
|
+
// THE CODE MUST NOT MAKE THIS RETRYABLE. A GOODBYE arriving mid-request
|
|
1271
|
+
// means the request was already forwarded and the module may have run it,
|
|
1272
|
+
// so it stays kind=outcome_unknown -- the same class as a mid-flight socket
|
|
1273
|
+
// drop, and never the not_sent/unknown_channel class that call() retries.
|
|
1274
|
+
// The code says WHICH route ended, not that it is safe to send again.
|
|
1275
|
+
this.failHandle(handle, new SubcError("route closed by subc (GOODBYE)", "route_closed"));
|
|
1068
1276
|
if (this.liveRoutes.get(handle.channel) === handle) this.liveRoutes.delete(handle.channel);
|
|
1069
1277
|
this.evictRouteHandle(handle);
|
|
1070
1278
|
return;
|
|
@@ -1202,11 +1410,12 @@ export class SubcClient {
|
|
|
1202
1410
|
}
|
|
1203
1411
|
|
|
1204
1412
|
private encode(value: unknown): Uint8Array {
|
|
1205
|
-
return
|
|
1413
|
+
return Buffer.from(JSON.stringify(value), "utf8");
|
|
1206
1414
|
}
|
|
1207
1415
|
|
|
1208
1416
|
private parseJson(frame: Frame): unknown {
|
|
1209
|
-
|
|
1417
|
+
const b = frame.body;
|
|
1418
|
+
return JSON.parse(Buffer.from(b.buffer, b.byteOffset, b.byteLength).toString("utf8"));
|
|
1210
1419
|
}
|
|
1211
1420
|
}
|
|
1212
1421
|
|
|
@@ -1246,6 +1455,7 @@ export function isRetryableRouteOpenCode(code: string | undefined): boolean {
|
|
|
1246
1455
|
return (
|
|
1247
1456
|
code === "unknown_module" ||
|
|
1248
1457
|
code === "module_reloading" ||
|
|
1458
|
+
code === "module_warming" ||
|
|
1249
1459
|
code === "target_unavailable" ||
|
|
1250
1460
|
code === "module_timeout"
|
|
1251
1461
|
);
|
|
@@ -1269,6 +1479,8 @@ function normalizeConnectOptions(opts: ConnectOptions): NormalizedConnectOptions
|
|
|
1269
1479
|
reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
|
|
1270
1480
|
sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
1271
1481
|
timeoutArbitrationGraceMs: opts.timeoutArbitrationGraceMs ?? TIMEOUT_ARBITRATION_GRACE_MS,
|
|
1482
|
+
livenessProbeWindowMs: opts.livenessProbeWindowMs ?? LIVENESS_PROBE_WINDOW_MS,
|
|
1483
|
+
onControlPush: opts.onControlPush,
|
|
1272
1484
|
};
|
|
1273
1485
|
}
|
|
1274
1486
|
|
package/src/connection-file.ts
CHANGED
|
@@ -31,9 +31,14 @@ export interface ConnectionInfo {
|
|
|
31
31
|
export class ConnectionFileError extends Error {}
|
|
32
32
|
|
|
33
33
|
function toBytes(value: unknown, field: string): Uint8Array {
|
|
34
|
-
if (!Array.isArray(value)
|
|
34
|
+
if (!Array.isArray(value)) {
|
|
35
35
|
throw new ConnectionFileError(`connection file field '${field}' must be a JSON array of bytes`);
|
|
36
36
|
}
|
|
37
|
+
for (const byte of value) {
|
|
38
|
+
if (typeof byte !== "number" || !Number.isInteger(byte) || byte < 0 || byte > 255) {
|
|
39
|
+
throw new ConnectionFileError(`connection file field '${field}' has invalid byte ${String(byte)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
37
42
|
return Uint8Array.from(value as number[]);
|
|
38
43
|
}
|
|
39
44
|
|
package/src/envelope.ts
CHANGED
|
@@ -122,6 +122,59 @@ export class DecodeError extends Error {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
function validateHeaderFields(header: EnvelopeHeader): void {
|
|
126
|
+
// Apply wire-width coercions in serialization order before semantic validation.
|
|
127
|
+
// This preserves encodeHeader's modulo behavior and its early conversion errors.
|
|
128
|
+
const len = header.len >>> 0;
|
|
129
|
+
const ver = (header.ver >>> 0) & 0xff;
|
|
130
|
+
const typeByte = (header.ty >>> 0) & 0xff;
|
|
131
|
+
const flags = (header.flags >>> 0) & 0xff;
|
|
132
|
+
const channel = (header.channel >>> 0) & 0xffff;
|
|
133
|
+
const epoch = header.epoch >>> 0;
|
|
134
|
+
void BigInt.asUintN(64, header.corr);
|
|
135
|
+
|
|
136
|
+
if (ver !== PROTOCOL_VERSION) throw new DecodeError(`unsupported envelope version ${ver}`, "unsupported_version");
|
|
137
|
+
if (typeByte > FRAME_TYPE_MAX) throw new DecodeError(`unknown frame type byte ${typeByte}`, "unknown_frame_type");
|
|
138
|
+
const ty = typeByte as FrameType;
|
|
139
|
+
if ((flags & FLAG_RESERVED_MASK) !== 0) {
|
|
140
|
+
throw new DecodeError(
|
|
141
|
+
`reserved flag bits set in flags 0b${flags.toString(2).padStart(8, "0")}`,
|
|
142
|
+
"reserved_flag_bits",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (((flags & FLAG_PRIORITY_MASK) >> FLAG_PRIORITY_SHIFT) === 0b11) {
|
|
146
|
+
throw new DecodeError(
|
|
147
|
+
`reserved priority bits set in flags 0b${flags.toString(2).padStart(8, "0")}`,
|
|
148
|
+
"reserved_priority_bits",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const admission = (flags & FLAG_ADMISSION_MASK) >> FLAG_ADMISSION_SHIFT;
|
|
152
|
+
if (admission === 0b11) {
|
|
153
|
+
throw new DecodeError(
|
|
154
|
+
`reserved admission class set in flags 0b${flags.toString(2).padStart(8, "0")}`,
|
|
155
|
+
"reserved_admission_class",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (admission === AdmissionClass.Sheddable && ty !== FrameType.Push && ty !== FrameType.StreamData) {
|
|
159
|
+
throw new DecodeError(
|
|
160
|
+
`SHEDDABLE admission class is illegal on ${FrameType[ty]} in flags 0b${flags.toString(2).padStart(8, "0")}`,
|
|
161
|
+
"sheddable_illegal_frame_type",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (channel === 0 && epoch !== 0) {
|
|
165
|
+
throw new DecodeError(
|
|
166
|
+
`control channel carried nonzero epoch ${epoch}`,
|
|
167
|
+
"nonzero_epoch_on_control_channel",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (isPureHeader(ty) && len !== 0) {
|
|
171
|
+
throw new DecodeError(
|
|
172
|
+
`pure-header frame ${FrameType[ty]} declared non-zero body length ${len}`,
|
|
173
|
+
"pure_header_frame_with_body",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
125
178
|
/** Decode and validate a header from the front of `bytes`. */
|
|
126
179
|
export function decodeHeader(bytes: Uint8Array): EnvelopeHeader {
|
|
127
180
|
if (bytes.length < FROZEN_PREFIX_LEN) {
|
|
@@ -210,7 +263,7 @@ export function buildFrameWithVersion(
|
|
|
210
263
|
throw new DecodeError(`frame body ${body.length} exceeds max ${MAX_FRAME_BODY_LEN}`, "frame_body_too_large");
|
|
211
264
|
}
|
|
212
265
|
const header = { len: body.length, ver, ty, flags, channel, epoch, corr };
|
|
213
|
-
|
|
266
|
+
validateHeaderFields(header);
|
|
214
267
|
return { header, body };
|
|
215
268
|
}
|
|
216
269
|
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export {
|
|
|
16
16
|
type RequestOptions,
|
|
17
17
|
type ManagedCallOptions,
|
|
18
18
|
type ConnectOptions,
|
|
19
|
+
type ControlPush,
|
|
19
20
|
type ReconnectBackoff,
|
|
20
21
|
type SubcCallErrorKind,
|
|
21
22
|
type SubscribeOptions,
|
|
@@ -70,19 +71,16 @@ export {
|
|
|
70
71
|
type BindDecision,
|
|
71
72
|
type Principal,
|
|
72
73
|
type BindingsInput,
|
|
73
|
-
type CircuitBreakerInput,
|
|
74
74
|
type Concurrency,
|
|
75
75
|
type ConsumerRoleInput,
|
|
76
76
|
type ExecutionMode,
|
|
77
77
|
type IdentityBindingInput,
|
|
78
78
|
type IdentityScope,
|
|
79
79
|
type InternalTransport,
|
|
80
|
-
type LeaseScope,
|
|
81
80
|
type ManagementOperationInput,
|
|
82
81
|
type ManagementOperationKind,
|
|
83
82
|
type ManagementSurfaceManifestOptions,
|
|
84
83
|
type ManifestInput,
|
|
85
|
-
type ModelPolicyInput,
|
|
86
84
|
type ModuleHelloAckBody,
|
|
87
85
|
type ObservabilityKind,
|
|
88
86
|
type ObservabilitySurfaceInput,
|
|
@@ -95,12 +93,10 @@ export {
|
|
|
95
93
|
type ProviderHealthHandler,
|
|
96
94
|
type ProviderRoleInput,
|
|
97
95
|
type RouteBindRequest,
|
|
98
|
-
type ScheduledTaskInput,
|
|
99
96
|
type StorageBindingInput,
|
|
100
97
|
type StorageKind,
|
|
101
98
|
type StorageScope,
|
|
102
99
|
type SubcProviderConnectOptions,
|
|
103
|
-
type TaskEligibilityInput,
|
|
104
100
|
type ToolInput,
|
|
105
101
|
type TrustTier,
|
|
106
102
|
type VaultGrantInput,
|