@cortexkit/subc-client 0.2.1 → 0.3.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/package.json +1 -1
- package/src/client.ts +202 -11
- package/src/socket.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/subc-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|
package/src/client.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// correlation id, not arrival order.
|
|
11
11
|
|
|
12
12
|
import { promises as fs } from "node:fs";
|
|
13
|
+
import { debuglog } from "node:util";
|
|
13
14
|
|
|
14
15
|
import { AuthError, authenticateClient } from "./auth.js";
|
|
15
16
|
import { ConnectionFileError, readConnectionFile, type ConnectionInfo } from "./connection-file.js";
|
|
@@ -31,8 +32,38 @@ import {
|
|
|
31
32
|
SubcSocket,
|
|
32
33
|
} from "./socket.js";
|
|
33
34
|
|
|
35
|
+
const debug = debuglog("subc-client");
|
|
36
|
+
|
|
34
37
|
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
35
38
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
39
|
+
// When a request-timeout timer fires, its reply may already be sitting in the
|
|
40
|
+
// socket read buffer, unprocessed only because the event loop was starved (Node
|
|
41
|
+
// runs the TIMERS phase before the POLL phase, so an expired timer can beat an
|
|
42
|
+
// already-arrived frame). Rather than settle as a timeout immediately, arbitrate:
|
|
43
|
+
// yield one check-phase turn (setImmediate) so a fully-buffered reply dispatches
|
|
44
|
+
// and wins, and — only while the reader is actively draining the same socket —
|
|
45
|
+
// allow a small hard-capped grace for a reply whose header/body spans more than
|
|
46
|
+
// one loop turn. This is a demux tiebreak for a reply that RACED the deadline,
|
|
47
|
+
// NOT a deadline extension: an absent reply still settles right after the check
|
|
48
|
+
// phase. Capped so it can never approach BODY_READ_TIMEOUT_MS.
|
|
49
|
+
const TIMEOUT_ARBITRATION_GRACE_MS = 50;
|
|
50
|
+
// Internal marker set as the `code` on the SubcError a request-deadline timeout
|
|
51
|
+
// rejects with, so the managed classifier can tell a deadline (reply may simply
|
|
52
|
+
// not have been read in time) from an actual connection drop. Never surfaced to
|
|
53
|
+
// callers directly — it is refined into DEADLINE_NO_DROP_CODE by classifyFailure.
|
|
54
|
+
const REQUEST_DEADLINE_MARKER = "request_deadline";
|
|
55
|
+
// The consumer-facing code for a managed call whose deadline elapsed while its
|
|
56
|
+
// bytes were queued to the local socket and NO connection drop / GOODBYE was
|
|
57
|
+
// observed. Distinct from "connection_dropped" so a caller can skip a
|
|
58
|
+
// was-it-even-sent recovery path — but still kind=outcome_unknown (never safe to
|
|
59
|
+
// retry: "queued to the local socket" is NOT proof the daemon received or ran it).
|
|
60
|
+
const DEADLINE_NO_DROP_CODE = "deadline_exceeded_no_drop_observed";
|
|
61
|
+
// A retryable route.open rejection (target booting / reloading / momentarily
|
|
62
|
+
// absent) is retried in-place against the same connection up to this deadline
|
|
63
|
+
// before it is surfaced as not_sent. Mirrors subc-client-rs
|
|
64
|
+
// DEFAULT_ROUTE_RETRY_DEADLINE so a target that is briefly unavailable at daemon
|
|
65
|
+
// restart recovers without a misleading terminal error.
|
|
66
|
+
const ROUTE_OPEN_RETRY_DEADLINE_MS = 10_000;
|
|
36
67
|
// Once a header arrives, its body must follow promptly; bound it so a truncated
|
|
37
68
|
// frame cannot wedge the read loop forever.
|
|
38
69
|
const BODY_READ_TIMEOUT_MS = 30_000;
|
|
@@ -194,6 +225,14 @@ export interface ConnectOptions {
|
|
|
194
225
|
reconnectBackoff?: ReconnectBackoff;
|
|
195
226
|
/** Injectable sleep for timer-free reconnect tests. */
|
|
196
227
|
sleep?: (ms: number) => Promise<void>;
|
|
228
|
+
/**
|
|
229
|
+
* Hard cap on the timeout-arbitration grace window (see
|
|
230
|
+
* TIMEOUT_ARBITRATION_GRACE_MS). A reply whose bytes are actively arriving when
|
|
231
|
+
* the request deadline fires is given up to this long to finish dispatching
|
|
232
|
+
* before the call settles as a timeout. Bounded and never a deadline extension;
|
|
233
|
+
* exposed mainly so tests can prove the arbitration deterministically.
|
|
234
|
+
*/
|
|
235
|
+
timeoutArbitrationGraceMs?: number;
|
|
197
236
|
}
|
|
198
237
|
|
|
199
238
|
interface NormalizedConnectOptions {
|
|
@@ -203,6 +242,7 @@ interface NormalizedConnectOptions {
|
|
|
203
242
|
targetKind: ManagedRouteKind;
|
|
204
243
|
reconnectBackoff: ReconnectBackoff;
|
|
205
244
|
sleep: (ms: number) => Promise<void>;
|
|
245
|
+
timeoutArbitrationGraceMs: number;
|
|
206
246
|
}
|
|
207
247
|
|
|
208
248
|
interface OpenedConnection {
|
|
@@ -238,6 +278,11 @@ export class SubcClient {
|
|
|
238
278
|
private closeStarted = false;
|
|
239
279
|
private reconnecting: Promise<void> | null = null;
|
|
240
280
|
private generation = 1;
|
|
281
|
+
// True while the read loop is actively reading/dispatching a frame off the
|
|
282
|
+
// current socket (between reading a header and finishing its dispatch). The
|
|
283
|
+
// timeout arbitration reads it to decide whether a just-fired timeout should
|
|
284
|
+
// grant a reply mid-arrival a small grace window before settling.
|
|
285
|
+
private readerActive = false;
|
|
241
286
|
|
|
242
287
|
private constructor(
|
|
243
288
|
private sock: SubcSocket,
|
|
@@ -319,7 +364,11 @@ export class SubcClient {
|
|
|
319
364
|
}
|
|
320
365
|
continue;
|
|
321
366
|
}
|
|
322
|
-
if (err.kind === "outcome_unknown") {
|
|
367
|
+
if (err.kind === "outcome_unknown" && err.code !== DEADLINE_NO_DROP_CODE) {
|
|
368
|
+
// A real drop schedules a reconnect. A deadline-with-no-drop does NOT:
|
|
369
|
+
// the socket was never observed to fail (the reply was likely just read
|
|
370
|
+
// late under load), so tearing it down would abandon a healthy connection
|
|
371
|
+
// and its other in-flight routes for nothing.
|
|
323
372
|
this.scheduleReconnectAfterDrop(err);
|
|
324
373
|
}
|
|
325
374
|
throw err;
|
|
@@ -521,7 +570,7 @@ export class SubcClient {
|
|
|
521
570
|
timer: null,
|
|
522
571
|
};
|
|
523
572
|
pending.timer = setTimeout(() => {
|
|
524
|
-
this.
|
|
573
|
+
this.arbitrateTimeout(key, pending, channel, corr, ms);
|
|
525
574
|
}, ms);
|
|
526
575
|
this.pending.set(key, pending);
|
|
527
576
|
this.sock.write(encodeFrame(frame), Date.now() + ms).catch((err) => {
|
|
@@ -531,6 +580,42 @@ export class SubcClient {
|
|
|
531
580
|
});
|
|
532
581
|
}
|
|
533
582
|
|
|
583
|
+
/**
|
|
584
|
+
* A request-deadline timer has fired. Before settling as a timeout, arbitrate
|
|
585
|
+
* the timer-vs-poll race: a reply may already be in the socket buffer, unread
|
|
586
|
+
* only because the loop was starved. Yield one check phase (setImmediate) so a
|
|
587
|
+
* fully-buffered reply dispatches and wins via settle()'s identity guard; then,
|
|
588
|
+
* only while the reader is actively draining THIS socket (a frame mid-arrival),
|
|
589
|
+
* grant a single hard-capped grace before finally settling. Absent replies
|
|
590
|
+
* still settle right after the check phase. The settle carries the deadline
|
|
591
|
+
* marker so the managed classifier reports deadline-not-drop.
|
|
592
|
+
*/
|
|
593
|
+
private arbitrateTimeout(key: string, pending: Pending, channel: number, corr: bigint, ms: number): void {
|
|
594
|
+
const settleAsTimeout = (): void => {
|
|
595
|
+
this.rejectPending(
|
|
596
|
+
key,
|
|
597
|
+
pending,
|
|
598
|
+
new SubcError(this.timeoutMessage(channel, corr, ms), REQUEST_DEADLINE_MARKER),
|
|
599
|
+
);
|
|
600
|
+
};
|
|
601
|
+
const graceDeadline = Date.now() + this.opts.timeoutArbitrationGraceMs;
|
|
602
|
+
const arbitrate = (): void => {
|
|
603
|
+
// Already settled (by dispatch, fail, GOODBYE, or close)? Nothing to do.
|
|
604
|
+
if (this.pending.get(key) !== pending) return;
|
|
605
|
+
// A reply is mid-arrival on this socket (or bytes are buffered), and we are
|
|
606
|
+
// still inside the grace window: give the reader another turn to finish
|
|
607
|
+
// dispatching it. The generation guard in readLoop keeps this scoped to the
|
|
608
|
+
// live socket; the grace cap keeps it from approaching the body-read timeout.
|
|
609
|
+
const readerDraining = this.readerActive || this.sock.bufferedBytes() > 0;
|
|
610
|
+
if (readerDraining && Date.now() < graceDeadline) {
|
|
611
|
+
setImmediate(arbitrate);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
settleAsTimeout();
|
|
615
|
+
};
|
|
616
|
+
setImmediate(arbitrate);
|
|
617
|
+
}
|
|
618
|
+
|
|
534
619
|
private async managedRequest(
|
|
535
620
|
routeChannel: number,
|
|
536
621
|
body: unknown,
|
|
@@ -572,6 +657,18 @@ export class SubcClient {
|
|
|
572
657
|
if (!handedToSocket) {
|
|
573
658
|
return this.notSentCallError("request bytes were not queued to the subc socket", err);
|
|
574
659
|
}
|
|
660
|
+
// A request-deadline timeout (arbitration expired without observing a drop)
|
|
661
|
+
// is refined from a real connection drop: the socket was NOT seen to fail, so
|
|
662
|
+
// the caller can skip a was-it-even-sent recovery path. Still outcome_unknown
|
|
663
|
+
// — queued-to-local-socket is not proof the daemon received or ran it.
|
|
664
|
+
if (err instanceof SubcError && err.code === REQUEST_DEADLINE_MARKER) {
|
|
665
|
+
return new SubcCallError(
|
|
666
|
+
"outcome_unknown",
|
|
667
|
+
`managed call deadline exceeded after request bytes were queued to the local socket; no terminal response was observed; outcome unknown${causeMessage(err)}`,
|
|
668
|
+
DEADLINE_NO_DROP_CODE,
|
|
669
|
+
err,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
575
672
|
return this.outcomeUnknownCallError("connection dropped before the managed call returned a response", err);
|
|
576
673
|
};
|
|
577
674
|
|
|
@@ -586,7 +683,7 @@ export class SubcClient {
|
|
|
586
683
|
classifyFailure,
|
|
587
684
|
};
|
|
588
685
|
pending.timer = setTimeout(() => {
|
|
589
|
-
this.
|
|
686
|
+
this.arbitrateTimeout(key, pending, channel, corr, ms);
|
|
590
687
|
}, ms);
|
|
591
688
|
this.pending.set(key, pending);
|
|
592
689
|
|
|
@@ -642,6 +739,9 @@ export class SubcClient {
|
|
|
642
739
|
}
|
|
643
740
|
|
|
644
741
|
private async openCachedRoute(cached: CachedRoute): Promise<number> {
|
|
742
|
+
const routeRetryDeadline = Date.now() + ROUTE_OPEN_RETRY_DEADLINE_MS;
|
|
743
|
+
let routeRetryDelay = this.opts.reconnectBackoff.baseMs;
|
|
744
|
+
let routeRetryAttempt = 0;
|
|
645
745
|
for (;;) {
|
|
646
746
|
if (cached.closed) throw this.routeClosedDuringOpen();
|
|
647
747
|
try {
|
|
@@ -679,6 +779,29 @@ export class SubcClient {
|
|
|
679
779
|
}
|
|
680
780
|
continue;
|
|
681
781
|
}
|
|
782
|
+
// A daemon-rejected route.open with a RETRYABLE code (target booting /
|
|
783
|
+
// reloading / momentarily absent) is retried IN-PLACE against the same live
|
|
784
|
+
// connection — never a socket reconnect, which would needlessly disrupt this
|
|
785
|
+
// connection's other routes — until the route-retry deadline. Past the
|
|
786
|
+
// deadline it surfaces as not_sent: provably pre-send (no data frame ever
|
|
787
|
+
// left the client) AND still transient, so the caller's own retry policy may
|
|
788
|
+
// safely re-attempt later. Reason and set kept in parity with subc-client-rs.
|
|
789
|
+
if (!this.closeStarted && err instanceof SubcError && isRetryableRouteOpenCode(err.code)) {
|
|
790
|
+
routeRetryAttempt += 1;
|
|
791
|
+
if (routeRetryAttempt < this.opts.reconnectBackoff.maxAttempts && Date.now() < routeRetryDeadline) {
|
|
792
|
+
await this.opts.sleep(routeRetryDelay);
|
|
793
|
+
routeRetryDelay = Math.min(routeRetryDelay * 2, this.opts.reconnectBackoff.capMs);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
throw this.notSentCallError(
|
|
797
|
+
`route.open failed for module ${cached.moduleId}: ${err.code} (retry budget exhausted)`,
|
|
798
|
+
err,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
// A permanent route.open rejection (bad_consumer_identity, config_divergence,
|
|
802
|
+
// unknown_target, ...) is pre-send but would never succeed on retry, so it
|
|
803
|
+
// stays terminal — a not_sent class here would invite a retry storm against a
|
|
804
|
+
// request the daemon will always reject.
|
|
682
805
|
throw this.terminalCallError(`route.open failed for module ${cached.moduleId}`, err);
|
|
683
806
|
}
|
|
684
807
|
}
|
|
@@ -789,14 +912,29 @@ export class SubcClient {
|
|
|
789
912
|
private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
|
|
790
913
|
try {
|
|
791
914
|
for (;;) {
|
|
792
|
-
// Header read waits indefinitely — idle time between frames is normal
|
|
915
|
+
// Header read waits indefinitely — idle time between frames is normal, and
|
|
916
|
+
// the reader is NOT "active" while parked here (a racing timeout must not
|
|
917
|
+
// grant grace just because the connection is idle between frames).
|
|
793
918
|
const headerBytes = await sock.readExact(HEADER_LEN, Number.POSITIVE_INFINITY);
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
919
|
+
// A frame is now arriving. Mark the reader active THROUGH dispatch so a
|
|
920
|
+
// timeout that fires mid-arrival grants the reply its bounded grace window
|
|
921
|
+
// instead of settling as a spurious timeout.
|
|
922
|
+
this.readerActive = true;
|
|
923
|
+
try {
|
|
924
|
+
const header = decodeHeader(headerBytes);
|
|
925
|
+
const body =
|
|
926
|
+
header.len === 0
|
|
927
|
+
? new Uint8Array(0)
|
|
928
|
+
: await sock.readExact(header.len, Date.now() + BODY_READ_TIMEOUT_MS);
|
|
929
|
+
// Drop a frame read off a socket this client has already replaced
|
|
930
|
+
// (reconnect): its pendings were settled by fail(), and dispatching it
|
|
931
|
+
// against the current pending map could match a re-used (channel, corr).
|
|
932
|
+
if (this.sock === sock && this.generation === generation) {
|
|
933
|
+
this.dispatch({ header, body });
|
|
934
|
+
}
|
|
935
|
+
} finally {
|
|
936
|
+
this.readerActive = false;
|
|
937
|
+
}
|
|
800
938
|
}
|
|
801
939
|
} catch (err) {
|
|
802
940
|
if (this.sock === sock && this.generation === generation) {
|
|
@@ -829,15 +967,46 @@ export class SubcClient {
|
|
|
829
967
|
this.failChannel(frame.header.channel, new SubcError("route closed by subc (GOODBYE)"));
|
|
830
968
|
return;
|
|
831
969
|
}
|
|
970
|
+
// A terminal frame (Response/Error/StreamEnd) with no waiter is almost always
|
|
971
|
+
// a reply that arrived AFTER its request already settled — the fingerprint of a
|
|
972
|
+
// premature timeout under event-loop starvation (the reply raced the deadline
|
|
973
|
+
// and lost). Metadata-only debug log (never the body) so every future
|
|
974
|
+
// occurrence is a one-line diagnosis instead of an invisible drop. Enable with
|
|
975
|
+
// NODE_DEBUG=subc-client.
|
|
976
|
+
if (
|
|
977
|
+
frame.header.ty === FrameType.Response ||
|
|
978
|
+
frame.header.ty === FrameType.Error ||
|
|
979
|
+
frame.header.ty === FrameType.StreamEnd
|
|
980
|
+
) {
|
|
981
|
+
debug(
|
|
982
|
+
"dropped terminal frame with no waiter: type=%d channel=%d corr=%s port=%s",
|
|
983
|
+
frame.header.ty,
|
|
984
|
+
frame.header.channel,
|
|
985
|
+
frame.header.corr,
|
|
986
|
+
this.sock.localPort() ?? "?",
|
|
987
|
+
);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
832
990
|
// Unmatched Push or stray frame: no registered waiter. Drop it — v1 has no
|
|
833
991
|
// unsolicited-push consumers.
|
|
834
992
|
}
|
|
835
993
|
|
|
836
|
-
|
|
994
|
+
/**
|
|
995
|
+
* Settle a pending exactly once. The object-identity guard (the map still
|
|
996
|
+
* holds THIS pending under `key`) is the single-winner primitive: whichever of
|
|
997
|
+
* dispatch, a timeout, fail(), failChannel(), a GOODBYE, or a deferred timeout
|
|
998
|
+
* arbitration reaches it first wins, and every later caller no-ops. This is what
|
|
999
|
+
* makes the deferred-timeout arbitration safe — it cannot double-settle, reject
|
|
1000
|
+
* an already-resolved promise, or delete a pending re-created for a later corr.
|
|
1001
|
+
* Returns true when this call was the settler.
|
|
1002
|
+
*/
|
|
1003
|
+
private settle(key: string, pending: Pending, run: () => void): boolean {
|
|
1004
|
+
if (this.pending.get(key) !== pending) return false;
|
|
837
1005
|
this.pending.delete(key);
|
|
838
1006
|
if (pending.timer) clearTimeout(pending.timer);
|
|
839
1007
|
run();
|
|
840
1008
|
pending.onSettle?.();
|
|
1009
|
+
return true;
|
|
841
1010
|
}
|
|
842
1011
|
|
|
843
1012
|
private rejectPending(key: string, pending: Pending, err: Error): void {
|
|
@@ -909,6 +1078,27 @@ export function isConsumerReconnectTransient(err: unknown): boolean {
|
|
|
909
1078
|
return code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EPIPE" || code === "ETIMEDOUT" || code === "ENOENT";
|
|
910
1079
|
}
|
|
911
1080
|
|
|
1081
|
+
/**
|
|
1082
|
+
* The closed set of route.open rejection codes that mean "the target is
|
|
1083
|
+
* momentarily unavailable but the request could succeed on retry" — the target
|
|
1084
|
+
* is booting, mid-reload, transiently absent, or the bind relay timed out. A
|
|
1085
|
+
* daemon-rejected route.open is provably pre-send (no data frame ever left the
|
|
1086
|
+
* client), so these classify as not_sent; the managed path retries them in-place
|
|
1087
|
+
* within ROUTE_OPEN_RETRY_DEADLINE_MS. Permanent rejections (bad_consumer_identity,
|
|
1088
|
+
* config_divergence, unknown_target, ...) are excluded — they are pre-send but
|
|
1089
|
+
* would never succeed, so retrying them would only storm the daemon. Kept
|
|
1090
|
+
* byte-identical to subc-client-rs is_retryable_route_open_code for cross-client
|
|
1091
|
+
* classification parity.
|
|
1092
|
+
*/
|
|
1093
|
+
export function isRetryableRouteOpenCode(code: string | undefined): boolean {
|
|
1094
|
+
return (
|
|
1095
|
+
code === "unknown_module" ||
|
|
1096
|
+
code === "module_reloading" ||
|
|
1097
|
+
code === "target_unavailable" ||
|
|
1098
|
+
code === "module_timeout"
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
912
1102
|
export async function connectionFileExists(path: string): Promise<boolean> {
|
|
913
1103
|
try {
|
|
914
1104
|
await fs.access(path);
|
|
@@ -926,6 +1116,7 @@ function normalizeConnectOptions(opts: ConnectOptions): NormalizedConnectOptions
|
|
|
926
1116
|
targetKind: opts.targetKind ?? DEFAULT_MANAGED_TARGET_KIND,
|
|
927
1117
|
reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
|
|
928
1118
|
sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
1119
|
+
timeoutArbitrationGraceMs: opts.timeoutArbitrationGraceMs ?? TIMEOUT_ARBITRATION_GRACE_MS,
|
|
929
1120
|
};
|
|
930
1121
|
}
|
|
931
1122
|
|
package/src/socket.ts
CHANGED
|
@@ -48,6 +48,13 @@ export class SubcSocket {
|
|
|
48
48
|
private waiter: Waiter | null = null;
|
|
49
49
|
private closedErr: Error | null = null;
|
|
50
50
|
|
|
51
|
+
/** Bytes currently buffered but not yet consumed by a reader. A timeout
|
|
52
|
+
* arbitration uses this to tell "a reply already arrived, keep draining" from
|
|
53
|
+
* "nothing is here, settle the timeout". */
|
|
54
|
+
bufferedBytes(): number {
|
|
55
|
+
return this.buffered;
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
private constructor(sock: net.Socket) {
|
|
52
59
|
this.sock = sock;
|
|
53
60
|
sock.on("data", (chunk: Buffer) => {
|