dsh-router-core 0.3.18 → 0.3.19

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/lib/index.js CHANGED
@@ -1235,29 +1235,43 @@ function tapStreamUsage(source, startedAt) {
1235
1235
  outputChars += frameText(obj).length;
1236
1236
  }
1237
1237
  };
1238
+ /** 源 reader:cancel 时要靠它把取消传下去(见下方 cancel 分支)。 */
1239
+ let reader;
1238
1240
  return {
1239
- stream: new ReadableStream({ start: async (ctrl) => {
1240
- const reader = source.getReader();
1241
- try {
1242
- for (;;) {
1243
- const { done, value } = await reader.read();
1244
- if (done) break;
1245
- if (firstAt === 0) firstAt = Date.now();
1246
- buf += dec.decode(value, { stream: true });
1247
- let sep;
1248
- while ((sep = buf.indexOf("\n\n")) >= 0) {
1249
- const frame = buf.slice(0, sep + 2);
1250
- buf = buf.slice(sep + 2);
1251
- parseFrame(frame);
1241
+ stream: new ReadableStream({
1242
+ start: async (ctrl) => {
1243
+ reader = source.getReader();
1244
+ try {
1245
+ for (;;) {
1246
+ const { done, value } = await reader.read();
1247
+ if (done) break;
1248
+ if (firstAt === 0) firstAt = Date.now();
1249
+ buf += dec.decode(value, { stream: true });
1250
+ let sep;
1251
+ while ((sep = buf.indexOf("\n\n")) >= 0) {
1252
+ const frame = buf.slice(0, sep + 2);
1253
+ buf = buf.slice(sep + 2);
1254
+ parseFrame(frame);
1255
+ }
1256
+ ctrl.enqueue(value);
1252
1257
  }
1253
- ctrl.enqueue(value);
1258
+ if (buf !== "") parseFrame(buf);
1259
+ } catch {} finally {
1260
+ try {
1261
+ reader.releaseLock();
1262
+ } catch {}
1254
1263
  }
1255
- if (buf !== "") parseFrame(buf);
1256
- } finally {
1257
- reader.releaseLock();
1258
- }
1259
- ctrl.close();
1260
- } }),
1264
+ try {
1265
+ ctrl.close();
1266
+ } catch {}
1267
+ },
1268
+ /**
1269
+ * 取消要往下传(同 normalizeSSEStream 的理由):不传 → 客户端断开时
1270
+ * 上游 fetch body 永不 cancel,连接与 in-flight 请求一直挂着。
1271
+ * 必须用 reader.cancel():流已被 getReader() 锁住,stream.cancel() 会抛。
1272
+ */
1273
+ cancel: (reason) => reader?.cancel(reason)
1274
+ }),
1261
1275
  done: async () => ({
1262
1276
  usage: merged,
1263
1277
  outputChars,
@@ -1399,59 +1413,65 @@ async function aggregateSSE(stream) {
1399
1413
  const calls = /* @__PURE__ */ new Map();
1400
1414
  const reader = stream.getReader();
1401
1415
  let buf = "";
1402
- for (;;) {
1403
- const { done, value } = await reader.read();
1404
- if (done) break;
1405
- buf += dec.decode(value, { stream: true });
1406
- let sep;
1407
- while ((sep = buf.indexOf("\n\n")) >= 0) {
1408
- const frame = buf.slice(0, sep);
1409
- buf = buf.slice(sep + 2);
1410
- for (const line of frame.split("\n")) {
1411
- if (!line.startsWith("data:")) continue;
1412
- const payload = line.slice(5).trim();
1413
- if (payload === "" || payload === "[DONE]") continue;
1414
- let obj;
1415
- try {
1416
- obj = JSON.parse(payload);
1417
- } catch {
1418
- continue;
1419
- }
1420
- if (typeof obj.id === "string" && obj.id !== "") id = obj.id;
1421
- if (typeof obj.model === "string" && obj.model !== "") model = obj.model;
1422
- if (typeof obj.created === "number") created = obj.created;
1423
- if (obj.usage !== void 0) usage = obj.usage;
1424
- const ch = obj.choices?.[0];
1425
- if (ch === void 0) continue;
1426
- if (typeof ch.finish_reason === "string") finish = ch.finish_reason;
1427
- const delta = ch.delta;
1428
- if (delta === void 0) {
1429
- const msg = ch.message;
1430
- if (msg !== void 0) {
1431
- if (typeof msg.content === "string") content += msg.content;
1432
- if (typeof msg.reasoning_content === "string") reasoning += msg.reasoning_content;
1416
+ try {
1417
+ for (;;) {
1418
+ const { done, value } = await reader.read();
1419
+ if (done) break;
1420
+ buf += dec.decode(value, { stream: true });
1421
+ let sep;
1422
+ while ((sep = buf.indexOf("\n\n")) >= 0) {
1423
+ const frame = buf.slice(0, sep);
1424
+ buf = buf.slice(sep + 2);
1425
+ for (const line of frame.split("\n")) {
1426
+ if (!line.startsWith("data:")) continue;
1427
+ const payload = line.slice(5).trim();
1428
+ if (payload === "" || payload === "[DONE]") continue;
1429
+ let obj;
1430
+ try {
1431
+ obj = JSON.parse(payload);
1432
+ } catch {
1433
+ continue;
1434
+ }
1435
+ if (typeof obj.id === "string" && obj.id !== "") id = obj.id;
1436
+ if (typeof obj.model === "string" && obj.model !== "") model = obj.model;
1437
+ if (typeof obj.created === "number") created = obj.created;
1438
+ if (obj.usage !== void 0) usage = obj.usage;
1439
+ const ch = obj.choices?.[0];
1440
+ if (ch === void 0) continue;
1441
+ if (typeof ch.finish_reason === "string") finish = ch.finish_reason;
1442
+ const delta = ch.delta;
1443
+ if (delta === void 0) {
1444
+ const msg = ch.message;
1445
+ if (msg !== void 0) {
1446
+ if (typeof msg.content === "string") content += msg.content;
1447
+ if (typeof msg.reasoning_content === "string") reasoning += msg.reasoning_content;
1448
+ }
1449
+ continue;
1450
+ }
1451
+ if (typeof delta.content === "string") content += delta.content;
1452
+ if (typeof delta.reasoning_content === "string") reasoning += delta.reasoning_content;
1453
+ const tcs = delta.tool_calls;
1454
+ if (!Array.isArray(tcs)) continue;
1455
+ for (const tc of tcs) {
1456
+ const idx = typeof tc.index === "number" ? tc.index : 0;
1457
+ const fn = tc.function;
1458
+ const prev = calls.get(idx) ?? {
1459
+ name: "",
1460
+ args: ""
1461
+ };
1462
+ if (typeof tc.id === "string" && tc.id !== "") prev.id = tc.id;
1463
+ if (typeof tc.type === "string") prev.type = tc.type;
1464
+ if (typeof fn?.name === "string" && fn.name !== "") prev.name = fn.name;
1465
+ if (typeof fn?.arguments === "string") prev.args += fn.arguments;
1466
+ calls.set(idx, prev);
1433
1467
  }
1434
- continue;
1435
- }
1436
- if (typeof delta.content === "string") content += delta.content;
1437
- if (typeof delta.reasoning_content === "string") reasoning += delta.reasoning_content;
1438
- const tcs = delta.tool_calls;
1439
- if (!Array.isArray(tcs)) continue;
1440
- for (const tc of tcs) {
1441
- const idx = typeof tc.index === "number" ? tc.index : 0;
1442
- const fn = tc.function;
1443
- const prev = calls.get(idx) ?? {
1444
- name: "",
1445
- args: ""
1446
- };
1447
- if (typeof tc.id === "string" && tc.id !== "") prev.id = tc.id;
1448
- if (typeof tc.type === "string") prev.type = tc.type;
1449
- if (typeof fn?.name === "string" && fn.name !== "") prev.name = fn.name;
1450
- if (typeof fn?.arguments === "string") prev.args += fn.arguments;
1451
- calls.set(idx, prev);
1452
1468
  }
1453
1469
  }
1454
1470
  }
1471
+ } finally {
1472
+ try {
1473
+ reader.releaseLock();
1474
+ } catch {}
1455
1475
  }
1456
1476
  const toolCalls = [...calls.entries()].sort((a, b) => a[0] - b[0]).filter(([, c]) => c.name !== "").map(([, c]) => ({
1457
1477
  id: c.id ?? `call_${c.name}`,
@@ -1505,31 +1525,47 @@ function normalizeSSEStream(stream) {
1505
1525
  const dec = new TextDecoder();
1506
1526
  let buf = "";
1507
1527
  let sawDone = false;
1528
+ /** 上游 reader:cancel 时要靠它把取消传下去(见下方 cancel 分支)。 */
1529
+ let reader;
1508
1530
  return {
1509
- stream: new ReadableStream({ start: async (ctrl) => {
1510
- const reader = stream.getReader();
1511
- try {
1512
- for (;;) {
1513
- const { done, value } = await reader.read();
1514
- if (done) break;
1515
- buf += dec.decode(value, { stream: true });
1516
- let sep;
1517
- while ((sep = buf.indexOf("\n\n")) >= 0) {
1518
- const frame = buf.slice(0, sep + 2);
1519
- buf = buf.slice(sep + 2);
1520
- if (isDoneFrame(frame)) sawDone = true;
1521
- ctrl.enqueue(enc.encode(fixFrame(frame)));
1531
+ stream: new ReadableStream({
1532
+ start: async (ctrl) => {
1533
+ reader = stream.getReader();
1534
+ try {
1535
+ for (;;) {
1536
+ const { done, value } = await reader.read();
1537
+ if (done) break;
1538
+ buf += dec.decode(value, { stream: true });
1539
+ let sep;
1540
+ while ((sep = buf.indexOf("\n\n")) >= 0) {
1541
+ const frame = buf.slice(0, sep + 2);
1542
+ buf = buf.slice(sep + 2);
1543
+ if (isDoneFrame(frame)) sawDone = true;
1544
+ ctrl.enqueue(enc.encode(fixFrame(frame)));
1545
+ }
1522
1546
  }
1547
+ if (buf !== "") {
1548
+ if (isDoneFrame(buf)) sawDone = true;
1549
+ ctrl.enqueue(enc.encode(fixFrame(buf)));
1550
+ }
1551
+ } catch {} finally {
1552
+ try {
1553
+ reader.releaseLock();
1554
+ } catch {}
1523
1555
  }
1524
- if (buf !== "") {
1525
- if (isDoneFrame(buf)) sawDone = true;
1526
- ctrl.enqueue(enc.encode(fixFrame(buf)));
1527
- }
1528
- } finally {
1529
- reader.releaseLock();
1530
- }
1531
- ctrl.close();
1532
- } }),
1556
+ try {
1557
+ ctrl.close();
1558
+ } catch {}
1559
+ },
1560
+ /**
1561
+ * **取消必须往下传**:ReadableStream 的取消不会自动穿过 wrapper。
1562
+ * 不实现这个分支,客户端断开的取消就停在这里,上游 fetch body 永不 cancel
1563
+ * ——那条 TCP 连接和 in-flight 请求原样挂着(长跑一天就会累积到耗尽)。
1564
+ * 用 reader.cancel() 而不是 stream.cancel():流已被 getReader() 锁住,
1565
+ * 直接 cancel 会抛 `Invalid state: ReadableStream is locked`。
1566
+ */
1567
+ cancel: (reason) => reader?.cancel(reason)
1568
+ }),
1533
1569
  sawDone: () => sawDone
1534
1570
  };
1535
1571
  }
@@ -1599,6 +1635,7 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1599
1635
  try {
1600
1636
  body = await aggregateSSE(r.stream);
1601
1637
  } catch {
1638
+ await r.stream.cancel().catch(() => {});
1602
1639
  return false;
1603
1640
  }
1604
1641
  if (probe !== void 0) probe.tokens = usageFromJsonBody(body);
@@ -1613,6 +1650,18 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1613
1650
  let wroteAny = false;
1614
1651
  /** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
1615
1652
  let dead = false;
1653
+ /**
1654
+ * 唤醒「等 drain 的挂起 write」。客户端断开时必须调它:那时 'drain' 永不到来,
1655
+ * 不唤醒就永远卡在 write 里,pipeTo 不 settle,finally 不执行,
1656
+ * **上游流也就永不 cancel**(= 每次客户端放弃漏一条连接)。
1657
+ */
1658
+ let releaseWait;
1659
+ /**
1660
+ * 中止 pipeTo 的信号源(客户端断开时 abort)。
1661
+ * 单靠 `dead` 标志只能让**下一次** write 立刻返回,唤醒不了**已经挂起**的那次;
1662
+ * abort 才能让 pipeTo 立刻收尾并走它的取消路径(进而 cancel 上游)。
1663
+ */
1664
+ const abort = new AbortController();
1616
1665
  const writeChunk = (chunk) => new Promise((resolve) => {
1617
1666
  if (dead) {
1618
1667
  resolve();
@@ -1635,6 +1684,11 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1635
1684
  resolve();
1636
1685
  };
1637
1686
  res.once("drain", onDrain);
1687
+ /** 让这个挂起的 write 立刻收尾(客户端断开时调用)。 */
1688
+ releaseWait = () => {
1689
+ res.removeListener("drain", onDrain);
1690
+ resolve();
1691
+ };
1638
1692
  }
1639
1693
  });
1640
1694
  const onError = () => {
@@ -1642,13 +1696,28 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1642
1696
  res.destroy();
1643
1697
  };
1644
1698
  res.once("error", onError);
1699
+ /**
1700
+ * **客户端断开必须被当成取消信号**:只监听 'error' 是不够的 —— 对端关闭
1701
+ * (pi-ai 的流空闲超时、用户取消、消费方提前退出)触发的是 'close',
1702
+ * 不是 'error'。此时若不收手,pipeTo 会一直等着 drain(永不到来),
1703
+ * finally 永不执行,上游 fetch body **永不 cancel** → 那条 TCP 连接和
1704
+ * in-flight 请求原样挂着,长跑累积到耗尽("用久了就报 connection error")。
1705
+ */
1706
+ const onClose = () => {
1707
+ if (res.writableEnded) return;
1708
+ dead = true;
1709
+ releaseWait?.();
1710
+ abort.abort();
1711
+ };
1712
+ res.once("close", onClose);
1645
1713
  const norm = normalizeSSEStream(tapped?.stream ?? r.stream);
1646
1714
  try {
1647
- await norm.stream.pipeTo(new WritableStream({ write: writeChunk }));
1715
+ await norm.stream.pipeTo(new WritableStream({ write: writeChunk }), { signal: abort.signal });
1648
1716
  } catch {} finally {
1649
1717
  res.removeListener("error", onError);
1718
+ res.removeListener("close", onClose);
1650
1719
  if (wroteAny && !norm.sawDone() && !dead && !res.writableEnded && !res.destroyed) await writeChunk(DONE_FRAME);
1651
- if (wroteAny) res.end();
1720
+ if (wroteAny && !dead) res.end();
1652
1721
  if (probe !== void 0 && tapped !== void 0) {
1653
1722
  const got = await tapped.done();
1654
1723
  probe.tokens = got.usage;
@@ -2315,6 +2384,16 @@ var Router = class {
2315
2384
  */
2316
2385
  /** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
2317
2386
  const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
2387
+ /**
2388
+ * 流空闲超时:上游连上后这么久没吐**任何**字节就判失败(抛 TIMEOUT,可重试)。
2389
+ *
2390
+ * 对齐 `dsh-llm-pi-ai` 的 `DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000`:同一个
2391
+ * dsh-router `/v1`,走自定义供应商(pi-ai)时有这个兜底、走内置 adapter 时没有
2392
+ * —— 差异导致「上游停摆」在内置路径上永久挂住。
2393
+ *
2394
+ * 只守空闲、不封总时长:长生成只要持续吐块就不该被打断。
2395
+ */
2396
+ const STREAM_IDLE_TIMEOUT_MS = 3e5;
2318
2397
  async function wireMessages(options, system, attachments) {
2319
2398
  const out = [];
2320
2399
  /**
@@ -2756,11 +2835,18 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
2756
2835
  baseURL;
2757
2836
  source;
2758
2837
  resolveAttachments;
2759
- constructor(baseURL, source, resolveAttachments) {
2838
+ /** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
2839
+ idleTimeoutMs;
2840
+ /**
2841
+ * @param idleTimeoutMs 流空闲超时;缺省用生产值(300s,对齐 pi-ai)。
2842
+ * 可注入只为测试能压到几百毫秒,生产不传。
2843
+ */
2844
+ constructor(baseURL, source, resolveAttachments, idleTimeoutMs = STREAM_IDLE_TIMEOUT_MS) {
2760
2845
  super();
2761
2846
  this.baseURL = baseURL;
2762
2847
  this.source = source;
2763
2848
  this.resolveAttachments = resolveAttachments ?? (() => void 0);
2849
+ this.idleTimeoutMs = idleTimeoutMs;
2764
2850
  }
2765
2851
  providerInfo(provider) {
2766
2852
  return {
@@ -2824,16 +2910,19 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
2824
2910
  signal: controller.signal
2825
2911
  });
2826
2912
  } catch (error) {
2913
+ options.signal?.removeEventListener("abort", onAbort);
2827
2914
  throw new LlmError(`dsh-router upstream call failed: ${error.message}`, "TRANSPORT", { cause: error });
2915
+ }
2916
+ try {
2917
+ if (!resp.ok) {
2918
+ const detail = await resp.text().catch(() => "");
2919
+ throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, "TRANSPORT");
2920
+ }
2921
+ if (resp.body === null) return;
2922
+ yield* translateSse(ssePayloads(resp.body, this.idleTimeoutMs));
2828
2923
  } finally {
2829
2924
  options.signal?.removeEventListener("abort", onAbort);
2830
2925
  }
2831
- if (!resp.ok) {
2832
- const detail = await resp.text().catch(() => "");
2833
- throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, "TRANSPORT");
2834
- }
2835
- if (resp.body === null) return;
2836
- yield* translateSse(ssePayloads(resp.body));
2837
2926
  }
2838
2927
  };
2839
2928
  /**
@@ -2861,16 +2950,41 @@ function looksComplete(payload) {
2861
2950
  * 跨块边界:一个 `data:` 行可能横跨两次 read(),所以留 `buffer` 接住
2862
2951
  * 最后一段未完整的行(9router 的 pipeTransformedSSE 同样处理)。
2863
2952
  */
2864
- async function* ssePayloads(body) {
2953
+ async function* ssePayloads(body, idleTimeoutMs) {
2865
2954
  const reader = body.getReader();
2866
2955
  const decoder = new TextDecoder("utf-8", { fatal: false });
2867
2956
  let buffer = "";
2868
2957
  /** 攒一帧内的多行 data(SSE 规范:多行用 \n 连起来)。**必须**是局部
2869
2958
  * 状态 —— 放模块级会让并发请求互相串数据。 */
2870
2959
  let payload = "";
2960
+ /**
2961
+ * 带**空闲超时**的读:上游连上后若长时间一个字节都不吐,就读穿不了 ——
2962
+ * 没有超时的话这里会永久挂着,连接和 in-flight 请求都收不回。
2963
+ *
2964
+ * 为什么必须有(issue #6 的另一半):`dsh-llm-pi-ai`(自定义供应商走的那条
2965
+ * openai-completions adapter)有 `idleWatchdog`,默认 **300s** 空闲即
2966
+ * `LLM_STREAM_IDLE_TIMEOUT` 中止;而本 adapter 此前**没有任何超时**,只靠
2967
+ * 调用方的 `options.signal`。于是同一个组合,走内置 Router 时上游一停摆就
2968
+ * 永久挂住(占用连接、且该请求永不收尾),走自定义供应商时 300s 后被放弃
2969
+ * —— 放弃又会触发服务端那条「客户端断开不回收」的泄漏路径,两边叠加就是
2970
+ * 「用久了报 connection error」。
2971
+ *
2972
+ * 只守**空闲**、不设总时长:长生成可以跑很久,只要还在吐块就不该被打断
2973
+ * (总时长封顶会让长回答写到一半被砍)。
2974
+ */
2975
+ const readWithIdleTimeout = () => new Promise((resolve, reject) => {
2976
+ const timer = setTimeout(() => reject(new LlmError(`dsh-router upstream stream idle for ${idleTimeoutMs}ms`, "TIMEOUT")), idleTimeoutMs);
2977
+ reader.read().then((r) => {
2978
+ clearTimeout(timer);
2979
+ resolve(r);
2980
+ }, (e) => {
2981
+ clearTimeout(timer);
2982
+ reject(e);
2983
+ });
2984
+ });
2871
2985
  try {
2872
2986
  for (;;) {
2873
- const { done, value } = await reader.read();
2987
+ const { done, value } = await readWithIdleTimeout();
2874
2988
  if (done) break;
2875
2989
  buffer += decoder.decode(value, { stream: true });
2876
2990
  const lines = buffer.split("\n");
@@ -58,7 +58,13 @@ export declare class RouterAdapter extends LlmAdapter {
58
58
  private readonly baseURL;
59
59
  private readonly source;
60
60
  private readonly resolveAttachments;
61
- constructor(baseURL: string, source: RouterAdapterSource, resolveAttachments?: () => RouterAttachmentStore | undefined);
61
+ /** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
62
+ private readonly idleTimeoutMs;
63
+ /**
64
+ * @param idleTimeoutMs 流空闲超时;缺省用生产值(300s,对齐 pi-ai)。
65
+ * 可注入只为测试能压到几百毫秒,生产不传。
66
+ */
67
+ constructor(baseURL: string, source: RouterAdapterSource, resolveAttachments?: () => RouterAttachmentStore | undefined, idleTimeoutMs?: number);
62
68
  providerInfo(provider: string): {
63
69
  id: string;
64
70
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.3.18",
3
+ "version": "0.3.19",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -15,6 +15,13 @@ import { EMPTY_RESPONSE_CODE } from '@deepseek-ai/dsh-llm'
15
15
  import { toTokenUsage } from '../router/usage-tokens.ts'
16
16
  import { RouterAdapter, translateSse } from './adapter.ts'
17
17
 
18
+ /**
19
+ * 本文件大量用例把 `globalThis.fetch` 打桩成假上游,且**从不还原**(沿既有惯例:
20
+ * 每个用例自己设桩)。所以「要打真实网络」的用例必须自己把真 fetch 放回去 ——
21
+ * 否则会静默拿到上一个用例残留的桩(曾导致「停摆测试 0ms 就 ended」的假结果)。
22
+ */
23
+ const realFetch: typeof fetch = globalThis.fetch
24
+
18
25
  /**
19
26
  * dsh-llm 默认可重试的错误码白名单(DEFAULT_RETRYABLE_CODES)。
20
27
  *
@@ -727,3 +734,78 @@ test('wire:纯文本 tool-result 不因图片逻辑改变顺序(tool 紧跟
727
734
  assertToolPairingIntact(messages)
728
735
  assert.deepEqual(messages.map((m) => m.role), ['user', 'assistant', 'tool'], '纯文本 tool 结果仍紧跟 assistant')
729
736
  })
737
+
738
+ /* ---------------- 流空闲超时:上游停摆不能永久挂住(issue #6) ---------------- */
739
+
740
+ /**
741
+ * 为什么要有这一组:同一个 dsh-router `/v1`,走**自定义供应商**(llm-pi-ai 的
742
+ * openai-completions adapter)时有 `idleWatchdog`(默认 300s)兜底,走**内置
743
+ * Router adapter** 时此前没有任何超时 —— 上游连上后一停摆就永久挂着,连接和
744
+ * in-flight 请求都收不回。这是 issue #6「内置 vs 自定义」差异的一半。
745
+ *
746
+ * **必须用真实 http server**:合成 `ReadableStream` 上 `AbortSignal` 是无效的
747
+ * (实测合成流 + AbortSignal.timeout 永不收尾),拿它测只能得到假结论。
748
+ */
749
+ test('流空闲超时:上游停摆 → adapter 自己中止(不依赖调用方给 signal)', async () => {
750
+ const http = await import('node:http')
751
+ const server = http.createServer((_req, res) => {
752
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' })
753
+ res.flushHeaders() // 一个字节都不吐,也不结束
754
+ })
755
+ await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()))
756
+ const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`
757
+ globalThis.fetch = realFetch // 本文件前面的用例会留桩,必须装回真 fetch
758
+ try {
759
+ // 用可注入的短超时(默认 300s,测起来太慢);不注入就退回生产值。
760
+ const adapter = new RouterAdapter(base, { comboModels: async () => [] }, undefined, 400)
761
+ const started = Date.now()
762
+ const consume = (async () => {
763
+ for await (const _c of adapter.stream({ model: 'm', messages: [] } as never)) void _c
764
+ })()
765
+ const outcome = await Promise.race([
766
+ consume.then(() => 'ended', (e: Error) => `threw:${(e as Error & { code?: string }).code ?? e.message}`),
767
+ new Promise<string>((r) => setTimeout(() => r('HANG'), 4000)),
768
+ ])
769
+ const ms = Date.now() - started
770
+ console.log(`[gate] 无调用方 signal,停摆 → ${outcome} @${ms}ms`)
771
+ assert.notEqual(outcome, 'HANG', 'adapter 必须自带空闲超时,不能永久挂住')
772
+ assert.ok(String(outcome).includes('TIMEOUT'), `应抛 TIMEOUT(可重试码),实际 ${outcome}`)
773
+ } finally {
774
+ server.closeAllConnections?.()
775
+ server.close()
776
+ }
777
+ })
778
+
779
+ test('调用方取消必须能贯穿整条流(响应头到手后仍可中断,不永久挂住)', async () => {
780
+ // 用**真实 http server**:合成 ReadableStream 会给出假象(abort 对非 undici 流
781
+ // 无效)。这里上游发出响应头 + 一块数据后停摆,调用方给 1s 超时。
782
+ // 修复前:adapter 在 fetch 返回时就 removeEventListener,body 读无人打断 →
783
+ // 请求永久挂住(实测 6s 仍未收尾)。修复后:1s 左右被中止。
784
+ const http = await import('node:http')
785
+ const server = http.createServer((_req, res) => {
786
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' })
787
+ res.write('data: {"choices":[{"delta":{"content":"a"}}]}\n\n')
788
+ res.flushHeaders() // 之后停摆:不吐数据也不结束
789
+ })
790
+ await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()))
791
+ const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`
792
+ globalThis.fetch = realFetch // 同上:必须先装回真 fetch
793
+ try {
794
+ const adapter = new RouterAdapter(base, { comboModels: async () => [] })
795
+ const t0 = Date.now()
796
+ const consume = (async () => {
797
+ for await (const _c of adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(1000) } as never)) void _c
798
+ })()
799
+ const outcome = await Promise.race([
800
+ consume.then(() => 'ended', (e: Error) => `threw:${e.message}`),
801
+ new Promise((r) => setTimeout(() => r('HANG'), 5000)),
802
+ ])
803
+ const ms = Date.now() - t0
804
+ console.log(`[gate] 停摆 + 1s 超时 → ${outcome} @${ms}ms`)
805
+ assert.notEqual(outcome, 'HANG', '调用方取消后必须收尾,不能永久挂住')
806
+ assert.ok(ms < 4000, `应在调用方超时附近收尾(实际 ${ms}ms)`)
807
+ } finally {
808
+ server.closeAllConnections?.()
809
+ server.close()
810
+ }
811
+ })
@@ -81,6 +81,17 @@ export interface RouterAdapterSource {
81
81
  /** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
82
82
  const TOOL_RESULT_IMAGE_TEXT = 'Tool result images'
83
83
 
84
+ /**
85
+ * 流空闲超时:上游连上后这么久没吐**任何**字节就判失败(抛 TIMEOUT,可重试)。
86
+ *
87
+ * 对齐 `dsh-llm-pi-ai` 的 `DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000`:同一个
88
+ * dsh-router `/v1`,走自定义供应商(pi-ai)时有这个兜底、走内置 adapter 时没有
89
+ * —— 差异导致「上游停摆」在内置路径上永久挂住。
90
+ *
91
+ * 只守空闲、不封总时长:长生成只要持续吐块就不该被打断。
92
+ */
93
+ const STREAM_IDLE_TIMEOUT_MS = 300_000
94
+
84
95
  async function wireMessages(options: GenerateOptions, system: string | undefined, attachments: RouterAttachmentStore | undefined): Promise<Array<Record<string, unknown>>> {
85
96
  const out: Array<Record<string, unknown>> = []
86
97
  /**
@@ -530,12 +541,24 @@ export class RouterAdapter extends LlmAdapter {
530
541
  private readonly baseURL: string
531
542
  private readonly source: RouterAdapterSource
532
543
  private readonly resolveAttachments: () => RouterAttachmentStore | undefined
544
+ /** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
545
+ private readonly idleTimeoutMs: number
533
546
 
534
- constructor(baseURL: string, source: RouterAdapterSource, resolveAttachments?: () => RouterAttachmentStore | undefined) {
547
+ /**
548
+ * @param idleTimeoutMs 流空闲超时;缺省用生产值(300s,对齐 pi-ai)。
549
+ * 可注入只为测试能压到几百毫秒,生产不传。
550
+ */
551
+ constructor(
552
+ baseURL: string,
553
+ source: RouterAdapterSource,
554
+ resolveAttachments?: () => RouterAttachmentStore | undefined,
555
+ idleTimeoutMs: number = STREAM_IDLE_TIMEOUT_MS,
556
+ ) {
535
557
  super()
536
558
  this.baseURL = baseURL
537
559
  this.source = source
538
560
  this.resolveAttachments = resolveAttachments ?? (() => undefined)
561
+ this.idleTimeoutMs = idleTimeoutMs
539
562
  }
540
563
 
541
564
  providerInfo(provider: string): { id: string; name: string } {
@@ -602,16 +625,24 @@ export class RouterAdapter extends LlmAdapter {
602
625
  signal: controller.signal,
603
626
  })
604
627
  } catch (error) {
628
+ options.signal?.removeEventListener('abort', onAbort)
605
629
  throw new LlmError(`dsh-router upstream call failed: ${(error as Error).message}`, 'TRANSPORT', { cause: error })
630
+ }
631
+ // **监听必须活到整条流结束**,不能在 fetch 返回时就摘:`controller.signal`
632
+ // 已经把传入的 signal 与这次 fetch 绑在一起(含 body 流),响应头到手后
633
+ // body 还在读,此时调用方取消(用户中断 / agent-loop 收敛 / 上游停摆被
634
+ // 上层超时放弃)必须还能 abort 掉这次请求 —— 摘早了,body 读就无人打断,
635
+ // 请求永久挂住、连接也收不回(issue #6「内置供应商才挂死」的另一半)。
636
+ try {
637
+ if (!resp.ok) {
638
+ const detail = await resp.text().catch(() => '')
639
+ throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, 'TRANSPORT')
640
+ }
641
+ if (resp.body === null) return
642
+ yield* translateSse(ssePayloads(resp.body, this.idleTimeoutMs))
606
643
  } finally {
607
644
  options.signal?.removeEventListener('abort', onAbort)
608
645
  }
609
- if (!resp.ok) {
610
- const detail = await resp.text().catch(() => '')
611
- throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, 'TRANSPORT')
612
- }
613
- if (resp.body === null) return
614
- yield* translateSse(ssePayloads(resp.body))
615
646
  }
616
647
  }
617
648
 
@@ -641,16 +672,42 @@ function looksComplete(payload: string): boolean {
641
672
  * 跨块边界:一个 `data:` 行可能横跨两次 read(),所以留 `buffer` 接住
642
673
  * 最后一段未完整的行(9router 的 pipeTransformedSSE 同样处理)。
643
674
  */
644
- async function* ssePayloads(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
675
+ async function* ssePayloads(body: ReadableStream<Uint8Array>, idleTimeoutMs: number): AsyncGenerator<string> {
645
676
  const reader = body.getReader()
646
677
  const decoder = new TextDecoder('utf-8', { fatal: false })
647
678
  let buffer = ''
648
679
  /** 攒一帧内的多行 data(SSE 规范:多行用 \n 连起来)。**必须**是局部
649
680
  * 状态 —— 放模块级会让并发请求互相串数据。 */
650
681
  let payload = ''
682
+ /**
683
+ * 带**空闲超时**的读:上游连上后若长时间一个字节都不吐,就读穿不了 ——
684
+ * 没有超时的话这里会永久挂着,连接和 in-flight 请求都收不回。
685
+ *
686
+ * 为什么必须有(issue #6 的另一半):`dsh-llm-pi-ai`(自定义供应商走的那条
687
+ * openai-completions adapter)有 `idleWatchdog`,默认 **300s** 空闲即
688
+ * `LLM_STREAM_IDLE_TIMEOUT` 中止;而本 adapter 此前**没有任何超时**,只靠
689
+ * 调用方的 `options.signal`。于是同一个组合,走内置 Router 时上游一停摆就
690
+ * 永久挂住(占用连接、且该请求永不收尾),走自定义供应商时 300s 后被放弃
691
+ * —— 放弃又会触发服务端那条「客户端断开不回收」的泄漏路径,两边叠加就是
692
+ * 「用久了报 connection error」。
693
+ *
694
+ * 只守**空闲**、不设总时长:长生成可以跑很久,只要还在吐块就不该被打断
695
+ * (总时长封顶会让长回答写到一半被砍)。
696
+ */
697
+ const readWithIdleTimeout = (): Promise<ReadableStreamReadResult<Uint8Array>> =>
698
+ new Promise((resolve, reject) => {
699
+ const timer = setTimeout(
700
+ () => reject(new LlmError(`dsh-router upstream stream idle for ${idleTimeoutMs}ms`, 'TIMEOUT')),
701
+ idleTimeoutMs,
702
+ )
703
+ reader.read().then(
704
+ (r) => { clearTimeout(timer); resolve(r) },
705
+ (e) => { clearTimeout(timer); reject(e) },
706
+ )
707
+ })
651
708
  try {
652
709
  for (;;) {
653
- const { done, value } = await reader.read()
710
+ const { done, value } = await readWithIdleTimeout()
654
711
  if (done) break
655
712
  buffer += decoder.decode(value, { stream: true })
656
713
  const lines = buffer.split('\n')
@@ -0,0 +1,200 @@
1
+ /**
2
+ * 客户端断开的连接回收测试 —— issue #6「组合模型用久了报 connection error」的真根因。
3
+ *
4
+ * 现象:把 `http://127.0.0.1:3080/v1` 加为**自定义供应商**(llm-pi-ai 的
5
+ * openai-completions)后,连续用半天到一天就全面 connection error;内置 Router
6
+ * 供应商同一个组合却正常。
7
+ *
8
+ * 真根因(**不是**「undici 每 origin 限 10 socket」——那条实测不成立,见下):
9
+ * 客户端主动断开时,服务端 `writeChatResult` 只监听 `res` 的 `'error'`,**不监听
10
+ * `'close'`**。对端关闭触发的是 `'close'`,于是:
11
+ * - 上游流还在推 → `writeChunk` 里 `res.write` 返回 false → 挂起等 `'drain'`
12
+ * - 连接已死 → `'drain'` 永不到来 → `pipeTo` 永不 settle
13
+ * - → `finally` 不执行 → 上游 fetch body **永不 cancel**
14
+ * → 每次「客户端放弃」漏一条上游 TCP 连接 + 挂一个请求;长跑累积到 fd/端口耗尽。
15
+ *
16
+ * 为什么只有自定义供应商路径中招:pi-ai 有**流空闲超时**(idleWatchdog,默认
17
+ * 300s)和消费方提前退出时的 `consumer.abort()`,会**主动放弃**请求;而 dsh-router
18
+ * 自己的 adapter 不设任何超时、从不主动放弃 → 内置路径几乎不触发这条泄漏。
19
+ *
20
+ * 为什么「每 origin 10 socket」的解释不成立:实测(探针 I)连续泄漏 200 次
21
+ * **全部成功、无排队**(单次 ≤10ms),undici 会自行关闭多余连接 —— 默认 fetch
22
+ * 没有这个硬上限,所以「池满 → 全部 connection error」推不出来。
23
+ *
24
+ * 用 node --test 跑(Node 原生 TS 剥离,零新依赖)。
25
+ */
26
+ import { test } from 'node:test'
27
+ import assert from 'node:assert/strict'
28
+ import http from 'node:http'
29
+ import { Router } from './index.ts'
30
+ import { AccountPool } from './account-pool.ts'
31
+ import type { ChatOnceResult, SupplierAccountNow, SupplierStatusNow } from '../suppliers/contract.ts'
32
+ import type { ModelInfo } from './types.ts'
33
+
34
+ /**
35
+ * 造一个**永不结束**的上游流(模拟长生成),并记录它有没有被 cancel。
36
+ * `enqueued` 用来证明「断开后服务端是否还在继续消费」。
37
+ */
38
+ function infiniteStream(): { stream: ReadableStream<Uint8Array>; cancelled: () => boolean; enqueued: () => number } {
39
+ const enc = new TextEncoder()
40
+ let cancelled = false
41
+ let enqueued = 0
42
+ let timer: ReturnType<typeof setInterval> | undefined
43
+ const stream = new ReadableStream<Uint8Array>({
44
+ start(ctrl) {
45
+ timer = setInterval(() => {
46
+ try { ctrl.enqueue(enc.encode('data: {"choices":[{"delta":{"content":"x"}}]}\n\n')); enqueued += 1 } catch { /* closed */ }
47
+ }, 20)
48
+ },
49
+ cancel() { cancelled = true; if (timer !== undefined) clearInterval(timer) },
50
+ })
51
+ return { stream, cancelled: () => cancelled, enqueued: () => enqueued }
52
+ }
53
+
54
+ function supplierWith(stream: ReadableStream<Uint8Array>) {
55
+ return {
56
+ id: 'sup', name: 'sup', priority: 0, pool: new AccountPool('sup'),
57
+ status: (): SupplierStatusNow => ({ id: 'sup', name: 'sup', accounts: [] }),
58
+ listModels: async (): Promise<ModelInfo[]> => [{ id: 'm1' }],
59
+ accounts: (): SupplierAccountNow[] => [{ uid: 'u1', credits: 0, state: 'ok' }],
60
+ getAlias: () => 'sup',
61
+ chatOnce: async (): Promise<ChatOnceResult> => ({ ok: true, stream }),
62
+ dispose: () => {},
63
+ }
64
+ }
65
+
66
+ /** 建 server + 发起「拿一块就断」的请求,返回服务端是否收尾。 */
67
+ async function abandonedRequest(
68
+ router: Router,
69
+ leak: { cancelled: () => boolean; enqueued: () => number },
70
+ ): Promise<{ settled: boolean; closeSeen: boolean; enqueuedAfterSettle: number }> {
71
+ let settled = false
72
+ let closeSeen = false
73
+ const server = http.createServer((req, res) => {
74
+ let body = ''
75
+ req.on('data', (c) => { body += c })
76
+ req.on('end', () => {
77
+ res.on('close', () => { closeSeen = true })
78
+ void router.chatCompletions(
79
+ { model: 'sup,m1', stream: true, rawBody: body } as never,
80
+ res as never,
81
+ ).then(() => { settled = true }, () => { settled = true })
82
+ })
83
+ })
84
+ await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()))
85
+ const port = (server.address() as { port: number }).port
86
+
87
+ await new Promise<void>((resolve) => {
88
+ const creq = http.request({
89
+ host: '127.0.0.1', port, path: '/x', method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ }, (cres) => {
92
+ cres.once('data', () => { creq.destroy(); resolve() })
93
+ })
94
+ creq.on('error', () => resolve())
95
+ creq.end(JSON.stringify({ model: 'sup,m1', messages: [{ role: 'user', content: 'hi' }], stream: true }))
96
+ })
97
+
98
+ await new Promise((r) => setTimeout(r, 1200))
99
+ const enqueuedAfterSettle = leak.enqueued()
100
+ server.closeAllConnections?.()
101
+ server.close()
102
+ return { settled, closeSeen, enqueuedAfterSettle }
103
+ }
104
+
105
+ test('客户端中途断开:服务端必须停手并 cancel 上游流(否则每放弃一次漏一条连接)', async () => {
106
+ const leak = infiniteStream()
107
+ const router = new Router('')
108
+ router.add(supplierWith(leak.stream) as never)
109
+
110
+ const { settled, closeSeen } = await abandonedRequest(router, leak)
111
+
112
+ assert.equal(closeSeen, true, 'server 应看到客户端 close')
113
+ assert.equal(settled, true, '客户端断开后请求处理必须收尾(不能永远挂着)')
114
+ assert.equal(leak.cancelled(), true, '上游流必须被 cancel(否则 fetch body 泄漏,累积到 connection error)')
115
+
116
+ // 断开后不能再继续消费上游
117
+ const a = leak.enqueued()
118
+ await new Promise((r) => setTimeout(r, 400))
119
+ assert.equal(leak.enqueued(), a, '断开后不该继续从上游拉数据')
120
+ })
121
+
122
+ test('客户端断开:上游流 cancel 链要穿过 tapStreamUsage / normalizeSSEStream 两层 wrapper', async () => {
123
+ // 带 probe(= 真实请求路径)时会多包一层 tapStreamUsage;两层 wrapper 都必须
124
+ // 实现 cancel→reader.cancel(),取消才到得了上游(缺一层就静默断链)。
125
+ const leak = infiniteStream()
126
+ const router = new Router('')
127
+ router.add(supplierWith(leak.stream) as never)
128
+
129
+ let settled = false
130
+ let closeSeen = false
131
+ const server = http.createServer((req, res) => {
132
+ let body = ''
133
+ req.on('data', (c) => { body += c })
134
+ req.on('end', () => {
135
+ res.on('close', () => { closeSeen = true })
136
+ void router.chatCompletions(
137
+ { model: 'sup,m1', stream: true, rawBody: body } as never,
138
+ res as never,
139
+ ).then(() => { settled = true }, () => { settled = true })
140
+ })
141
+ })
142
+ await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()))
143
+ const port = (server.address() as { port: number }).port
144
+ await new Promise<void>((resolve) => {
145
+ const creq = http.request({
146
+ host: '127.0.0.1', port, path: '/x', method: 'POST',
147
+ headers: { 'Content-Type': 'application/json' },
148
+ }, (cres) => { cres.once('data', () => { creq.destroy(); resolve() }) })
149
+ creq.on('error', () => resolve())
150
+ creq.end(JSON.stringify({ model: 'sup,m1', messages: [{ role: 'user', content: 'hi' }], stream: true }))
151
+ })
152
+ await new Promise((r) => setTimeout(r, 1200))
153
+ server.closeAllConnections?.()
154
+ server.close()
155
+
156
+ assert.equal(closeSeen, true)
157
+ assert.equal(settled, true, '带探针的路径也必须收尾')
158
+ assert.equal(leak.cancelled(), true, '取消必须穿过两层 wrapper 到达上游')
159
+ })
160
+
161
+ test('聚合失败:reader 必须被解绑(否则连接还活着时上层无法 cancel)', async () => {
162
+ // 这一条测的**不是**「cancel 回调有没有触发」——那测不出东西:
163
+ // 实测结论(两个都验过):
164
+ // - 流 error 后调 cancel(),只会以同一错误 reject,**不触发** cancel 回调
165
+ // (`pull()` 抛错会把流置为 errored;上游拆连接则流直接 error)
166
+ // - 这两种情况下 undici **已经自己关了连接**,不需要我们 cancel
167
+ // 所以聚合失败路径真正要保证的是 **reader 被解绑**:流锁着的话,上层
168
+ // (writeChatResult 的失败路径 / 未来的取消逻辑)想 cancel 都会抛
169
+ // `Invalid state: ReadableStream is locked`,连接就永久漏了。
170
+ let releaseLocked = false
171
+ const enc = new TextEncoder()
172
+ const failing = new ReadableStream<Uint8Array>({
173
+ start(c) { c.enqueue(enc.encode('data: {"choices":[{"delta":{"content":"a"}}]}\n\n')) },
174
+ pull() { throw new Error('aggregate read failed') },
175
+ })
176
+ const origGetReader = failing.getReader.bind(failing)
177
+ ;(failing as unknown as { getReader: () => unknown }).getReader = () => {
178
+ const r = origGetReader()
179
+ const origRelease = r.releaseLock.bind(r)
180
+ r.releaseLock = () => { releaseLocked = true; origRelease() }
181
+ return r
182
+ }
183
+
184
+ const router = new Router('')
185
+ router.add(supplierWith(failing) as never)
186
+ const sink = {
187
+ writeHead: () => undefined, end: () => undefined, write: () => true,
188
+ once: () => undefined, removeListener: () => undefined, destroy: () => undefined,
189
+ writableEnded: false, destroyed: false,
190
+ } as never
191
+
192
+ await router.chatCompletions(
193
+ { model: 'sup,m1', stream: false, rawBody: JSON.stringify({ model: 'sup,m1', messages: [] }) } as never,
194
+ sink,
195
+ )
196
+ await new Promise((r) => setTimeout(r, 150))
197
+
198
+ assert.equal(releaseLocked, true, '聚合失败后必须 releaseLock,否则流锁死、上层无法回收')
199
+ assert.equal(failing.locked, false, '流必须处于未锁定状态(可被 cancel / 已被回收)')
200
+ })
@@ -140,6 +140,7 @@ async function aggregateSSE(stream: ReadableStream<Uint8Array>): Promise<string>
140
140
 
141
141
  const reader = stream.getReader()
142
142
  let buf = ''
143
+ try {
143
144
  for (;;) {
144
145
  const { done, value } = await reader.read()
145
146
  if (done) break
@@ -189,6 +190,12 @@ async function aggregateSSE(stream: ReadableStream<Uint8Array>): Promise<string>
189
190
  }
190
191
  }
191
192
  }
193
+ } finally {
194
+ // 无论正常读完还是中途抛错,都要**解绑** reader:调用方(writeChatResult)
195
+ // 在失败路径里要对同一流 cancel(),而流被锁住时 cancel 会抛
196
+ // `Invalid state: ReadableStream is locked` —— 那样这次的连接就漏了。
197
+ try { reader.releaseLock() } catch { /* 有待定 read 时会抛,忽略 */ }
198
+ }
192
199
 
193
200
  const toolCalls = [...calls.entries()]
194
201
  .sort((a, b) => a[0] - b[0])
@@ -242,9 +249,11 @@ function normalizeSSEStream(stream: ReadableStream<Uint8Array>): {
242
249
  const dec = new TextDecoder()
243
250
  let buf = ''
244
251
  let sawDone = false
252
+ /** 上游 reader:cancel 时要靠它把取消传下去(见下方 cancel 分支)。 */
253
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
245
254
  const out = new ReadableStream<Uint8Array>({
246
255
  start: async (ctrl) => {
247
- const reader = stream.getReader()
256
+ reader = stream.getReader()
248
257
  try {
249
258
  for (;;) {
250
259
  const { done, value } = await reader.read()
@@ -263,11 +272,21 @@ function normalizeSSEStream(stream: ReadableStream<Uint8Array>): {
263
272
  if (isDoneFrame(buf)) sawDone = true
264
273
  ctrl.enqueue(enc.encode(fixFrame(buf)))
265
274
  }
275
+ } catch {
276
+ // 消费方取消(客户端断开)会让挂起的 read 抛错/返回 done —— 静默收尾
266
277
  } finally {
267
- reader.releaseLock()
278
+ try { reader.releaseLock() } catch { /* 有待定 read 时会抛,忽略 */ }
268
279
  }
269
- ctrl.close()
280
+ try { ctrl.close() } catch { /* 已取消 */ }
270
281
  },
282
+ /**
283
+ * **取消必须往下传**:ReadableStream 的取消不会自动穿过 wrapper。
284
+ * 不实现这个分支,客户端断开的取消就停在这里,上游 fetch body 永不 cancel
285
+ * ——那条 TCP 连接和 in-flight 请求原样挂着(长跑一天就会累积到耗尽)。
286
+ * 用 reader.cancel() 而不是 stream.cancel():流已被 getReader() 锁住,
287
+ * 直接 cancel 会抛 `Invalid state: ReadableStream is locked`。
288
+ */
289
+ cancel: (reason) => reader?.cancel(reason),
271
290
  })
272
291
  return { stream: out, sawDone: () => sawDone }
273
292
  }
@@ -338,7 +357,12 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
338
357
  try {
339
358
  body = await aggregateSSE(r.stream)
340
359
  } catch {
341
- return false // 聚合失败 = 一个字节都没写,调用方可以换号重试
360
+ // 聚合失败 = 一个字节都没写,调用方可以换号重试。
361
+ // **但底层流必须 cancel**:aggregateSSE 内部 getReader() 消费到一半抛错时,
362
+ // reader 只是 releaseLock(),传输层毫无察觉 —— 那条上游连接会一直挂着。
363
+ // 不 cancel 就退化成「每次聚合失败漏一条连接」(issue #6 的路径 1)。
364
+ await r.stream.cancel().catch(() => {})
365
+ return false
342
366
  }
343
367
  if (probe !== undefined) probe.tokens = usageFromJsonBody(body)
344
368
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
@@ -351,6 +375,18 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
351
375
  let wroteAny = false
352
376
  /** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
353
377
  let dead = false
378
+ /**
379
+ * 唤醒「等 drain 的挂起 write」。客户端断开时必须调它:那时 'drain' 永不到来,
380
+ * 不唤醒就永远卡在 write 里,pipeTo 不 settle,finally 不执行,
381
+ * **上游流也就永不 cancel**(= 每次客户端放弃漏一条连接)。
382
+ */
383
+ let releaseWait: (() => void) | undefined
384
+ /**
385
+ * 中止 pipeTo 的信号源(客户端断开时 abort)。
386
+ * 单靠 `dead` 标志只能让**下一次** write 立刻返回,唤醒不了**已经挂起**的那次;
387
+ * abort 才能让 pipeTo 立刻收尾并走它的取消路径(进而 cancel 上游)。
388
+ */
389
+ const abort = new AbortController()
354
390
  const writeChunk = (chunk: Uint8Array): Promise<void> =>
355
391
  new Promise<void>((resolve) => {
356
392
  if (dead) { resolve(); return }
@@ -370,25 +406,44 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
370
406
  if (res.write(chunk)) resolve()
371
407
  else {
372
408
  // 背压:等 drain。但连接可能在这期间断掉 —— 那时 'drain' 永不到来,
373
- // 得靠 dead 标志(onError 里置位)兜住,否则这次请求会永远挂着。
409
+ // 得靠 release() 兜住(客户端断开时唤醒),否则这次请求会永远挂着。
374
410
  const onDrain = (): void => { res.removeListener('error', onDrain); resolve() }
375
411
  res.once('drain', onDrain)
412
+ /** 让这个挂起的 write 立刻收尾(客户端断开时调用)。 */
413
+ releaseWait = () => { res.removeListener('drain', onDrain); resolve() }
376
414
  }
377
415
  })
378
416
 
379
- // 流中途出错(客户端断开/上游断流)也要结束响应,否则连接悬挂
417
+ // 客户端断开 / 流中途出错都要停手,否则连接悬挂
380
418
  const onError = (): void => {
381
419
  dead = true
382
420
  res.destroy()
383
421
  }
384
422
  res.once('error', onError)
423
+ /**
424
+ * **客户端断开必须被当成取消信号**:只监听 'error' 是不够的 —— 对端关闭
425
+ * (pi-ai 的流空闲超时、用户取消、消费方提前退出)触发的是 'close',
426
+ * 不是 'error'。此时若不收手,pipeTo 会一直等着 drain(永不到来),
427
+ * finally 永不执行,上游 fetch body **永不 cancel** → 那条 TCP 连接和
428
+ * in-flight 请求原样挂着,长跑累积到耗尽("用久了就报 connection error")。
429
+ */
430
+ const onClose = (): void => {
431
+ if (res.writableEnded) return // 正常写完的 close,不是取消
432
+ dead = true
433
+ releaseWait?.()
434
+ abort.abort()
435
+ }
436
+ res.once('close', onClose)
385
437
  const norm = normalizeSSEStream(tapped?.stream ?? r.stream)
386
438
  try {
387
- await norm.stream.pipeTo(new WritableStream<Uint8Array>({ write: writeChunk }))
439
+ // signal 让 abort 时 pipeTo 立刻收尾(而不是等 drain);其收尾路径会
440
+ // cancel 源流,顺着 wrapper 的 cancel 一路传到上游 fetch body。
441
+ await norm.stream.pipeTo(new WritableStream<Uint8Array>({ write: writeChunk }), { signal: abort.signal })
388
442
  } catch {
389
- // 流断了:一个字节都没写的话,调用方还能换号重试——交给上层决定
443
+ // 断流/取消:一个字节都没写的话,调用方还能换号重试——交给上层决定
390
444
  } finally {
391
445
  res.removeListener('error', onError)
446
+ res.removeListener('close', onClose)
392
447
  // 断流也要给客户端一个终止帧。客户端(dsh-llm adapter)严格等
393
448
  // `[DONE]`,缺了就整轮判失败(`SSE payload stream ended without
394
449
  // [DONE]`),已经流式吐出去的内容全部作废。上游自己发了就不重复发
@@ -396,10 +451,11 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
396
451
  //
397
452
  // 只在**已提交响应**时补:一个字节都没写 = 还能换号重试,补了就等于
398
453
  // 把这次失败坐实成一个空响应,组合回退就没了。
454
+ // dead(客户端已走)时也不补:对端没了,写了也没人收。
399
455
  if (wroteAny && !norm.sawDone() && !dead && !res.writableEnded && !res.destroyed) {
400
456
  await writeChunk(DONE_FRAME)
401
457
  }
402
- if (wroteAny) res.end()
458
+ if (wroteAny && !dead) res.end()
403
459
  // 流走完才出得了 usage(上游多在最后一帧才发 usage)
404
460
  if (probe !== undefined && tapped !== undefined) {
405
461
  const got = await tapped.done()
@@ -280,9 +280,11 @@ export function tapStreamUsage(
280
280
  }
281
281
  }
282
282
 
283
+ /** 源 reader:cancel 时要靠它把取消传下去(见下方 cancel 分支)。 */
284
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
283
285
  const stream = new ReadableStream<Uint8Array>({
284
286
  start: async (ctrl) => {
285
- const reader = source.getReader()
287
+ reader = source.getReader()
286
288
  try {
287
289
  for (;;) {
288
290
  const { done, value } = await reader.read()
@@ -300,11 +302,19 @@ export function tapStreamUsage(
300
302
  ctrl.enqueue(value as Uint8Array)
301
303
  }
302
304
  if (buf !== '') parseFrame(buf)
305
+ } catch {
306
+ // 消费方取消会让挂起的 read 收尾 —— 静默退出
303
307
  } finally {
304
- reader.releaseLock()
308
+ try { reader.releaseLock() } catch { /* 有待定 read 时会抛,忽略 */ }
305
309
  }
306
- ctrl.close()
310
+ try { ctrl.close() } catch { /* 已取消 */ }
307
311
  },
312
+ /**
313
+ * 取消要往下传(同 normalizeSSEStream 的理由):不传 → 客户端断开时
314
+ * 上游 fetch body 永不 cancel,连接与 in-flight 请求一直挂着。
315
+ * 必须用 reader.cancel():流已被 getReader() 锁住,stream.cancel() 会抛。
316
+ */
317
+ cancel: (reason) => reader?.cancel(reason),
308
318
  })
309
319
 
310
320
  return {