dsh-chubby-whale 1.1.64 → 1.1.66

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
@@ -4,6 +4,53 @@
4
4
 
5
5
  ## [未发布]
6
6
 
7
+ ## [1.1.66] - 2026-11-07
8
+
9
+ ### 修复(B94 HTTP 错误分类与聊天断连分身)
10
+ - **`isTimeoutError` 把「客户端走了」与「上游太慢」混为一类**:日志分不清,路由也没法据此分流。新增 `isAbortError` / `isTimeoutOnly`(旧函数语义原样保留,不破既有断言)
11
+ - **`/chat` 不接断连**:客户端走了仍把模型跑完、且把 AbortError 记成 `CHAT-TIMEOUT`;请求体断连还会往死连接回 200 兜底包。现在断连即 abort 上游、**半截不记账**(`resolve` 出的完整回复仍照常记账——已付费的语义不变)
12
+ - **`/chat-stream` 漏 `req` 侧断连信号**、每请求留一个 `res close` 监听不摘、断连后仍往死 socket 写 `stream: client closed` 帧
13
+ - `http.ts` 新增公共 `watchClientGone`(三形状判定,正常结束有 `writableFinished` 守护)
14
+
15
+ ### 修复(B94 TTS 断连中止分身)
16
+ - **客户端走了仍在合成**:`/tts` 完全没有断连检测,`TtsEngine`/`cloudTts` 也没有 signal 通道。现在 signal 一路贯到 worker/CLI/云端
17
+ - 取舍写在代码里:edge-tts **共享 worker 不杀**(杀掉会连带失败并发段并重付启动),只把本次从 pending 摘除、产物作废;CLI 回退路径本请求独占 python,abort 直接 kill;临时文件 `EBUSY` 时 5s 补删一次
18
+ - abort 的请求**不记账、不写缓存、不进降级闸、不回退 edge-tts**,只留一行 `TTS-CLIENT-ABORT` 诊断
19
+
20
+ ### 修复(主代理)
21
+ - **整句打断词表三处各抄一份**:`echo-filter` 认 19 条、`voice-bridge` 只认 8 条 → 「停下来吧」在闸门能放行、到桥接却认不出。统一到 `shared/noise.ts` 的 `INTERRUPT_PHRASES` + `isInterruptCommand()`
22
+ - `intent.ts` 头部「纯函数」口径修正:判定同步,但会触发一次 `ensurePoemTitles()` 后台预热(有网络副作用),单测须 stub
23
+
24
+ ### 测试
25
+ - 新增 `test-chat-abort.mjs`(51)、`test-tts-abort.mjs`(61),接入 `npm test`;`test-shared-voice` +7
26
+ - 全链 **4033 项 0 失败**(`node tools/test-count.mjs`:56 个套件,失败 0,未识别 0)
27
+
28
+ ## [1.1.65] - 2026-11-07
29
+
30
+ ### 修复(B93 音色白名单同源分身)
31
+ - **音色白名单只在客户端**:宿主 `/tts` 只做 `^[a-zA-Z0-9-]+# 更新日志
32
+
33
+ 本文件格式遵循 Keep a Changelog,版本号遵循语义化版本。版本号唯一来源是 `package.json`;「未发布」一节记录已进 `main`、尚未发版的内容,发版时整节改为 `## [X.Y.Z] - YYYY-MM-DD`。
34
+
35
+ ,HTTP 直调 `ja-JP-NanamiNeural` 会原样进引擎(旧测试里甚至写着「现状快照:仍会收到它」)。白名单提到 `shared/voices.ts`(client/host 同一份),名单外**回落默认音色**(不 400,兼容旧调用方)并带 `x-tts-voice: fallback`
36
+ - **路由内自建引擎兜底**:`opts.engine || createEdgeTtsEngine()` 与「引擎必须在装配层构造(否则没句柄 dispose)」冲突;改为 engine 必传,构造时缺即抛(fail-fast)
37
+ - `ports.ts` 死字段 `voice?: string` 删除
38
+
39
+ ### 修复(B93 ASR 断连中止分身)
40
+ - **客户端已放弃,宿主还在跑满 60s**:`/asr` 没有断连监听,客户端 30s 超时并重试后,宿主仍把第一次识别跑完(付费调用)
41
+ - 现在 `watchClientGone` 三条判定(`req aborted` / `req close` 且未收完 / `res close` 且未写完)——**注意不能只看 close**:正常请求 body 读完就发 close,直接判会误伤每一个正常请求
42
+ - 断开即 abort 上游;`AbortError` 按「客户端走了」处理:只留 `[asr] ASR-CLIENT-ABORT` 诊断,**不写缓存、不计失败、不回包**
43
+ - `host/http.ts` 的 `fetchJson/fetchWithTimeout` 支持外部 `signal`(原来会被内部超时**覆盖**掉),与超时用 `AbortSignal.any` 组合
44
+
45
+ ### 修复(主代理)
46
+ - **云端日预算并发超额**:判定与记账之间有 `await fetch`,并发 N 个请求一起通过判定(最多超额 `(N-1)×单次字数`)。改为发请求前**预占**、`finally` 释放,`usage()` 仍只报已用量
47
+ - **朗读队列丢段无人结账**:`capQueue` 只返回数量,被丢段若带 `onDone` 永远没人调(叙述锁只能等时间窗自愈);新增 `capQueueDetailed` 返回被丢段,`voice.ts` 补 `onDone` 并清预取引用
48
+ - **云端谎报 `x-tts-voice: fallback`**:云端用配置里的 speaker、不接收 `voice`,成功出云端时摘掉该头
49
+
50
+ ### 测试
51
+ - 新增 `test-voices-shared.mjs`(60)、`test-asr-abort.mjs`(46),接入 `npm test`;`test-persona-cloud` +3、`test-voice-queue` +4、`test-voice-routes` +2
52
+ - 全链 **3914 项 0 失败**(`node tools/test-count.mjs`:54 个套件,失败 0,未识别 0)
53
+
7
54
  ## [1.1.64] - 2026-11-07
8
55
 
9
56
  ### 修复(B92 客户端朗读播放路径分身)
package/dist/client.js CHANGED
@@ -25,7 +25,7 @@
25
25
  }
26
26
 
27
27
  // src/client/version.ts
28
- var VERSION = "1.1.64";
28
+ var VERSION = "1.1.66";
29
29
 
30
30
  // src/client/styles.ts
31
31
  var W = 240;
@@ -1133,6 +1133,32 @@
1133
1133
  if (/^([\u4e00-\u9fa5])\1{2,}$/.test(k)) return true;
1134
1134
  return false;
1135
1135
  }
1136
+ var INTERRUPT_PHRASES = Object.freeze([
1137
+ "停下",
1138
+ "停下来",
1139
+ "停下来吧",
1140
+ "停一下",
1141
+ "停一停",
1142
+ "停吧",
1143
+ "别说了",
1144
+ "别讲了",
1145
+ "别念了",
1146
+ "别吵",
1147
+ "别说下去了",
1148
+ "不要说了",
1149
+ "不用说了",
1150
+ "安静",
1151
+ "打住",
1152
+ "够了",
1153
+ "暂停",
1154
+ "算了"
1155
+ ]);
1156
+ function isInterruptCommand(text) {
1157
+ const k = normalizeNoiseKey(text);
1158
+ if (!k) return false;
1159
+ if (isInterruptWord(k)) return true;
1160
+ return INTERRUPT_PHRASES.indexOf(k) >= 0;
1161
+ }
1136
1162
  function isInterruptWord(text) {
1137
1163
  const k = normalizeNoiseKey(text);
1138
1164
  return k.length > 0 && INTERRUPT_WORDS.indexOf(k) >= 0;
@@ -1183,31 +1209,6 @@
1183
1209
  }
1184
1210
 
1185
1211
  // src/client/voice/echo-filter.ts
1186
- var SINGLE_INTERRUPT = /^[停别不]$/;
1187
- var INTERRUPT_PHRASES = [
1188
- "停下",
1189
- "停下来",
1190
- "停下来吧",
1191
- "停一下",
1192
- "停一停",
1193
- "停吧",
1194
- "别说了",
1195
- "别讲了",
1196
- "别念了",
1197
- "别吵",
1198
- "别说下去了",
1199
- "不要说了",
1200
- "不用说了",
1201
- "闭嘴",
1202
- "安静",
1203
- "打住",
1204
- "够了",
1205
- "暂停",
1206
- "算了"
1207
- ];
1208
- function isInterruptCommand(plain) {
1209
- return SINGLE_INTERRUPT.test(plain) || INTERRUPT_PHRASES.indexOf(plain) >= 0;
1210
- }
1211
1212
  function createEchoFilter() {
1212
1213
  const spokenRecent = [];
1213
1214
  const recentHeard = [];
@@ -1229,7 +1230,7 @@
1229
1230
  }
1230
1231
  const plain = normalizePlain(text).toLowerCase();
1231
1232
  const plainKey = echoKey(text).toLowerCase();
1232
- const interrupt = SINGLE_INTERRUPT.test(plain);
1233
+ const interrupt = isInterruptWord(plain);
1233
1234
  const stopCmd = isInterruptCommand(plain);
1234
1235
  if (plain.length < 2 && !interrupt) return { ok: false, reason: "trivial" };
1235
1236
  if (!interrupt && isNoiseText(plain)) return { ok: false, reason: "noise" };
@@ -1337,7 +1338,7 @@
1337
1338
  }
1338
1339
  }
1339
1340
 
1340
- // src/client/voice/voices.ts
1341
+ // src/shared/voices.ts
1341
1342
  var TTS_VOICES = [
1342
1343
  "zh-CN-XiaoyiNeural",
1343
1344
  "zh-CN-XiaoxiaoNeural",
@@ -1359,14 +1360,16 @@
1359
1360
  function normalizeVoiceName(name, fallback = DEFAULT_VOICE) {
1360
1361
  let n = "";
1361
1362
  try {
1362
- n = str(name).trim();
1363
- } catch (e) {
1363
+ n = (name == null ? "" : String(name)).trim();
1364
+ } catch {
1364
1365
  return fallback;
1365
1366
  }
1366
1367
  if (!n) return fallback;
1367
1368
  const hit = VOICE_BY_KEY.get(n.toLowerCase());
1368
1369
  return hit || fallback;
1369
1370
  }
1371
+
1372
+ // src/client/voice/voices.ts
1370
1373
  function readStoredVoiceName() {
1371
1374
  try {
1372
1375
  return normalizeVoiceName(localStorage.getItem("chubby-tts-voice"));
@@ -1485,12 +1488,11 @@
1485
1488
  return typeof token === "number" && token !== current;
1486
1489
  }
1487
1490
  var TTS_QUEUE_MAX = 200;
1488
- function capQueue(q, max = TTS_QUEUE_MAX) {
1491
+ function capQueueDetailed(q, max = TTS_QUEUE_MAX) {
1489
1492
  const limit = Math.max(0, Math.floor(Number(max) || 0));
1490
- if (!Array.isArray(q) || q.length <= limit) return 0;
1493
+ if (!Array.isArray(q) || q.length <= limit) return [];
1491
1494
  const drop = q.length - limit;
1492
- q.splice(0, drop);
1493
- return drop;
1495
+ return q.splice(0, drop);
1494
1496
  }
1495
1497
 
1496
1498
  // src/client/voice/recorder.ts
@@ -2558,8 +2560,20 @@
2558
2560
  });
2559
2561
  ttsSeq += segs.length;
2560
2562
  for (let i = 0; i < jobs.length; i++) ttsQ.push(jobs[i]);
2561
- const droppedQ = capQueue(ttsQ);
2562
- if (droppedQ > 0) stats.qDropped = (stats.qDropped || 0) + droppedQ;
2563
+ const droppedJobs = capQueueDetailed(ttsQ);
2564
+ if (droppedJobs.length > 0) {
2565
+ stats.qDropped = (stats.qDropped || 0) + droppedJobs.length;
2566
+ for (const d of droppedJobs) {
2567
+ try {
2568
+ ttsPrefetch.delete(d.seq);
2569
+ } catch (e) {
2570
+ }
2571
+ try {
2572
+ if (d && typeof d.onDone === "function") d.onDone();
2573
+ } catch (e) {
2574
+ }
2575
+ }
2576
+ }
2563
2577
  prefetchUpcoming(!!(ttsQ[0] && ttsQ[0].narration));
2564
2578
  pumpTts();
2565
2579
  }
@@ -3874,13 +3888,11 @@
3874
3888
  }
3875
3889
 
3876
3890
  // src/client/voice-bridge.ts
3877
- var STOP_PHRASES = ["停下", "停一下", "别说了", "别讲了", "别念了", "别吵", "安静", "够了"];
3878
3891
  var NARR_STOP_RE = /停|别念|不念|够了|算了|打住/;
3879
3892
  var HAS_CONTENT_RE = /[\u4e00-\u9fa5a-z0-9]/i;
3880
3893
  function isStopCommand(norm) {
3881
3894
  if (!norm) return false;
3882
- if (isInterruptWord(norm)) return true;
3883
- return STOP_PHRASES.indexOf(norm) >= 0;
3895
+ return isInterruptCommand(norm);
3884
3896
  }
3885
3897
  function bump(key) {
3886
3898
  try {
package/dist/host/http.js CHANGED
@@ -21,25 +21,157 @@ function bodyTooLarge() {
21
21
  export function isBodyTooLarge(err) {
22
22
  return Boolean(err && err.code === BODY_TOO_LARGE);
23
23
  }
24
- /** 超时/中止类错误(AbortSignal.timeout 抛 TimeoutError,手动 abort 抛 AbortError) */
24
+ /**
25
+ * 错误名(B94):只看 name,不猜 instanceof —— DOMException 跨 realm 不可靠,
26
+ * 测试里的替身错误也只是 Object.assign(new Error(...), { name })。
27
+ * 有的运行时/薄封装会把中止原因再包一层(err.reason = 原始 reason),这里顺手认一层。
28
+ */
29
+ function errorName(err) {
30
+ return String((err && err.name) || '');
31
+ }
32
+ /** 超时/中止类错误 —— **兼容旧语义:TimeoutError 与 AbortError 都算**(既有测试与 asr 路由按此口径) */
25
33
  export function isTimeoutError(err) {
26
- const n = String((err && err.name) || '');
34
+ const n = errorName(err);
27
35
  return n === 'TimeoutError' || n === 'AbortError';
28
36
  }
37
+ /**
38
+ * 只因**调用方**(外部 signal:客户端断连 / 切页 / 上层主动取消)中止 —— AbortError。
39
+ * 依据:fetch 被中止时以 signal.reason 拒绝;AbortController.abort() 的默认 reason 是 AbortError。
40
+ * 与内部超时(TimeoutError)互斥:路由据此把「客户端走了」与「上游太慢」分开记日志/记账
41
+ * (B93 取证:两者混在 isTimeoutError 里时,CHAT-TIMEOUT / ASR-TIMEOUT 分不出是谁先放弃的)。
42
+ */
43
+ export function isAbortError(err) {
44
+ return errorName(err) === 'AbortError' || errorName(err && err.reason) === 'AbortError';
45
+ }
46
+ /**
47
+ * 只是内部**超时**(AbortSignal.timeout(ms) 到点)—— TimeoutError;调用方中止不算。
48
+ * 旧名 isTimeoutError 被「两者都算」的历史语义占着,区分用的新名字另起(B94)。
49
+ */
50
+ export function isTimeoutOnly(err) {
51
+ return errorName(err) === 'TimeoutError' || errorName(err && err.reason) === 'TimeoutError';
52
+ }
29
53
  /** N 毫秒后自动中止的 signal(Node 18+ 的 AbortSignal.timeout,项目 engines 要求 >=22) */
30
54
  export function timeoutSignal(ms) {
31
55
  return AbortSignal.timeout(ms);
32
56
  }
33
- /** 带超时的 fetch(薄封装:超时拒绝时抛 TimeoutError) */
34
- export async function fetchWithTimeout(url, init = {}, ms = 30000) {
35
- return fetch(url, { ...(init || {}), signal: timeoutSignal(ms) });
57
+ /** 调用方给的 signal 两种写法都认(第 4 个参数 / init.signal);不是合法 AbortSignal 就当没给 */
58
+ function pickSignal(signal, init) {
59
+ const cand = signal || (init && init.signal);
60
+ if (!cand)
61
+ return undefined;
62
+ return typeof cand.addEventListener === 'function' && typeof cand.aborted === 'boolean' ? cand : undefined;
63
+ }
64
+ /**
65
+ * 取中止原因(B94):先触发那一方的 reason 原样转发;老运行时 signal 没有 reason 时
66
+ * 按来源补一个**同名**的 DOMException —— 否则内部超时会退化成 AbortError,
67
+ * isTimeoutOnly 就再也认不出「这是超时还是客户端中止」(手写兜底路径的关键)。
68
+ */
69
+ function abortReason(src, name) {
70
+ if (src && src.reason !== undefined)
71
+ return src.reason;
72
+ const D = globalThis.DOMException;
73
+ if (typeof D === 'function') {
74
+ try {
75
+ return new D('aborted: ' + name, name);
76
+ }
77
+ catch { }
78
+ }
79
+ const e = new Error('aborted: ' + name);
80
+ e.name = name;
81
+ return e;
82
+ }
83
+ /**
84
+ * 把「内部超时」和「调用方中止」(客户端断连等外部 signal)合成一个 signal:任一触发都中止外呼。
85
+ *
86
+ * 为什么要合:只用内部超时的话,调用方已经放弃了(客户端 30s 硬超时、切页面、断网),
87
+ * 宿主仍会把付费外呼跑满 ASR_TIMEOUT=60s —— 客户端早就超时重发了一次,等于同一句话付两次钱
88
+ * (2026-11-07 B93 取证)。
89
+ *
90
+ * 两条路:
91
+ * · 有 AbortSignal.any(Node ≥20.3)就直接用:内部按弱引用管理源 signal 的监听,不会在
92
+ * 长期存活的 signal 上留监听;由此派生的 signal 被 fetch 直接使用;
93
+ * · 老 Node 没有 any 时手写组合:把**先触发那一方**的 reason 原样转发(超时仍是 TimeoutError、
94
+ * 外部中止仍是 AbortError,isTimeoutError 的口径不变)。
95
+ * 监听怎么收尾(防重复监听/泄漏):外部 signal 上的转发监听在「触发」或「本次外呼结束」时
96
+ * 立刻摘掉(dispose);超时 signal 上的监听刻意保留到它到点 —— 那个 signal 是本次调用自己的,
97
+ * 而且超时必须继续盖住响应体读取(fetch resolve 之后才算开始读 body)。
98
+ * 代价:老 Node 上「外部中止」只覆盖到响应头返回为止(之后的 body 读取只受内部超时约束);
99
+ * Node ≥20.3 走 any 路径没有这个差别。
100
+ */
101
+ function combineSignals(timeout, outer) {
102
+ // 没有外部 signal:原样交回超时 signal —— 与改动前完全一致(错误仍是 TimeoutError)
103
+ if (!outer)
104
+ return { signal: timeout, dispose() { } };
105
+ const AnySig = AbortSignal;
106
+ if (typeof AnySig.any === 'function') {
107
+ try {
108
+ return { signal: AnySig.any([timeout, outer]), dispose() { } };
109
+ }
110
+ catch { }
111
+ }
112
+ const ctl = new AbortController();
113
+ let hooked = false;
114
+ /** 只摘外部 signal 上的转发监听(超时那份要留到到点,body 读取还要靠它兜底) */
115
+ function unhookOuter() {
116
+ if (!hooked)
117
+ return;
118
+ hooked = false;
119
+ try {
120
+ outer.removeEventListener('abort', onAbort);
121
+ }
122
+ catch { }
123
+ }
124
+ function onAbort(ev) {
125
+ unhookOuter();
126
+ // 事件没带源 signal(少数实现)时按「谁已经中止」判来源:绝不能把超时当调用方中止
127
+ const given = ev && (ev.target || ev.currentTarget);
128
+ const src = given || (timeout.aborted ? timeout : outer);
129
+ try {
130
+ ctl.abort(abortReason(src, src === timeout ? 'TimeoutError' : 'AbortError'));
131
+ }
132
+ catch { }
133
+ }
134
+ // 传进来就已经中止的 signal 不会再发事件:先各自判一次,否则这个请求会一直等超时
135
+ if (timeout.aborted || outer.aborted) {
136
+ const src = timeout.aborted ? timeout : outer;
137
+ try {
138
+ ctl.abort(abortReason(src, src === timeout ? 'TimeoutError' : 'AbortError'));
139
+ }
140
+ catch { }
141
+ return { signal: ctl.signal, dispose() { } };
142
+ }
143
+ hooked = true;
144
+ try {
145
+ timeout.addEventListener('abort', onAbort);
146
+ }
147
+ catch { }
148
+ try {
149
+ outer.addEventListener('abort', onAbort);
150
+ }
151
+ catch { }
152
+ return { signal: ctl.signal, dispose: unhookOuter };
153
+ }
154
+ /**
155
+ * 带超时的 fetch(薄封装:超时拒绝时抛 TimeoutError)。
156
+ * 可选第 4 个参数 signal(也认 init.signal):调用方中止时立刻中断,错误名为 AbortError;
157
+ * 不传时行为与改动前一致。
158
+ */
159
+ export async function fetchWithTimeout(url, init = {}, ms = 30000, signal) {
160
+ const combo = combineSignals(timeoutSignal(ms), pickSignal(signal, init));
161
+ try {
162
+ return await fetch(url, { ...(init || {}), signal: combo.signal });
163
+ }
164
+ finally {
165
+ combo.dispose();
166
+ }
36
167
  }
37
168
  /**
38
169
  * 带超时的 fetch + 一次性读文本(能解析就顺手给 JSON)。
39
170
  * 不因 HTTP 非 2xx 抛错——路由要自己拿 status 决定回什么文案。
171
+ * signal:可选,调用方(如客户端断连)中止时立刻中断外呼。
40
172
  */
41
- export async function fetchJson(url, init = {}, ms = 30000) {
42
- const resp = await fetchWithTimeout(url, init, ms);
173
+ export async function fetchJson(url, init = {}, ms = 30000, signal) {
174
+ const resp = await fetchWithTimeout(url, init, ms, signal);
43
175
  const text = await resp.text().catch(() => '');
44
176
  let data = null;
45
177
  try {
@@ -129,3 +261,74 @@ export async function readBody(req, maxBytes) {
129
261
  throw bodyTooLarge(); // 已经读空,路由这时写 413 能送到客户端
130
262
  return Buffer.concat(chunks);
131
263
  }
264
+ /**
265
+ * 盯住「客户端是不是走了」(B94:从 routes/asr.ts 的私有实现提成公共件,两条 chat 路由共用)。
266
+ * 返回的 signal 交给 host/http.ts 的外呼(fetchWithTimeout / fetchJson 的第 4 个参数):
267
+ * 客户端一走,还在等上游的那次付费请求就立刻中止,不必跑满超时、也不把回复写进记忆。
268
+ * onGone 可选:仅供路由在自己的 controller 上收口(如 /chat-stream 的空闲/总时长 controller),
269
+ * 在 ctl.abort() 之后调用一次,抛错只吞掉。
270
+ *
271
+ * 为什么不能只听 req 的 close(Node 24 本机实测,时间线见 scripts/test-asr-abort.mjs):
272
+ * 请求体读完时 req 就会发 close(此时 req.complete=true、res.writableEnded=false、响应还没写)——
273
+ * 只看 close 会把**每个正常请求**都误判成断连。真正的断连有三种形状:
274
+ * ① req 'aborted':请求体还没收完客户端就断了(Node 24 实测会发,虽已标记废弃);
275
+ * ② req 'close' 且 req.complete === false:①的兜底(老版本/某些客户端不发 aborted);
276
+ * ③ res 'close' 且响应没写完(writableEnded 与 writableFinished 都为 false):客户端发完了
277
+ * body、等不到结果先走了 —— 客户端 30s 硬超时就是这个形状(宿主还在跑 30s 的聊天/60s 的识别)。
278
+ * 正常结束的 res 'close' 里 writableFinished=true,不会命中 ③,所以不会误中止正常请求。
279
+ */
280
+ export function watchClientGone(req, res, onGone) {
281
+ const ctl = new AbortController();
282
+ let gone = false;
283
+ const mark = () => {
284
+ if (gone)
285
+ return;
286
+ gone = true;
287
+ try {
288
+ ctl.abort();
289
+ }
290
+ catch { }
291
+ if (onGone) {
292
+ try {
293
+ onGone();
294
+ }
295
+ catch { }
296
+ }
297
+ };
298
+ const onReqAborted = () => mark();
299
+ const onReqClose = () => { if (!req || req.complete === false)
300
+ mark(); };
301
+ const onResClose = () => { if (!res || !(res.writableEnded || res.writableFinished))
302
+ mark(); };
303
+ const on = (target, ev, fn) => {
304
+ // 测试里的假 req/res 未必是 EventEmitter:没有 on 就静默跳过(既有用例都是这种形状)
305
+ if (!target || typeof target.on !== 'function')
306
+ return;
307
+ try {
308
+ target.on(ev, fn);
309
+ }
310
+ catch { }
311
+ };
312
+ const off = (target, ev, fn) => {
313
+ if (!target || typeof target.removeListener !== 'function')
314
+ return;
315
+ try {
316
+ target.removeListener(ev, fn);
317
+ }
318
+ catch { }
319
+ };
320
+ on(req, 'aborted', onReqAborted);
321
+ on(req, 'close', onReqClose);
322
+ on(res, 'close', onResClose);
323
+ return {
324
+ signal: ctl.signal,
325
+ gone: () => gone,
326
+ dispose() {
327
+ // req/res 是每请求对象,不摘也不会串到下一个请求;但已结束的 handler 没必要继续响应
328
+ // 'close'/'aborted'(多做一次无意义的 abort),排障时也容易看花眼
329
+ off(req, 'aborted', onReqAborted);
330
+ off(req, 'close', onReqClose);
331
+ off(res, 'close', onResClose);
332
+ },
333
+ };
334
+ }
@@ -3,7 +3,7 @@
3
3
  * POST JSON { audio: base64, format: 'webm'|'wav'|'mp3' } → { text }
4
4
  * Key 由入口注入(getKey),路由不再自己读 config.json / 环境变量。
5
5
  * 体积:请求体(base64)上限 12MB → 413;解码后音频上限 8MB → 同样 413(口径统一,2026-11-07)
6
- * 外呼超时 60 秒
6
+ * 外呼超时 60 秒;**客户端断开(30s 硬超时/切页/断网)立刻中止外呼**,不再把付费识别跑满 60s(B93)
7
7
  */
8
8
  import { hashBytes } from '../../shared/hash.js';
9
9
  import { isInterruptWord, isNoiseText } from '../../shared/noise.js';
@@ -20,8 +20,76 @@ const ASR_MAX_BODY = 12 * 1024 * 1024;
20
20
  const ASR_MAX_AUDIO = 8 * 1024 * 1024;
21
21
  /** 外呼超时:60 秒 */
22
22
  const ASR_TIMEOUT = 60 * 1000;
23
+ /**
24
+ * 客户端已经走了的日志标签:与真正的上游超时/网络错误分开,排障时一眼能分清。
25
+ * 分两条是因为两种时机差一步:外呼发出后断开(中止外呼)/ 请求体还没读完就断开(根本没发起外呼)。
26
+ */
27
+ const LOG_CLIENT_ABORT_OUTBOUND = '[asr] ASR-CLIENT-ABORT 客户端已断开,上游外呼已中止(不计入 ASR 失败)';
28
+ const LOG_CLIENT_ABORT_REQUEST = '[asr] ASR-CLIENT-ABORT 客户端已断开,请求中止(未发起外呼,不计入 ASR 失败)';
29
+ /**
30
+ * 盯住「客户端是不是走了」。返回的 signal 会被合进外呼超时(见 host/http.ts 的 fetchWithTimeout),
31
+ * 客户端一走,还在等上游的那次付费外呼就立刻中止。
32
+ *
33
+ * 为什么不能只听 req 的 close(Node 24 本机实测,时间线见 scripts/test-asr-abort.mjs):
34
+ * 请求体读完时 req 就会发 close(此时 req.complete=true、res.writableEnded=false、响应还没写)——
35
+ * 只看 close 会把**每个正常请求**都误判成断连。真正的断连有三种形状:
36
+ * ① req 'aborted':请求体还没收完客户端就断了(Node 24 实测会发,虽已标记废弃);
37
+ * ② req 'close' 且 req.complete === false:①的兜底(老版本/某些客户端不发 aborted);
38
+ * ③ res 'close' 且响应没写完(writableEnded 与 writableFinished 都为 false):客户端发完了
39
+ * body、等不到结果先走了 —— 客户端 30s 硬超时就是这个形状(宿主还在跑 60s 的那次)。
40
+ * 正常结束的 res 'close' 里 writableFinished=true,不会命中 ③,所以不会误中止正常请求。
41
+ */
42
+ function watchClientGone(req, res) {
43
+ const ctl = new AbortController();
44
+ let gone = false;
45
+ const mark = () => {
46
+ if (gone)
47
+ return;
48
+ gone = true;
49
+ try {
50
+ ctl.abort();
51
+ }
52
+ catch { }
53
+ };
54
+ const onReqAborted = () => mark();
55
+ const onReqClose = () => { if (!req || req.complete === false)
56
+ mark(); };
57
+ const onResClose = () => { if (!res || !(res.writableEnded || res.writableFinished))
58
+ mark(); };
59
+ const on = (target, ev, fn) => {
60
+ // 测试里的假 req/res 未必是 EventEmitter:没有 on 就静默跳过(既有用例都是这种形状)
61
+ if (!target || typeof target.on !== 'function')
62
+ return;
63
+ try {
64
+ target.on(ev, fn);
65
+ }
66
+ catch { }
67
+ };
68
+ const off = (target, ev, fn) => {
69
+ if (!target || typeof target.removeListener !== 'function')
70
+ return;
71
+ try {
72
+ target.removeListener(ev, fn);
73
+ }
74
+ catch { }
75
+ };
76
+ on(req, 'aborted', onReqAborted);
77
+ on(req, 'close', onReqClose);
78
+ on(res, 'close', onResClose);
79
+ return {
80
+ signal: ctl.signal,
81
+ gone: () => gone,
82
+ dispose() {
83
+ off(req, 'aborted', onReqAborted);
84
+ off(req, 'close', onReqClose);
85
+ off(res, 'close', onResClose);
86
+ },
87
+ };
88
+ }
23
89
  export function createAsrHandler(opts) {
24
90
  return async function handleAsr(req, res) {
91
+ // 先挂上断连观察器:从读 body 到外呼返回的整段都在它的看护下,结束时必须 dispose(finally)
92
+ const client = watchClientGone(req, res);
25
93
  try {
26
94
  const parsed = await readJsonBody(req, ASR_MAX_BODY);
27
95
  if (!parsed.ok) {
@@ -102,9 +170,18 @@ export function createAsrHandler(opts) {
102
170
  'content-type': 'multipart/form-data; boundary=' + boundary,
103
171
  },
104
172
  body: payload,
105
- }, ASR_TIMEOUT);
173
+ }, ASR_TIMEOUT, client.signal);
106
174
  }
107
175
  catch (err) {
176
+ // 客户端已经走了(30s 硬超时/切页面/断网重发):这次外呼是**我们自己** abort 的,
177
+ // 不是上游超时、也不是网络故障。处理口径:
178
+ // · 不写缓存(没拿到结果,也避免把半截状态记进缓存);
179
+ // · 不记失败(不计入 ASR-TIMEOUT/ASR-NETWORK 的排障口径),只留专用诊断;
180
+ // · 不回包(连接已经断了,没人收;写了也只是往死 socket 上叠一次写)。
181
+ if (client.gone()) {
182
+ console.error(LOG_CLIENT_ABORT_OUTBOUND);
183
+ return;
184
+ }
108
185
  // 超时/网络错误:用户看到的仍是 ok:false + 空文本,日志里留标签
109
186
  if (isTimeoutError(err))
110
187
  console.error('[asr] ASR-TIMEOUT ' + ASR_TIMEOUT + 'ms 无响应,已中止');
@@ -135,9 +212,19 @@ export function createAsrHandler(opts) {
135
212
  }
136
213
  catch (err) {
137
214
  try {
215
+ // 连请求体都没收完客户端就断了(req 'aborted' / ECONNRESET):同上,不算上游失败、不回包
216
+ if (client.gone()) {
217
+ console.error(LOG_CLIENT_ABORT_REQUEST);
218
+ return;
219
+ }
138
220
  sendError(res, err, { text: '' });
139
221
  }
140
222
  catch { }
141
223
  }
224
+ finally {
225
+ // 结束就摘监听:req/res 是每请求对象,不摘也不会串到下一个请求,但已经结束的 handler
226
+ // 没必要继续响应 'close'/'aborted'(多做一次无意义的 abort),排障时也容易看花眼
227
+ client.dispose();
228
+ }
142
229
  };
143
230
  }