pi-freeflow 1.40.0 → 1.40.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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.40.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Long streams are no longer cut at the five-minute ceiling mid-turn; a stalled relay is cut and re-fired instead.
8
+
9
+ Relay attempts carried a single deadline for the whole request. When a heavy reasoning turn ran past five minutes — big-pickle and long deepseek sessions routinely do — the deadline fired on live bytes, the proxy saw the dead stream as an upstream truncation, and silently re-fired the turn on another relay: on the screen the answer suddenly stopped and then continued as a fresh full generation, with the earlier partial content above it. The deadline now bounds only the header wait and is released the moment headers arrive, so a streaming body lives as long as bytes keep flowing — exactly like the other proxy paths that already cleared their timer on headers.
10
+
11
+ A body that stops flowing entirely is cut by a per-turn idle watchdog (120s of byte silence on a live socket is a wedged tunnel, not a thinking pause) and follows the existing one-refire truncation path, so a hung relay leg still hands the turn to a sibling instead of stalling until the host gives up.
12
+
3
13
  ## 1.40.0
4
14
 
5
15
  ### Minor Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.40.0",
4
+ "version": "1.40.1",
5
5
  "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
6
  "main": "extensions/index.ts",
7
7
  "types": "src/index.ts",
package/src/relay.ts CHANGED
@@ -31,8 +31,8 @@ const ROLL_NOTIFY_MS = 5 * 60 * 1_000;
31
31
  * per attempt. Relay leg only - the direct-to-upstream
32
32
  * fallback keeps the built-in global dispatcher. headersTimeout
33
33
  * reaps wedged tunnels, bodyTimeout 0 disables per-chunk kill
34
- * (the 300s total backstop still bounds a turn), connections 50
35
- * caps burst spray.
34
+ * (bodies stream unbounded — a wedged leg is cut by stream-pipe's
35
+ * body-stall watchdog instead), connections 50 caps burst spray.
36
36
  *
37
37
  * Compat: an npm undici Agent is rejected as a `dispatcher`
38
38
  * by an older-major global fetch bundled in Node
@@ -75,6 +75,18 @@ export function _setHedgeGraceForTest(ms: number): () => void {
75
75
  }
76
76
  /** Test-only: reset roll-notify throttle */
77
77
  export function _resetRollNotifyForTest(): void { lastRollNotify = 0; }
78
+ /********************************************************
79
+ * Relay attempt header-wait deadline (config default).
80
+ * Bounded only to the header wait; the streaming body must never be
81
+ * killed by a fixed wall-clock ceiling (see buildAttempt).
82
+ ********************************************************/
83
+ let attemptHeaderTimeoutMs = UPSTREAM_HEADER_TIMEOUT_MS;
84
+ /** Test-only: override the relay attempt header deadline; returns a restore function. */
85
+ export function _setAttemptHeaderTimeoutForTest(ms: number): () => void {
86
+ const prev = attemptHeaderTimeoutMs;
87
+ attemptHeaderTimeoutMs = ms;
88
+ return () => { attemptHeaderTimeoutMs = prev; };
89
+ }
78
90
 
79
91
  /**
80
92
  * Determine if an HTTP status code indicates a temporary relay or upstream error
@@ -263,12 +275,28 @@ export async function relayFetch(
263
275
  // window, combined with the caller's signal so either can fire.
264
276
  // A hung relay then trips only its own timeout (roll) instead of
265
277
  // vetoing the pool; a genuine client cancel (caller signal
266
- // aborted) still propagates to both hedged attempts.
267
- const attemptTimeout = AbortSignal.timeout(UPSTREAM_HEADER_TIMEOUT_MS);
278
+ // aborted) still propagates to both hedged attempts. The timer
279
+ // is disarmed the moment the fetch settles: the deadline bounds
280
+ // the header WAIT only, never the streaming body (an
281
+ // AbortSignal.timeout would keep hanging over the body and kill
282
+ // a healthy turn past five minutes mid-generation).
283
+ const headerDeadlineCtl = new AbortController();
284
+ let headerTimer: NodeJS.Timeout | undefined;
285
+ let headerDeadlineDisarmed = false;
286
+ const disarmHeaderTimeout = (): void => {
287
+ if (headerDeadlineDisarmed) return;
288
+ headerDeadlineDisarmed = true;
289
+ clearTimeout(headerTimer);
290
+ headerTimer = undefined;
291
+ };
292
+ headerTimer = setTimeout(() => {
293
+ disarmHeaderTimeout();
294
+ headerDeadlineCtl.abort();
295
+ }, attemptHeaderTimeoutMs);
268
296
  const attemptSignal = opts.signal
269
- ? AbortSignal.any([opts.signal, attemptTimeout, hedgeCtl.signal])
270
- : AbortSignal.any([attemptTimeout, hedgeCtl.signal]);
271
- return { headers: attemptHeaders, signal: attemptSignal };
297
+ ? AbortSignal.any([opts.signal, headerDeadlineCtl.signal, hedgeCtl.signal])
298
+ : AbortSignal.any([headerDeadlineCtl.signal, hedgeCtl.signal]);
299
+ return { headers: attemptHeaders, signal: attemptSignal, disarmHeaderTimeout };
272
300
  };
273
301
 
274
302
  // Hedged failover: an attempt with no response headers within
@@ -289,14 +317,26 @@ export async function relayFetch(
289
317
  if (!canHedge) {
290
318
  const soloCtl = new AbortController();
291
319
  const solo = buildAttempt(targetUrl, soloCtl);
292
- res = await fetch(targetUrl, { ...opts, headers: solo.headers, signal: solo.signal, ...relayLegInit() } as unknown as RequestInit);
320
+ try {
321
+ res = await fetch(targetUrl, { ...opts, headers: solo.headers, signal: solo.signal, ...relayLegInit() } as unknown as RequestInit);
322
+ } finally {
323
+ solo.disarmHeaderTimeout();
324
+ }
293
325
  } else {
294
326
  const primaryUrl = targetUrl;
295
327
  const nextUrl = candidates[i + 1];
296
328
  const nextCheck = validateRelayUrl(nextUrl);
297
329
  const hedgeCtlA = new AbortController();
298
330
  const first = buildAttempt(primaryUrl, hedgeCtlA);
299
- const fetchA = fetch(primaryUrl, { ...opts, headers: first.headers, signal: first.signal, ...relayLegInit() } as unknown as RequestInit);
331
+ // Disarm a settled leg's header deadline: once the fetch resolves
332
+ // with headers (or rejects) the attempt's abort window is closed,
333
+ // so a streaming body is never killed past the fixed ceiling.
334
+ // Wrapping here covers both the staged promise reader and the
335
+ // hedge race below.
336
+ const fetchA = fetch(primaryUrl, { ...opts, headers: first.headers, signal: first.signal, ...relayLegInit() } as unknown as RequestInit).then(
337
+ (r: Response) => { first.disarmHeaderTimeout(); return r; },
338
+ (e: unknown) => { first.disarmHeaderTimeout(); throw e; },
339
+ );
300
340
  const stalled = await new Promise<boolean>((resolve) => {
301
341
  const graceTimer = setTimeout(() => resolve(true), HEDGE_GRACE_MS);
302
342
  fetchA.then(
@@ -311,7 +351,10 @@ export async function relayFetch(
311
351
  const hedgeCtlB = new AbortController();
312
352
  const second = buildAttempt(nextUrl, hedgeCtlB);
313
353
  const hedgeStart = Date.now();
314
- const fetchB = fetch(nextUrl, { ...opts, headers: second.headers, signal: second.signal, ...relayLegInit() } as unknown as RequestInit);
354
+ const fetchB = fetch(nextUrl, { ...opts, headers: second.headers, signal: second.signal, ...relayLegInit() } as unknown as RequestInit).then(
355
+ (r: Response) => { second.disarmHeaderTimeout(); return r; },
356
+ (e: unknown) => { second.disarmHeaderTimeout(); throw e; },
357
+ );
315
358
  // Swallow the loser path so the deliberate abort never
316
359
  // surfaces as an unhandled rejection or a direct fallback.
317
360
  fetchA.catch(() => {});
@@ -671,7 +714,7 @@ export async function relayFetch(
671
714
  rateLimitOnly = false;
672
715
  const errMsg =
673
716
  (err as Error)?.name === "AbortError"
674
- ? `relay header timeout (${Math.round(UPSTREAM_HEADER_TIMEOUT_MS / 1000)}s exceeded)`
717
+ ? `relay header timeout (${Math.round(attemptHeaderTimeoutMs / 1000)}s exceeded)`
675
718
  : (err as Error)?.message || String(err);
676
719
  markRelayFailure(targetUrl, 0, errMsg);
677
720
  log(
@@ -27,6 +27,17 @@ import { repairToolArguments } from "./tool-args.ts";
27
27
  */
28
28
  export const SUBSTANTIAL_MIN_CHUNKS = 50;
29
29
  export const SUBSTANTIAL_MIN_BYTES = 100 * 1024;
30
+ // Body-stall cut for streamed upstream bodies (see pipeUpstreamStream).
31
+ // Generous: every healthy stream observed on this proxy re-arms it within a
32
+ // few hundred ms; a 120s byte-silence on an open socket is a wedged relay
33
+ // leg, not a building model.
34
+ let bodyStallCutMs = 120_000;
35
+ /** Test-only: override the body-stall cut; returns a restore function. */
36
+ export function _setBodyStallCutForTest(ms: number): () => void {
37
+ const prev = bodyStallCutMs;
38
+ bodyStallCutMs = ms;
39
+ return () => { bodyStallCutMs = prev; };
40
+ }
30
41
  // Bounded lookahead for a streaming response whose upstream omitted the
31
42
  // content-type: the pipe holds at most this many bytes while it waits for SSE
32
43
  // evidence, then classifies the body itself (raw passthrough on overflow —
@@ -993,6 +1004,46 @@ export function pipeUpstreamStream(
993
1004
  // One-shot resume guard: the refire hook below fires at most once per turn.
994
1005
  let refireUsed = false;
995
1006
 
1007
+ // Body-stall watchdog: after response headers a streaming body has no fixed
1008
+ // total deadline (See relay.ts: the per-attempt timer is headers-only).
1009
+ // The only failure left is a wedged leg that keeps the socket open
1010
+ // and sends nothing — protected by a per-stream idle cut well above every
1011
+ // observed healthy inter-chunk gap (thinking-heavy streams emit sub-second,
1012
+ // even the slowest logged TTFB is tens of seconds). On expiry the CURRENT
1013
+ // stream is destroyed with a relay-fault error so the truncation path
1014
+ // (health mark + one mid-stream refire) owns the turn. Every forwarded byte
1015
+ // re-arms it; end/close/error disarm it; a rescue re-arms the replacement.
1016
+ let stallCutTimer: NodeJS.Timeout | undefined;
1017
+ let stallCutStream: Readable | null = null;
1018
+ const fireStallCut = (target: Readable): void => {
1019
+ stallCutTimer = undefined;
1020
+ stallCutStream = null;
1021
+ if (target.destroyed || clientAborted || target !== nodeStream) return;
1022
+ const stallErr = new Error(
1023
+ `upstream body idle (no data for ${bodyStallCutMs}ms)`,
1024
+ ) as Error & { code?: string };
1025
+ stallErr.code = "FF_BODY_STALL";
1026
+ stallErr.name = "TimeoutError";
1027
+ target.destroy(stallErr);
1028
+ };
1029
+ const scheduleStallCut = (s: Readable): void => {
1030
+ clearTimeout(stallCutTimer);
1031
+ stallCutStream = s;
1032
+ const timer = setTimeout(() => fireStallCut(s), bodyStallCutMs);
1033
+ timer.unref?.();
1034
+ stallCutTimer = timer;
1035
+ };
1036
+ const breatheStallCut = (): void => {
1037
+ if (stallCutTimer !== undefined && stallCutStream === nodeStream) {
1038
+ scheduleStallCut(nodeStream);
1039
+ }
1040
+ };
1041
+ const disarmStallCut = (): void => {
1042
+ clearTimeout(stallCutTimer);
1043
+ stallCutTimer = undefined;
1044
+ stallCutStream = null;
1045
+ };
1046
+
996
1047
  const sniffThinking = (chunk: Buffer | string): boolean => {
997
1048
  const s =
998
1049
  typeof chunk === "string"
@@ -1287,6 +1338,7 @@ export function pipeUpstreamStream(
1287
1338
  sseDecoder = new StringDecoder("utf8");
1288
1339
  terminalScanCarry = Buffer.alloc(0);
1289
1340
  watch(next);
1341
+ scheduleStallCut(next);
1290
1342
  log("debug", "mid-stream refire: continuing turn on replacement stream", { totalChunks, totalBytes }, rid);
1291
1343
  return true;
1292
1344
  };
@@ -1341,6 +1393,7 @@ export function pipeUpstreamStream(
1341
1393
  };
1342
1394
  const onData = (chunk: Buffer | string): void => {
1343
1395
  try {
1396
+ breatheStallCut();
1344
1397
  // While a replacement stream is being fetched the cut stream is stale:
1345
1398
  // its late frames must not interleave with the rescued turn.
1346
1399
  if (refirePending) return;
@@ -1386,6 +1439,7 @@ export function pipeUpstreamStream(
1386
1439
  };
1387
1440
 
1388
1441
  const onError = async (e: unknown): Promise<void> => {
1442
+ disarmStallCut();
1389
1443
  // A rescue is already in flight (its outcome decides the turn): a late
1390
1444
  // error from the cut stream must not inject a terminal mid-rescue.
1391
1445
  if (refirePending) return;
@@ -1437,6 +1491,7 @@ export function pipeUpstreamStream(
1437
1491
  };
1438
1492
 
1439
1493
  const onEnd = async (): Promise<void> => {
1494
+ disarmStallCut();
1440
1495
  // A rescue is in flight (its outcome owns the turn): the cut stream's FIN
1441
1496
  // must neither inject a terminal nor end res mid-rescue.
1442
1497
  if (refirePending) return;
@@ -1542,6 +1597,7 @@ export function pipeUpstreamStream(
1542
1597
  };
1543
1598
 
1544
1599
  const onClose = async (): Promise<void> => {
1600
+ disarmStallCut();
1545
1601
  // A stalled `close` trailing an `end` that is mid-rescue must neither
1546
1602
  // inject a terminal nor end res — the replacement stream owns the turn now.
1547
1603
  if (refirePending) return;
@@ -1603,6 +1659,7 @@ export function pipeUpstreamStream(
1603
1659
  s.on("close", () => { if (s === nodeStream) void onClose(); });
1604
1660
  };
1605
1661
  watch(nodeStream);
1662
+ scheduleStallCut(nodeStream);
1606
1663
 
1607
1664
  req.on("aborted", () => {
1608
1665
  clientAborted = true;