# 断网请求堆积 Bug 与传输层修复方案 > 适用项目:`x8-controller-v2`(x9 / b9 / bd9 / bh9 **尚未同步**) > 落地日期:2026-09-30 | 真机验证:2026-09-30(设备 192.168.9.127) > 状态:已修复并通过「假黑洞集成测试 + 真机三阶段协作测试」双重验证;未提交 Git、未部署 --- ## 1. 问题现象 设备休眠后有概率断网。此时 controller 端: - 所有操作失效,界面看似正常但设备毫无反应; - 请求**不报错、不超时**,一直挂起; - 设备唤醒并重新联网后,断网期间积压的请求**排着队依次送达设备**。 典型复现:断网期间用户疯狂左右拖动首页音量条 → 设备无反应 → 设备恢复联网后,积压的几十条音量命令按序全部执行 → **设备音量突变**(可能突然拉满)。 --- ## 2. 根因分析:三层时钟,只有操作系统那一层会响 ### 2.1 应用层:等待时间 = ∞ 改造前,`client/src/lib/luxsinApi.ts` 中所有设备请求都是裸 `fetch`: - 全仓设备请求**零 `timeout`**、**零 `AbortController`**(唯一的 AbortController 在 `aiApi.ts` 的云端流式接口,与设备无关); - `setSetting` 只对 `peqSelect` 做串行排队,其余设置项**各自独立发出、互不覆盖**; - 轮询(`msgCount`,3s 一次)**无重入保护**:上一轮没回来,下一轮照样发。 结论:应用层永远不会主动放弃一个请求。 ### 2.2 浏览器层:排队时间 = ∞ HTTP/1.1 对同一 origin 有 **6 条并发连接**上限。断网时这 6 个 socket 全部被挂起请求占死,后续请求进入浏览器 **stalled 队列**,而 stalled **没有超时**。于是请求越积越多。 ### 2.3 操作系统 TCP 层:唯一会响的钟 | 场景 | 行为 | 量级 | | --- | --- | --- | | 新建连接、SYN 无人应答(黑洞) | 指数退避重传 SYN,受 `net.inet.tcp.keepinit` 约束 | macOS 约 **75s** 才失败 | | 已建连接、数据未被确认 | 指数退避重传数据 | **分钟级**预算 | | 连接被拒 / 地址不可达 | 立即 RST / ICMP | **毫秒级** 失败 | 所以「请求会等待多久」的答案是:**应用层 ∞,浏览器层 ∞,实际由 OS TCP 重传预算决定,黑洞场景下是几十秒到几分钟**。 ### 2.4 为什么是「音量突变」而不是「全部失败」 丢弃只发生在 OS 重传预算耗尽之后。**如果设备先恢复联网**,那些还在重传的挂起请求会全部按序成功送达 —— 一条都不少。这正是音量突变的成因: ``` 断网期间:setVolume(10) setVolume(35) setVolume(60) ... setVolume(180) ← 全部挂起 设备恢复:10 → 35 → 60 → ... → 180 ← 按序全部执行,音量阶梯式飙升 ``` ### 2.5 放大因素 - 轮询无重入保护:3s 一条持续挤占连接池; - `isConnected` 在断网时**不翻转**,UI 不提示掉线,用户以为「没点上」而反复操作,进一步加剧积压; - PEQ 串行队列被一个挂起请求堵死后,后续 PEQ 操作全部排在它后面。 --- ## 3. 修复方案 四层修复,全部集中在传输层,业务调用方**零改动**。 ### 3.1 统一超时(方案 1) 所有设备 `fetch` 改走私有 `timedFetch`,到点 `abort` 并抛 `DeviceTimeoutError`。 ```ts export const DEVICE_FETCH_TIMEOUT_MS = 4000; export class DeviceTimeoutError extends Error { constructor(url: string) { super(`Device request timed out: ${url}`); // message 含 "timed out" this.name = "DeviceTimeoutError"; } } ``` 4s 的取值依据:设备在线时正常响应为毫秒级(实测 1–900ms),4s 足以容纳最差抖动;断网时 4s 内失败,用户感知为「这一下没生效」,而不是永久假死。 ### 3.2 指令合并 + 重连补发(方案 2) `setSetting` 按语义**三分流**: | 分流 | 判定 | 行为 | 理由 | | --- | --- | --- | --- | | PEQ 事务 | `params.peqSelect !== undefined` | 走原有串行队列,**只加超时**,不合并不补发 | 事务有时序依赖,合并会破坏语义;加超时保证队列能排空 | | **动作型命令** | 命中 `ACTION_ONLY_SETTING_KEYS` = `{power, bt_play, bt_next}` | **发一次,失败即丢弃,重连后绝不补发** | 语义是「触发一次」。补发会导致用户没按却**意外关机 / 意外切歌**,属安全边界 | | 状态型设置 | 其余(volume、输入源、EQ 开关等) | 按**通道**合并,保留最新意图,重连后补发一次 | 语义是「让它等于 X」,只有最后一个值有意义 | **通道(channel)** = 参数键排序后 join,例如 `{volume:118}` → `"volume"`。合并规则: 1. 新命令进来 → **立即 abort 同通道在途的旧请求**(last-write-wins),旧 Promise **静默 resolve**(值已被新意图覆盖,不该向用户报错); 2. 每通道**至多一个在途请求**,从根上杜绝堆积; 3. 请求因**超时或网络错误**失败 → 把该意图存入 `pendingIntents`,并向调用方 throw(让 UI 能提示); 4. 因**被新命令取代**而 abort → 丢弃,不存、不报错; 5. **任意**设备请求成功(包括轮询)即视为链路恢复 → `onDeviceResponseOk()` 按通道依次补发**最新意图一次**;同通道已有用户在途命令时跳过(那个更新);补发失败则意图重新挂起。 读请求(`syncData` / `msgCount` / `msgAdd` / `syncPeq`)与 PEQ POST:**只加超时,不补发**。 #### 语义澄清:为什么不是「超时的一律丢弃」 原始设想是「重新联网后只补发最近一次没超时的请求,超时的直接丢弃」。若按字面执行(最后一条也丢弃),会导致 **UI 显示 118、设备停在旧值**,两者永久失配且用户无从察觉。 因此实现为:**超时的「传输」丢弃,但每通道保留最新「意图」,重连后补发一次**。既消除音量阶梯突变,又保证最终一致。 ### 3.3 轮询重入保护(方案 3) `client/src/contexts/DeviceContext.tsx`: ```tsx const pollInFlightRef = useRef(false); // ... const poll = async () => { if (pollInFlightRef.current) return; // 上轮未 settle 则跳过本轮 pollInFlightRef.current = true; try { /* getMsgCount → 必要时 syncData / syncPeq */ } finally { pollInFlightRef.current = false; } }; ``` ### 3.4 错误文案识别(方案 4) `client/src/lib/networkError.ts` 的 `NETWORK_FETCH_HINTS` 增加 `"timed out"`,使 `DeviceTimeoutError` 被识别为网络类错误,向用户展示「网络/设备不可达」文案而非通用错误。 ### 3.5 代码位置 | 文件 | 行 | 内容 | | --- | --- | --- | | `client/src/lib/luxsinApi.ts` | 327 | `DEVICE_FETCH_TIMEOUT_MS` | | | 330 | `DeviceTimeoutError` | | | 341 | `ACTION_ONLY_SETTING_KEYS` | | | 343 | `WriteSlot`(controller + timedOut 标记) | | | 352 / 354 / 355 | `pendingIntents` / `inflightWrites` / `flushScheduled` | | | 406 | `setSetting` 三分流 | | | 419 | `sendSettingRequest`(PEQ / 动作型) | | | 433 | `sendCoalescedSetting`(合并 + last-write-wins) | | | 467 | `onDeviceResponseOk`(重连补发) | | | 488 | `timedFetch`(统一超时包装) | | | 384/397/403/423/446/512/540/555/571/587 | 全部 10 个设备请求调用点 | | `client/src/contexts/DeviceContext.tsx` | 62 / 265-278 | 轮询重入保护 | | `client/src/lib/networkError.ts` | 12 | `"timed out"` hint | --- ## 4. 测试 ### 4.1 A:自动化集成测试(假黑洞服务器) 不依赖真机,随时可跑。原理:用 esbuild 把零外部依赖的 `luxsinApi.ts` 打成单文件 CJS,在 node 里对着一个「hang 模式接受连接但永不响应」的假服务器跑断言。 ```bash cd x8-controller-v2 ./node_modules/.bin/esbuild client/src/lib/luxsinApi.ts \ --bundle --format=cjs --outfile=/tmp/luxsin-api.cjs --log-level=error node /tmp/req-timeout-test.mjs ```
测试脚本全文(可存为 scripts/req-timeout-test.mjs 长期保留) ```js // 隔离集成测试:验证设备请求超时 + 状态型指令合并/重连补发 + 动作型指令不补发。 // 假黑洞服务器:hang 模式接受连接但永不响应(模拟设备断网),ok 模式正常 200。 import http from "node:http"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const { LuxsinAPI } = require("/tmp/luxsin-api.cjs"); let mode = "hang"; const received = []; const server = http.createServer((req, res) => { received.push({ mode, url: req.url, t: Date.now() }); if (mode === "hang") return; // 永不响应 const body = req.url.includes("msgCount") ? "5" : ""; res.setHeader("Content-Type", "text/plain"); res.end(body); }); await new Promise((r) => server.listen(8099, "127.0.0.1", r)); const api = new LuxsinAPI("127.0.0.1:8099"); const results = {}; const t0 = Date.now(); const mark = (key) => (v) => { results[key] = v && v.name ? { ok: false, name: v.name, dt: Date.now() - t0 } : { ok: true, dt: Date.now() - t0 }; }; const p1 = api.setVolume(10).then(mark("p1_vol10"), mark("p1_vol10")); await new Promise((r) => setTimeout(r, 200)); const p2 = api.setVolume(20).then(mark("p2_vol20"), mark("p2_vol20")); const p3 = api.powerOff().then(mark("p3_power"), mark("p3_power")); const p4 = api.getDeviceState().then(mark("p4_sync"), mark("p4_sync")); await Promise.all([p1, p2, p3, p4]); // 模拟重连:服务器恢复响应;任意成功请求应触发挂起意图补发 mode = "ok"; const count = await api.getMsgCount(); await new Promise((r) => setTimeout(r, 300)); // 等 flush 完成 const settingInOkMode = received.filter((r) => r.mode === "ok" && r.url.includes("action=setting")).map((r) => r.url); console.log(JSON.stringify({ results, msgCount: count, hangPhaseReceived: received.filter((r) => r.mode === "hang").map((r) => r.url), okPhaseSettingReceived: settingInOkMode, assertions: { p1_superseded_resolves: results.p1_vol10.ok === true && results.p1_vol10.dt < 1000, p2_timeout_4s: results.p2_vol20.ok === false && results.p2_vol20.name === "DeviceTimeoutError" && results.p2_vol20.dt >= 3900 && results.p2_vol20.dt < 5000, p3_action_timeout: results.p3_power.ok === false && results.p3_power.name === "DeviceTimeoutError", p4_read_timeout: results.p4_sync.ok === false && results.p4_sync.name === "DeviceTimeoutError", resend_only_latest_volume: settingInOkMode.length === 1 && settingInOkMode[0].includes("volume=20"), action_never_resent: !settingInOkMode.some((u) => u.includes("power=0")), }, }, null, 2)); server.close(); process.exit(0); ```
结果:**6 项断言全过**(被取代命令 208ms 静默 resolve;p2/p3/p4 均 4209ms 抛 `DeviceTimeoutError`;重连后仅 `volume=20` 一条补发;`power=0` 与 `volume=10` 零补发)。 ### 4.2 B:真机三阶段协作测试 需要两人配合:测试者操作浏览器,另一人**手动断网 / 恢复设备网络**并在每步之后回报。 #### 前置准备 1. 启动 dev server:`cd x8-controller-v2 && pnpm dev`,打开 `http://localhost:3000/?ip=<设备IP>`; 2. 开一个**设备可达性探测日志**作为时间基准(每秒一次): ```bash nohup bash -c 'for i in $(seq 1 1800); do printf "%s " "$(date +%H:%M:%S)" >> /tmp/dev-probe.log; \ curl -s -m 2 -o /dev/null -w "code=%{http_code} t=%{time_total}\n" "http://<设备IP>/msgCount" >> /tmp/dev-probe.log 2>&1 \ || echo "exit=$?" >> /tmp/dev-probe.log; sleep 1; done' >/dev/null 2>&1 & ``` > ⚠️ **必须探测 `/msgCount`**(应用真正轮询的端点)。`/dev/info.cgi?action=msg_count` 在该固件上**即使设备在线也返回空响应**(curl `exit=52`、浏览器 `ERR_EMPTY_RESPONSE`),用它会把在线误判为离线。 3. 在页面内安装 **fetch 探针**(只记录、不改行为),用于取得每条请求的起始时刻 / 耗时 / 结果: ```js window.__origFetch = window.fetch; window.__log = []; window.fetch = async function (...args) { const url = typeof args[0] === 'string' ? args[0] : args[0].url; const t0 = performance.now(); const rec = { startMs: Math.round(t0), url: url.replace('http://<设备IP>', ''), durMs: null, result: null }; window.__log.push(rec); try { const r = await window.__origFetch.apply(this, args); rec.durMs = Math.round(performance.now() - t0); rec.result = 'status=' + r.status; return r; } catch (e) { rec.durMs = Math.round(performance.now() - t0); rec.result = e.name + ': ' + e.message; throw e; } }; ``` 4. **脚本化拖动音量条的正确姿势**(关键坑,见 §5):对 slider 的 **wrapper** 依次派发 `pointerdown` / `pointermove` / `pointerup`,按下点必须落在**圆球中心 ±24px** 内,并在 move 与 up 之间 `await` 一个宏任务等 React 重渲染: ```js const input = document.querySelector('input[type=range]'); const wrapper = input.parentElement; const min = Number(input.min), max = Number(input.max); // 0 / 200 Element.prototype.setPointerCapture = function () {}; // 合成指针无 active pointer,打桩避免抛错 const centerXFor = (rect, v) => rect.left + 9 + ((v - min) / (max - min)) * Math.max(0, rect.width - 18); const pe = (type, x, y) => new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 7, pointerType: 'touch', clientX: x, clientY: y, isPrimary: true }); async function dragTo(target) { const rect = input.getBoundingClientRect(); const y = rect.top + rect.height / 2; wrapper.dispatchEvent(pe('pointerdown', centerXFor(rect, Number(input.value)), y)); wrapper.dispatchEvent(pe('pointermove', centerXFor(rect, target), y)); await new Promise(r => setTimeout(r, 100)); // 等 React 重渲染,否则提交滞后一步的旧值 wrapper.dispatchEvent(pe('pointerup', centerXFor(input.getBoundingClientRect(), target), y)); await new Promise(r => setTimeout(r, 150)); } ``` 音量换算:`dB = (value - 200) * 0.5`(`value=117` → `-41.5dB`)。**测试请用 ±几档的小幅变化**,避免真机突然大音量。 #### Phase A —— 在线基线(设备在网) | 步骤 | 断言 | | --- | --- | | 刷新页面同步真实状态,记录基线音量 | 页面显示设备真实值 | | 拖动 3 次(小幅) | 每次产生 1 条 `action=setting&volume=<精确目标值>` 且 **200**;happy path 未被传输层破坏 | 实测:`setting&volume=120` → 200,`valueAfter=120`(下发值与拖动目标精确一致)。 #### Phase B —— 离线洪泛(**请对方断网**) > ⚠️ **断网期间浏览器页面绝对不能刷新** —— `pendingIntents` 存活于页面内存,刷新即丢失待补发意图,Phase C 就测不到了。 | 步骤 | 断言 | 实测 | | --- | --- | --- | | 连续 5 次拖动:112 → 116 → 124 → 128 → **118**,间隔 ~250ms | 命令数与拖动 1:1,不放大 | `settingTotal=5` | | | 前 4 条被新命令**立即** abort,不等 4s | 各 **~261ms** `AbortError`,且**静默 resolve** | | | 最后一条不无限挂起 | **3950ms** `TypeError` → 转挂起意图 | | | 黑洞轮询被 4s 切断 | **4001 / 4001 / 4002ms** `AbortError`(修复前挂到 OS 预算耗尽,分钟级) | | | 轮询不堆叠 | 严格 **3000ms** 一条,同刻仅 1 条在途 | | | UI 不卡死、本地反馈正常 | `rAF = 0ms`,slider 未禁用,即时显示 -41.0dB | | | 无未捕获异常、无错误轰炸 | 5 次失败仅 **1 条** console error,无 unhandled rejection | #### Phase C —— 重连补发(**请对方恢复联网**) | 断言 | 实测 | | --- | --- | | 链路恢复后自动补发,无需用户再操作 | 首个 `/msgCount` → `200` 之后 **8ms**,自动发出 `action=setting&volume=118` → **200** | | **只补发最后一条意图** | `settingTotal=6` = 5 次拖动 + **1 次补发** | | 被取代的值**永不补发** | `volume=112/116/124/128` 各只出现 **1 次**(原始失败那次) | | 补发**不重复** | 其后 20+ 条成功轮询、1 次 syncData/syncPeq,**再无第二条 setting** | | 设备与 UI 最终一致 | 双方均为 `value=118`(-41.0dB) | 原始时间轴(相对洪泛起点): ``` 102ms setting&volume=112 AbortError(261ms,被 116 取代) 355ms setting&volume=116 AbortError(261ms,被 124 取代) 609ms setting&volume=124 AbortError(262ms,被 128 取代) 862ms setting&volume=128 AbortError(261ms,被 118 取代) 1116ms setting&volume=118 TypeError(3950ms)→ 存入 pendingIntents 1749ms msgCount AbortError(4001ms)← 黑洞被 4s 切断 ... msgCount 每 3000ms 一条,各 1-2ms 快失败,同时只 1 条在途 94750ms msgCount status=200 ← 链路恢复 94758ms setting&volume=118 status=200(38ms) ← 自动补发,仅此一条 ``` #### 收尾 - 把音量**还原到基线**(`setting&volume=117` → 200),并经 `syncData` 回读确认设备侧一致; - 卸载 fetch 探针(`window.fetch = window.__origFetch`)、终止后台探测进程。 ### 4.3 覆盖矩阵 | 场景 | 集成测试 | 真机 | | --- | --- | --- | | 状态型命令合并(last-write-wins) | ✅ | ✅ | | 超时后保留最新意图 | ✅ | ✅ | | 重连仅补发一条最新意图 | ✅ | ✅ | | 读请求超时不补发 | ✅ | ✅(轮询) | | 轮询重入保护 | — | ✅ | | **动作型命令绝不补发** | ✅ | ❌ **未验证** | | PEQ 事务只加超时不合并 | — | ❌ 未验证 | | 断网期间刷新页面 | — | ❌ 未验证(意图必然丢失,见 §5.3) | > 动作型命令未在真机验证的原因:验证它需要真机执行 `power` / `bt_play`,会**真的关机 / 切歌**打断现场。目前该安全边界仅由集成测试断言 `action_never_resent` 覆盖。 --- ## 5. 已知坑与限制 ### 5.1 探测端点选错 → 误判断网 见 §4.2 前置准备第 2 条。`action=msg_count` 在该固件上始终空响应;判断在网一律用 `/msgCount`。 ### 5.2 脚本化拖动音量条的三个坑 1. **按在轨道上无效**:`ThumbOnlyRangeSlider` 对轨道点击/触摸**直接忽略**(防误触高音量),按下点必须落在圆球中心 ±24px(`THUMB_HIT_RADIUS_PX`)内,圆球中心按 `9 + ratio * (trackWidth - 18)` 计算; 2. **提交值滞后一步**:`onRelease` 取的是 `valueRef.current`(上次渲染的 prop)。合成事件不等 React 重渲染,必须在 `pointermove` 与 `pointerup` 之间 `await` 一个宏任务,否则提交的是旧值; 3. **直接改 `input.value` + 派 `change` 事件不会产生任何请求**:音量提交走 `onRelease`(指针抬起),不走 `onChange`;`onChange` 只更新本地显示值。另外原生 input 已被设为 `pointer-events: none`,必须对 wrapper 派发事件;合成指针没有 active pointer,需把 `setPointerCapture` 打桩为 no-op。 ### 5.3 待补发意图是内存态 `pendingIntents` 存活于页面的 `LuxsinAPI` 实例内存中。**断网期间刷新页面 = 永久丢失待补发意图**,恢复后设备停留在旧值而 UI 显示新值。若需覆盖该场景,需另做持久化(如 localStorage)设计,或在页面加载后强制以设备状态为准回写 UI。 ### 5.4 `isConnected` 仍不随断网翻转 本次修复解决的是「请求堆积 + 状态突变」,**未**改动连接态判定:断网期间 UI 仍显示为已连接(只有单次操作失败提示)。若要显式掉线提示,可基于连续 N 次轮询失败来翻转 `isConnected`,属独立议题。 ### 5.5 未同步到其他项目 x9 / b9 / bd9 / bh9 仍是裸 `fetch`,同样存在本 bug。同步时需按项目核对端点与设置键差异(尤其 `ACTION_ONLY_SETTING_KEYS` 的覆盖面),不可直接整文件覆盖。 --- ## 6. 相关文件 - 传输层实现:`client/src/lib/luxsinApi.ts` - 轮询重入保护:`client/src/contexts/DeviceContext.tsx` - 错误文案识别:`client/src/lib/networkError.ts` - 离线态截图:`../screenshots/x8-offline-phaseB.png` - 恢复态截图:`../screenshots/x8-online-phaseC-restored.png`