Files

382 lines
20 KiB
Markdown
Raw Permalink Normal View History

2026-10-09 13:56:41 +08:00
# 断网请求堆积 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
```
<details>
<summary>测试脚本全文(可存为 scripts/req-timeout-test.mjs 长期保留)</summary>
```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);
```
</details>
结果:**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`