20 KiB
断网请求堆积 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。
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"。合并规则:
- 新命令进来 → 立即 abort 同通道在途的旧请求(last-write-wins),旧 Promise 静默 resolve(值已被新意图覆盖,不该向用户报错);
- 每通道至多一个在途请求,从根上杜绝堆积;
- 请求因超时或网络错误失败 → 把该意图存入
pendingIntents,并向调用方 throw(让 UI 能提示); - 因被新命令取代而 abort → 丢弃,不存、不报错;
- 任意设备请求成功(包括轮询)即视为链路恢复 →
onDeviceResponseOk()按通道依次补发最新意图一次;同通道已有用户在途命令时跳过(那个更新);补发失败则意图重新挂起。
读请求(syncData / msgCount / msgAdd / syncPeq)与 PEQ POST:只加超时,不补发。
语义澄清:为什么不是「超时的一律丢弃」
原始设想是「重新联网后只补发最近一次没超时的请求,超时的直接丢弃」。若按字面执行(最后一条也丢弃),会导致 UI 显示 118、设备停在旧值,两者永久失配且用户无从察觉。
因此实现为:超时的「传输」丢弃,但每通道保留最新「意图」,重连后补发一次。既消除音量阶梯突变,又保证最终一致。
3.3 轮询重入保护(方案 3)
client/src/contexts/DeviceContext.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 模式接受连接但永不响应」的假服务器跑断言。
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 长期保留)
// 隔离集成测试:验证设备请求超时 + 状态型指令合并/重连补发 + 动作型指令不补发。
// 假黑洞服务器: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:真机三阶段协作测试
需要两人配合:测试者操作浏览器,另一人手动断网 / 恢复设备网络并在每步之后回报。
前置准备
-
启动 dev server:
cd x8-controller-v2 && pnpm dev,打开http://localhost:3000/?ip=<设备IP>; -
开一个设备可达性探测日志作为时间基准(每秒一次):
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在该固件上即使设备在线也返回空响应(curlexit=52、浏览器ERR_EMPTY_RESPONSE),用它会把在线误判为离线。 -
在页面内安装 fetch 探针(只记录、不改行为),用于取得每条请求的起始时刻 / 耗时 / 结果:
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; } }; -
脚本化拖动音量条的正确姿势(关键坑,见 §5):对 slider 的 wrapper 依次派发
pointerdown/pointermove/pointerup,按下点必须落在圆球中心 ±24px 内,并在 move 与 up 之间await一个宏任务等 React 重渲染: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 脚本化拖动音量条的三个坑
- 按在轨道上无效:
ThumbOnlyRangeSlider对轨道点击/触摸直接忽略(防误触高音量),按下点必须落在圆球中心 ±24px(THUMB_HIT_RADIUS_PX)内,圆球中心按9 + ratio * (trackWidth - 18)计算; - 提交值滞后一步:
onRelease取的是valueRef.current(上次渲染的 prop)。合成事件不等 React 重渲染,必须在pointermove与pointerup之间await一个宏任务,否则提交的是旧值; - 直接改
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