@cortexkit/subc-client 0.2.1 → 0.3.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.
@@ -0,0 +1,45 @@
1
+ export declare class SocketClosedError extends Error {
2
+ }
3
+ export declare class SocketTimeoutError extends Error {
4
+ }
5
+ export declare class SocketWriteNotQueuedError extends Error {
6
+ readonly cause?: Error | undefined;
7
+ constructor(message: string, cause?: Error | undefined);
8
+ }
9
+ export declare class SocketWriteQueuedError extends Error {
10
+ readonly cause?: Error | undefined;
11
+ constructor(message: string, cause?: Error | undefined);
12
+ }
13
+ export interface SocketWriteResult {
14
+ /** True once bytes were handed to Node's net.Socket.write. This is not a delivery guarantee. */
15
+ queued: boolean;
16
+ /** Resolves when Node reports the write complete; rejects with a classified write error. */
17
+ completed: Promise<void>;
18
+ }
19
+ export declare class SubcSocket {
20
+ private readonly sock;
21
+ private chunks;
22
+ private buffered;
23
+ private waiter;
24
+ private closedErr;
25
+ /** Bytes currently buffered but not yet consumed by a reader. A timeout
26
+ * arbitration uses this to tell "a reply already arrived, keep draining" from
27
+ * "nothing is here, settle the timeout". */
28
+ bufferedBytes(): number;
29
+ private constructor();
30
+ /**
31
+ * The OS-assigned local TCP port of this connection, or null if not yet
32
+ * connected/closed. Used to correlate a client-side timeout with a specific
33
+ * socket in a packet capture when diagnosing reply-delivery issues.
34
+ */
35
+ localPort(): number | null;
36
+ static connect(host: string, port: number, deadlineMs: number): Promise<SubcSocket>;
37
+ /** Read exactly `n` bytes, rejecting if `deadlineMs` (epoch ms) passes first. */
38
+ readExact(n: number, deadlineMs: number): Promise<Uint8Array>;
39
+ write(bytes: Uint8Array, deadlineMs: number): Promise<void>;
40
+ writeTracked(bytes: Uint8Array, deadlineMs: number): SocketWriteResult;
41
+ close(): void;
42
+ private tryServe;
43
+ private take;
44
+ }
45
+ //# sourceMappingURL=socket.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"socket.d.ts","sourceRoot":"","sources":["../src/socket.ts"],"names":[],"mappings":"AAQA,qBAAa,iBAAkB,SAAQ,KAAK;CAAG;AAC/C,qBAAa,kBAAmB,SAAQ,KAAK;CAAG;AAEhD,qBAAa,yBAA0B,SAAQ,KAAK;IAGhD,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK;gBADtB,OAAO,EAAE,MAAM,EACN,KAAK,CAAC,EAAE,KAAK,YAAA;CAIzB;AAED,qBAAa,sBAAuB,SAAQ,KAAK;IAG7C,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK;gBADtB,OAAO,EAAE,MAAM,EACN,KAAK,CAAC,EAAE,KAAK,YAAA;CAIzB;AAED,MAAM,WAAW,iBAAiB;IAChC,gGAAgG;IAChG,MAAM,EAAE,OAAO,CAAC;IAChB,4FAA4F;IAC5F,SAAS,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AASD,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAa;IAClC,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,SAAS,CAAsB;IAEvC;;gDAE4C;IAC5C,aAAa,IAAI,MAAM;IAIvB,OAAO;IAgBP;;;;OAIG;IACH,SAAS,IAAI,MAAM,GAAG,IAAI;IAI1B,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAmBnF,iFAAiF;IACjF,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAyBvD,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAWjE,YAAY,CAAC,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,GAAG,iBAAiB;IA4EtE,KAAK,IAAI,IAAI;IAIb,OAAO,CAAC,QAAQ;IAiBhB,OAAO,CAAC,IAAI;CAmBb"}
package/dist/socket.js ADDED
@@ -0,0 +1,208 @@
1
+ // A pull-based buffered wrapper over a node TCP socket. Node sockets are
2
+ // event-driven; the handshake and frame loop want "give me exactly N bytes, but
3
+ // don't exceed this absolute deadline". readExact provides that, draining an
4
+ // internal buffer and parking a single waiter until enough bytes arrive, the
5
+ // deadline passes, or the socket ends.
6
+ import net from "node:net";
7
+ export class SocketClosedError extends Error {
8
+ }
9
+ export class SocketTimeoutError extends Error {
10
+ }
11
+ export class SocketWriteNotQueuedError extends Error {
12
+ cause;
13
+ constructor(message, cause) {
14
+ super(message);
15
+ this.cause = cause;
16
+ }
17
+ }
18
+ export class SocketWriteQueuedError extends Error {
19
+ cause;
20
+ constructor(message, cause) {
21
+ super(message);
22
+ this.cause = cause;
23
+ }
24
+ }
25
+ export class SubcSocket {
26
+ sock;
27
+ chunks = [];
28
+ buffered = 0;
29
+ waiter = null;
30
+ closedErr = null;
31
+ /** Bytes currently buffered but not yet consumed by a reader. A timeout
32
+ * arbitration uses this to tell "a reply already arrived, keep draining" from
33
+ * "nothing is here, settle the timeout". */
34
+ bufferedBytes() {
35
+ return this.buffered;
36
+ }
37
+ constructor(sock) {
38
+ this.sock = sock;
39
+ sock.on("data", (chunk) => {
40
+ this.chunks.push(chunk);
41
+ this.buffered += chunk.length;
42
+ this.tryServe();
43
+ });
44
+ const fail = (err) => {
45
+ if (!this.closedErr)
46
+ this.closedErr = err;
47
+ this.tryServe();
48
+ };
49
+ sock.on("error", (err) => fail(err instanceof Error ? err : new Error(String(err))));
50
+ sock.on("end", () => fail(new SocketClosedError("subc closed the connection")));
51
+ sock.on("close", () => fail(new SocketClosedError("subc connection closed")));
52
+ }
53
+ /**
54
+ * The OS-assigned local TCP port of this connection, or null if not yet
55
+ * connected/closed. Used to correlate a client-side timeout with a specific
56
+ * socket in a packet capture when diagnosing reply-delivery issues.
57
+ */
58
+ localPort() {
59
+ return this.sock.localPort ?? null;
60
+ }
61
+ static connect(host, port, deadlineMs) {
62
+ return new Promise((resolve, reject) => {
63
+ const sock = net.connect({ host, port });
64
+ sock.setNoDelay(true);
65
+ const timer = setTimeout(() => {
66
+ sock.destroy();
67
+ reject(new SocketTimeoutError(`timed out connecting to ${host}:${port}`));
68
+ }, Math.max(0, deadlineMs - Date.now()));
69
+ sock.once("connect", () => {
70
+ clearTimeout(timer);
71
+ resolve(new SubcSocket(sock));
72
+ });
73
+ sock.once("error", (err) => {
74
+ clearTimeout(timer);
75
+ reject(err);
76
+ });
77
+ });
78
+ }
79
+ /** Read exactly `n` bytes, rejecting if `deadlineMs` (epoch ms) passes first. */
80
+ readExact(n, deadlineMs) {
81
+ if (this.waiter) {
82
+ return Promise.reject(new Error("concurrent readExact is not supported"));
83
+ }
84
+ if (n === 0)
85
+ return Promise.resolve(new Uint8Array(0));
86
+ return new Promise((resolve, reject) => {
87
+ // A non-finite deadline means "wait indefinitely" (the background frame
88
+ // loop relies on this; per-request timeouts live on the request waiters).
89
+ let timer = null;
90
+ if (Number.isFinite(deadlineMs)) {
91
+ const remaining = deadlineMs - Date.now();
92
+ if (remaining <= 0) {
93
+ reject(new SocketTimeoutError(`timed out waiting for ${n} bytes`));
94
+ return;
95
+ }
96
+ timer = setTimeout(() => {
97
+ this.waiter = null;
98
+ reject(new SocketTimeoutError(`timed out waiting for ${n} bytes`));
99
+ }, remaining);
100
+ }
101
+ this.waiter = { need: n, resolve, reject, timer };
102
+ this.tryServe();
103
+ });
104
+ }
105
+ async write(bytes, deadlineMs) {
106
+ try {
107
+ await this.writeTracked(bytes, deadlineMs).completed;
108
+ }
109
+ catch (err) {
110
+ if (err instanceof SocketWriteNotQueuedError || err instanceof SocketWriteQueuedError) {
111
+ throw err.cause ?? err;
112
+ }
113
+ throw err;
114
+ }
115
+ }
116
+ writeTracked(bytes, deadlineMs) {
117
+ if (this.closedErr) {
118
+ return {
119
+ queued: false,
120
+ completed: Promise.reject(new SocketWriteNotQueuedError("subc socket was closed before bytes could be queued", this.closedErr)),
121
+ };
122
+ }
123
+ let queued = false;
124
+ let settled = false;
125
+ let timer = null;
126
+ const completed = new Promise((resolve, reject) => {
127
+ const settle = (run) => {
128
+ if (settled)
129
+ return;
130
+ settled = true;
131
+ if (timer)
132
+ clearTimeout(timer);
133
+ run();
134
+ };
135
+ const remaining = deadlineMs - Date.now();
136
+ if (remaining <= 0) {
137
+ settle(() => reject(new SocketWriteNotQueuedError("timed out before bytes could be queued to subc", new SocketTimeoutError("timed out writing to subc"))));
138
+ return;
139
+ }
140
+ timer = setTimeout(() => {
141
+ const timeout = new SocketTimeoutError("timed out writing to subc");
142
+ settle(() => reject(queued
143
+ ? new SocketWriteQueuedError("timed out after bytes were handed to the subc socket", timeout)
144
+ : new SocketWriteNotQueuedError("timed out before bytes could be queued to subc", timeout)));
145
+ }, remaining);
146
+ try {
147
+ this.sock.write(Buffer.from(bytes), (err) => {
148
+ settle(() => {
149
+ if (err) {
150
+ reject(new SocketWriteQueuedError("subc socket reported a write error after bytes were handed to the socket", err instanceof Error ? err : new Error(String(err))));
151
+ }
152
+ else {
153
+ resolve();
154
+ }
155
+ });
156
+ });
157
+ queued = true;
158
+ }
159
+ catch (err) {
160
+ settle(() => reject(new SocketWriteNotQueuedError("subc socket write threw before bytes could be queued", err instanceof Error ? err : new Error(String(err)))));
161
+ }
162
+ });
163
+ return { queued, completed };
164
+ }
165
+ close() {
166
+ this.sock.destroy();
167
+ }
168
+ tryServe() {
169
+ const w = this.waiter;
170
+ if (!w)
171
+ return;
172
+ if (this.buffered >= w.need) {
173
+ const out = this.take(w.need);
174
+ this.waiter = null;
175
+ if (w.timer)
176
+ clearTimeout(w.timer);
177
+ w.resolve(out);
178
+ return;
179
+ }
180
+ if (this.closedErr) {
181
+ this.waiter = null;
182
+ if (w.timer)
183
+ clearTimeout(w.timer);
184
+ w.reject(this.closedErr);
185
+ }
186
+ }
187
+ take(n) {
188
+ const out = Buffer.allocUnsafe(n);
189
+ let off = 0;
190
+ while (off < n) {
191
+ const head = this.chunks[0];
192
+ const want = n - off;
193
+ if (head.length <= want) {
194
+ head.copy(out, off);
195
+ off += head.length;
196
+ this.chunks.shift();
197
+ }
198
+ else {
199
+ head.copy(out, off, 0, want);
200
+ this.chunks[0] = head.subarray(want);
201
+ off += want;
202
+ }
203
+ }
204
+ this.buffered -= n;
205
+ return out;
206
+ }
207
+ }
208
+ //# sourceMappingURL=socket.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"socket.js","sourceRoot":"","sources":["../src/socket.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@cortexkit/subc-client",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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": {
7
7
  ".": {
8
- "types": "./src/index.ts",
9
- "import": "./src/index.ts"
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
10
  }
11
11
  },
12
12
  "files": [
13
+ "dist",
13
14
  "src",
14
15
  "README.md"
15
16
  ],
16
17
  "scripts": {
18
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
19
+ "prepublishOnly": "npm run build",
17
20
  "test": "bun test",
18
21
  "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit"
19
22
  },
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.rejectPending(key, pending, new SubcError(this.timeoutMessage(channel, corr, ms)));
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.rejectPending(key, pending, new SubcError(this.timeoutMessage(channel, corr, ms)));
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
- const header = decodeHeader(headerBytes);
795
- const body =
796
- header.len === 0
797
- ? new Uint8Array(0)
798
- : await sock.readExact(header.len, Date.now() + BODY_READ_TIMEOUT_MS);
799
- this.dispatch({ header, body });
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
- private settle(key: string, pending: Pending, run: () => void): void {
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) => {