@cortexkit/subc-client 0.21.0 → 0.21.2

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/src/provider.ts CHANGED
@@ -312,6 +312,12 @@ export interface SubcProviderConnectOptions {
312
312
  sleep?: (ms: number) => Promise<void>;
313
313
  /** Milliseconds to wait before emitting restored after the provider re-registers following a reconnect. */
314
314
  restoredDebounceMs?: number;
315
+ /**
316
+ * Deadline budget (ms) without progress for reading a frame body after its
317
+ * header arrives. Each incoming chunk pushes the deadline out again.
318
+ * Defaults to 30,000 ms.
319
+ */
320
+ bodyReadTimeoutMs?: number;
315
321
  /** Callback that receives ProviderConnectionState events one at a time and in order. */
316
322
  onConnectionState?: (event: ProviderConnectionState) => void | Promise<void>;
317
323
  /**
@@ -351,6 +357,7 @@ interface NormalizedSubcProviderConnectOptions {
351
357
  reconnectBackoff: ReconnectBackoff;
352
358
  sleep: (ms: number) => Promise<void>;
353
359
  restoredDebounceMs: number;
360
+ bodyReadTimeoutMs?: number;
354
361
  onConnectionState?: (event: ProviderConnectionState) => void | Promise<void>;
355
362
  /** The launch nonce to echo in HELLO, already resolved against the environment. */
356
363
  launchNonce?: string;
@@ -718,7 +725,9 @@ export class SubcProvider {
718
725
  const control: ControlPushState = { undecodablePushLogged: false, drainingHooks: new Set() };
719
726
  try {
720
727
  for (;;) {
721
- const frame = await sock.readFrame(Number.POSITIVE_INFINITY, { afterHeaderMs: BODY_READ_TIMEOUT_MS });
728
+ const frame = await sock.readFrame(Number.POSITIVE_INFINITY, {
729
+ afterHeaderMs: this.opts.bodyReadTimeoutMs ?? BODY_READ_TIMEOUT_MS,
730
+ });
722
731
  const keepGoing = await this.dispatch(frame, sock, generation, control);
723
732
  if (!keepGoing) {
724
733
  if (this.sock === sock && this.generation === generation) {
@@ -1493,6 +1502,7 @@ function normalizeProviderConnectOptions(opts: SubcProviderConnectOptions): Norm
1493
1502
  reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
1494
1503
  sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
1495
1504
  restoredDebounceMs: opts.restoredDebounceMs ?? DEFAULT_RESTORED_DEBOUNCE_MS,
1505
+ bodyReadTimeoutMs: opts.bodyReadTimeoutMs,
1496
1506
  onConnectionState: opts.onConnectionState,
1497
1507
  launchNonce: nonEmpty(opts.launchNonce ?? envLaunchNonce),
1498
1508
  reconnectOnDrop: opts.reconnectOnDrop ?? !supervised,
package/src/socket.ts CHANGED
@@ -71,8 +71,18 @@ interface Waiter {
71
71
  resolve: (bytes: Uint8Array) => void;
72
72
  reject: (err: Error) => void;
73
73
  timer: ReturnType<typeof setTimeout> | null;
74
+ immediate: ReturnType<typeof setImmediate> | null;
75
+ budget: number;
76
+ /**
77
+ * True for an idle budget (time without progress), which arriving bytes
78
+ * extend. False for an absolute deadline, such as the handshake's, which is
79
+ * a fixed bound: a peer that trickles bytes must not be able to stretch it.
80
+ */
81
+ extendOnProgress: boolean;
74
82
  }
75
83
 
84
+ export type ReadDeadline = number | { idleTimeoutMs: number };
85
+
76
86
  export class SubcSocket {
77
87
  private readonly sock: net.Socket;
78
88
  private chunks: Buffer[] = [];
@@ -92,6 +102,17 @@ export class SubcSocket {
92
102
  sock.on("data", (chunk: Buffer) => {
93
103
  this.chunks.push(chunk);
94
104
  this.buffered += chunk.length;
105
+ if (this.waiter) {
106
+ if (this.waiter.immediate) {
107
+ clearImmediate(this.waiter.immediate);
108
+ this.waiter.immediate = null;
109
+ }
110
+ if (this.waiter.extendOnProgress && this.buffered < this.waiter.need) {
111
+ // Progress was made on the pending read: push the deadline out
112
+ // by the budget again so a slow but moving stream does not time out.
113
+ this.armWaiterTimer(this.waiter);
114
+ }
115
+ }
95
116
  this.tryServe();
96
117
  });
97
118
  const fail = (err: Error) => {
@@ -163,34 +184,107 @@ export class SubcSocket {
163
184
  throw new DecodeError(`frame body ${header.len} exceeds max ${MAX_FRAME_BODY_LEN}`, "frame_body_too_large");
164
185
  }
165
186
  onHeader?.();
166
- const bodyDeadlineMs =
167
- typeof bodyDeadline === "number" ? bodyDeadline : Date.now() + bodyDeadline.afterHeaderMs;
168
- const body = header.len === 0 ? new Uint8Array(0) : await this.readExact(header.len, bodyDeadlineMs);
187
+ const bodyDeadlineArg =
188
+ typeof bodyDeadline === "number" ? bodyDeadline : { idleTimeoutMs: bodyDeadline.afterHeaderMs };
189
+ const body = header.len === 0 ? new Uint8Array(0) : await this.readExact(header.len, bodyDeadlineArg);
169
190
  return { header, body };
170
191
  }
171
192
 
172
- /** Read exactly `n` bytes, rejecting if `deadlineMs` (epoch ms) passes first. */
173
- readExact(n: number, deadlineMs: number): Promise<Uint8Array> {
193
+ /**
194
+ * Arm or reset the idle timeout timer for a pending waiter.
195
+ *
196
+ * In Node and Bun, expired timers run BEFORE the event loop polls for I/O.
197
+ * If the host process is blocked by CPU-bound work or a busy thread, the
198
+ * deadline timer may expire while bytes from the peer are already waiting in
199
+ * the OS kernel socket receive buffer. When the event loop resumes, the
200
+ * timer fires first, before the socket's 'data' event has had a chance to
201
+ * read those bytes from the kernel.
202
+ *
203
+ * A timer alone therefore cannot distinguish between "the peer never sent
204
+ * data" and "data arrived but has not yet been delivered across the event
205
+ * loop stall". We give pending socket data a chance to be delivered by
206
+ * deferring the timeout check past the I/O poll phase via setImmediate.
207
+ * (In Node, setImmediate runs after the poll phase; in Bun, a second
208
+ * setImmediate ensures the poll phase has run if the first ran before it).
209
+ * We only reject if the read is still unsatisfied and no bytes arrived
210
+ * meanwhile.
211
+ */
212
+ private armWaiterTimer(w: Waiter): void {
213
+ if (!Number.isFinite(w.budget)) return;
214
+ if (w.timer) {
215
+ clearTimeout(w.timer);
216
+ w.timer = null;
217
+ }
218
+ w.timer = setTimeout(() => {
219
+ w.timer = null;
220
+ const bufferedAtTimeout = this.buffered;
221
+ const verifyTimeout = () => {
222
+ if (this.waiter !== w) return;
223
+ if (w.extendOnProgress && this.buffered > bufferedAtTimeout) {
224
+ // Progress was made: bytes arrived after the timer fired.
225
+ // Push the deadline out by the budget again.
226
+ this.armWaiterTimer(w);
227
+ return;
228
+ }
229
+ this.waiter = null;
230
+ w.reject(new SocketTimeoutError(`timed out waiting for ${w.need} bytes`));
231
+ };
232
+
233
+ w.immediate = setImmediate(() => {
234
+ if (this.waiter !== w) return;
235
+ if (this.buffered > bufferedAtTimeout) {
236
+ verifyTimeout();
237
+ } else {
238
+ w.immediate = setImmediate(() => {
239
+ w.immediate = null;
240
+ verifyTimeout();
241
+ });
242
+ }
243
+ });
244
+ }, w.budget);
245
+ }
246
+
247
+ /**
248
+ * Read exactly `n` bytes, rejecting if `deadline` passes without progress.
249
+ * `deadline` is either an absolute epoch-ms deadline or `{ idleTimeoutMs }`
250
+ * specifying the maximum allowed time without progress (bytes arriving).
251
+ */
252
+ readExact(n: number, deadline: ReadDeadline): Promise<Uint8Array> {
174
253
  if (this.waiter) {
175
254
  return Promise.reject(new Error("concurrent readExact is not supported"));
176
255
  }
177
256
  if (n === 0) return Promise.resolve(new Uint8Array(0));
178
257
  return new Promise<Uint8Array>((resolve, reject) => {
179
- // A non-finite deadline means "wait indefinitely" (the background frame
180
- // loop relies on this; per-request timeouts live on the request waiters).
181
- let timer: ReturnType<typeof setTimeout> | null = null;
182
- if (Number.isFinite(deadlineMs)) {
183
- const remaining = deadlineMs - Date.now();
184
- if (remaining <= 0) {
258
+ let budget: number;
259
+ const extendOnProgress = typeof deadline === "object";
260
+ if (typeof deadline === "object") {
261
+ budget = deadline.idleTimeoutMs;
262
+ if (budget <= 0) {
185
263
  reject(new SocketTimeoutError(`timed out waiting for ${n} bytes`));
186
264
  return;
187
265
  }
188
- timer = setTimeout(() => {
189
- this.waiter = null;
266
+ } else if (Number.isFinite(deadline)) {
267
+ const remaining = deadline - Date.now();
268
+ if (remaining <= 0) {
190
269
  reject(new SocketTimeoutError(`timed out waiting for ${n} bytes`));
191
- }, remaining);
270
+ return;
271
+ }
272
+ budget = remaining;
273
+ } else {
274
+ budget = Number.POSITIVE_INFINITY;
192
275
  }
193
- this.waiter = { need: n, resolve, reject, timer };
276
+
277
+ const waiter: Waiter = {
278
+ need: n,
279
+ resolve,
280
+ reject,
281
+ timer: null,
282
+ immediate: null,
283
+ budget,
284
+ extendOnProgress,
285
+ };
286
+ this.waiter = waiter;
287
+ this.armWaiterTimer(waiter);
194
288
  this.tryServe();
195
289
  });
196
290
  }
@@ -309,12 +403,14 @@ export class SubcSocket {
309
403
  const out = this.take(w.need);
310
404
  this.waiter = null;
311
405
  if (w.timer) clearTimeout(w.timer);
406
+ if (w.immediate) clearImmediate(w.immediate);
312
407
  w.resolve(out);
313
408
  return;
314
409
  }
315
410
  if (this.closedErr) {
316
411
  this.waiter = null;
317
412
  if (w.timer) clearTimeout(w.timer);
413
+ if (w.immediate) clearImmediate(w.immediate);
318
414
  w.reject(this.closedErr);
319
415
  }
320
416
  }