dsh-router-core 0.3.18 → 0.3.20

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.
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { useEffect, useRef, useState } from 'react'
12
12
  import { ROUTER_API_BASE, type RouterExtItem, type RouterExtResponse } from '../shared.ts'
13
+ import { ExtDetail } from './ExtDetail.tsx'
13
14
 
14
15
  function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
15
16
  return (
@@ -28,6 +29,7 @@ export function ExtTab(): JSX.Element {
28
29
  const [items, setItems] = useState<RouterExtItem[]>([])
29
30
  const [loaded, setLoaded] = useState(false)
30
31
  const [error, setError] = useState('')
32
+ const [selectedId, setSelectedId] = useState<string | null>(null)
31
33
  const mounted = useRef(true)
32
34
 
33
35
  useEffect(() => {
@@ -79,6 +81,12 @@ export function ExtTab(): JSX.Element {
79
81
  }
80
82
  }
81
83
 
84
+ // 打开详情视图:用 live item(否则详情里的开关状态不会随列表更新)。
85
+ const selected = selectedId !== null ? items.find((i) => i.id === selectedId) ?? null : null
86
+ if (selected !== null) {
87
+ return <ExtDetail item={selected} onBack={() => setSelectedId(null)} onToggle={toggle} />
88
+ }
89
+
82
90
  return (
83
91
  <div className="dshr-tabBody">
84
92
  {error !== '' && (
@@ -92,51 +100,43 @@ export function ExtTab(): JSX.Element {
92
100
  <span className="dshr-keyEmptyIcon"><Icon d={I.puzzle} size={30} /></span>
93
101
  <p className="dshr-keyEmptyTitle">暂无扩展</p>
94
102
  <p className="dshr-keyEmptyDesc">
95
- 安装扩展插件(如 <code>dsh-router-ext-rtk</code>)后,这里会出现开关
103
+ 安装扩展插件(如 <code>dsh-router-ext-watch</code>)后,这里会出现开关
96
104
  </p>
97
105
  </div>
98
106
  ) : (
99
- items.map(item => {
100
- const notReady = item.ready === false
101
- return (
102
- <section key={item.id} className="dshr-card">
103
- <div className="dshr-cardHead">
104
- <span className="dshr-cardIcon"><Icon d={I.bolt} /></span>
105
- <div className="dshr-cardTitle">{item.name}</div>
106
- {/* 开关放标题行最右端:.dshr-cardAction 是现成的
107
- margin-left:auto(EndpointTab 的「创建 Key」就靠它),复用不新增。
108
- 不可用时禁开——禁了就无从开启成功。 */}
109
- <button
110
- type="button"
111
- className={`dshr-toggle dshr-cardAction ${item.enabled ? 'dshr-toggle-on' : ''}`}
112
- role="switch"
113
- aria-checked={!!item.enabled}
114
- disabled={notReady && !item.enabled}
115
- onClick={() => void toggle(item, !item.enabled)}
116
- title={notReady && !item.enabled
117
- ? '当前不可用,无法开启'
118
- : item.enabled ? `关闭 ${item.name}` : `开启 ${item.name}`}
119
- >
120
- <span className="dshr-toggleKnob" />
121
- </button>
122
- </div>
107
+ <div className="dshr-supplierGrid">
108
+ {items.map(item => {
109
+ const notReady = item.ready === false
110
+ return (
111
+ <section key={item.id} className="dshr-supplierCard" onClick={() => setSelectedId(item.id)}>
112
+ <div className="dshr-supplierRow">
113
+ {item.icon !== undefined ? (
114
+ <img
115
+ className="dshr-supplierIcon dshr-extLogo"
116
+ src={item.icon}
117
+ alt=""
118
+ onError={(e) => { e.currentTarget.style.display = 'none' }}
119
+ />
120
+ ) : (
121
+ <span className="dshr-supplierIcon"><Icon d={I.bolt} size={24} /></span>
122
+ )}
123
+ <div className="dshr-supplierName">{item.name}</div>
124
+ </div>
123
125
 
124
- {/* 内容区:说明 + 未就绪红字(左对齐、与卡片图标对齐) */}
125
- <div className="dshr-extBody">
126
- {item.description !== undefined && item.description !== '' && (
127
- <div className="dshr-muted">{item.description}</div>
128
- )}
129
126
  {notReady && (
130
- <div className="dshr-ext-error">
131
- {item.detail !== undefined && item.detail !== ''
132
- ? item.detail
133
- : '未就绪,无法开启'}
127
+ <div className="dshr-extCardError">
128
+ {item.detail !== undefined && item.detail !== '' ? item.detail : '未就绪'}
134
129
  </div>
135
130
  )}
136
- </div>
137
- </section>
138
- )
139
- })
131
+
132
+ <div className="dshr-supplierMeta">
133
+ <span className="dshr-muted">{item.enabled ? '已启用' : '未启用'}</span>
134
+ <span className="dshr-chevron">›</span>
135
+ </div>
136
+ </section>
137
+ )
138
+ })}
139
+ </div>
140
140
  )}
141
141
  </div>
142
142
  )
@@ -923,6 +923,34 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-
923
923
  margin-top: 4px;
924
924
  }
925
925
 
926
+ /* watch 进程行动作区(复制 PID / kill 命令,只读) */
927
+ .dshr-procOps {
928
+ display: inline-flex;
929
+ align-items: center;
930
+ justify-content: flex-end;
931
+ gap: 4px;
932
+ white-space: nowrap;
933
+ }
934
+
935
+ /* 扩展供应商式小卡片:未就绪红字(描述只在详情页,卡片不放) */
936
+ .dshr-extCardError {
937
+ color: var(--rs-danger);
938
+ font-size: 11px;
939
+ }
940
+
941
+ /* 扩展卡 logo(如 rtk 白底 logo):黑底 + 内边距,白色 logo 在黑底上显形 */
942
+ .dshr-extLogo {
943
+ background: #000;
944
+ padding: 3px;
945
+ box-sizing: border-box;
946
+ object-fit: contain;
947
+ }
948
+
949
+ /* 扩展详情头部右侧的开关(顶到标题行右边,同供应商详情的编辑按钮位) */
950
+ .dshr-extDetailToggle {
951
+ margin-left: auto;
952
+ }
953
+
926
954
  /* 扩展卡片内容区:文字左对齐,左侧留白与标题行的图标位对齐 */
927
955
  .dshr-extBody {
928
956
  padding: 10px 14px 12px 48px;
@@ -25,6 +25,7 @@
25
25
  * file IO —— 落盘位置由核心锚定(不跟 cwd 跑),读写经核心,插件无状态。
26
26
  */
27
27
  import type { Context } from '@deepseek-ai/cordis'
28
+ import type { IncomingMessage, ServerResponse } from 'node:http'
28
29
 
29
30
  /** 扩展器唯一 id(注册键)。 */
30
31
  export type ExtId = string
@@ -53,8 +54,16 @@ export interface RouterExt {
53
54
  readonly name: string
54
55
  /** 面板副标题说明。 */
55
56
  readonly description?: string
57
+ /** 卡片/详情图标的 logo URL(可选;缺省用默认图标)。 */
58
+ readonly icon?: string
56
59
  /** 当前运行时状态(ready + 不就绪时的说明)。 */
57
60
  getState(): ExtState
61
+ /**
62
+ * 通用扩展 API 点(可选):核心把 `/router/api/ext/{id}/{subpath}` 兜底路由派发进来,
63
+ * 由扩展器自己裁决/处理(如 watch 的 `processes`/`kill`)。返回 true 表示已处理;
64
+ * false / 未实现 → 核心回 404。这样扩展的执行面逻辑进插件,核心只加一条通用转发。
65
+ */
66
+ api?(subpath: string, req: IncomingMessage, res: ServerResponse): boolean | Promise<boolean>
58
67
  /** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
59
68
  dispose?(): void
60
69
  }
@@ -64,6 +73,8 @@ export interface ExtInfo {
64
73
  id: string
65
74
  name: string
66
75
  description?: string
76
+ /** 卡片/详情图标 logo URL(可选)。 */
77
+ icon?: string
67
78
  /** 核心持久化的开关(默认关)。 */
68
79
  enabled: boolean
69
80
  /** 插件报的运行时就绪状态。 */
package/src/index.ts CHANGED
@@ -525,6 +525,7 @@ export function apply(rawContext: unknown): void {
525
525
  id: e.id,
526
526
  name: e.name ?? e.id,
527
527
  ...(e.description !== undefined ? { description: e.description } : {}),
528
+ ...(e.icon !== undefined ? { icon: e.icon } : {}),
528
529
  enabled: extStore.isEnabled(e.id),
529
530
  ready: st?.ready === true,
530
531
  ...(st?.detail !== undefined ? { detail: st.detail } : {}),
@@ -534,6 +535,34 @@ export function apply(rawContext: unknown): void {
534
535
  writeJson(res, 200, { ok: true, enhancers: list })
535
536
  })
536
537
 
538
+ // ---- 扩展通用 API 转发:/router/api/ext/{id}/{subpath} → 该扩展器的 api() ----
539
+ // watch 这类自定义扩展用它暴露自己的面板接口(如 processes)。分开的 exact 路由
540
+ // `/ext`(上方)管开关列表;这里必须是 **prefix** 路由兜子路径(`route()` 是 exact,
541
+ // 匹配不了 `/ext/watch/processes`——用 exact 会造成 404 空 body → 前端 res.json() 崩)。
542
+ // 核心不知道子路径长什么样——是否处理、怎么处理全由扩展器自己裁决(执行面),核心只转发。
543
+ const extDispatch = ctx.webServer.register({
544
+ kind: 'prefix',
545
+ path: `${ROUTER_API_BASE}/ext/`,
546
+ handler: async (req, res) => {
547
+ const pathname = new URL(req.url ?? '/', 'http://localhost').pathname
548
+ const mount = `${ROUTER_API_BASE}/ext/`
549
+ const rest = pathname.startsWith(mount) ? pathname.slice(mount.length) : ''
550
+ const slash = rest.indexOf('/')
551
+ const extId = slash < 0 ? rest : rest.slice(0, slash)
552
+ const subpath = slash < 0 ? '' : rest.slice(slash + 1)
553
+ if (extId === '') {
554
+ writeJson(res, 404, { ok: false, error: 'extension not found' })
555
+ return
556
+ }
557
+ const ext = (exts[extId] as RouterExt | undefined)
558
+ if (ext !== undefined && typeof ext.api === 'function' && (await ext.api(subpath, req, res))) {
559
+ return // 扩展器已处理并写出响应
560
+ }
561
+ writeJson(res, 404, { ok: false, error: 'extension api not found' })
562
+ },
563
+ })
564
+ disposers.push(extDispatch)
565
+
537
566
  // ---- 概览看板:用量统计 ----
538
567
 
539
568
  /** 解析并校验周期参数(不接受任意字符串)。 */
@@ -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
+ })