@cortexkit/subc-client 0.21.1 → 0.22.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/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
  }