Files
controller-v2/NETWORK_RESILIENCE.md
T
2026-10-09 13:56:41 +08:00

382 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 断网请求堆积 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`