修「经中转几次后永久哑掉」——「按一下没反应」的真因 == 现象 == 用户报: * 局域网能搜到电脑,IP/端口/配对码都对,连上后**没有任何反应** * 走中转「已配对」,但点按钮没反应 局域网那一半已经修过(preferLan)。这次抓到的是**经中转**那一半, 而且比预想的严重得多。 == 证据链(插桩复现出来的)== 被控端日志里直接抛了未捕获异常,线程当场死: Exception in thread client-relay: server.py:407 session.send(reply) relay.py:166 self._link.send_to_client(data) relay.py:357 self._post("/v1/agent/send", ...) relay.py:766 raise RelayError(f"请求失败: {last_error}") lanremote.relay.RelayError: 请求失败: Idle 之后的状态: * client-relay 线程没了 —— 电脑再也不往手表回任何东西 * relay-link 线程还活着、还在长轮询 —— 中转那边看着「设备在线」 * **手表看到的就是「连上了,但按一下毫无反应」** 写入仓库的测试 _tests/test_relay_local.py 一直是这个形状: 前面几项 PASS、跑到经中转发命令就卡死。之前没人深挖。 == 根因 1:一条 HTTP/1.1 连接被两个线程同时用 == _post() 里锁只包住「取连接」那一段: with self._http_lock: connection = self._http # 锁到这里就放了 try: connection.request("POST", ...) # 锁外发请求 于是两个线程会同时用同一条 connection: * relay-link 线程跑 /v1/agent/poll —— 长轮询,一条请求挂 180 秒 * client-relay 线程同时 POST /v1/agent/send HTTP/1.1 一条连接同一时刻只能有一个在途请求,第二个线程一进来 就撞上 http.client 状态机,抛 CannotSendRequest("Idle")。 _post 的「重试一次」也救不了:它 _drop_http() 之后,另一个线程 手里还攥着那条旧连接对象在发,照样坏。 修法: * **发命令用独立连接**(_post 加 dedicated 参数)。 长轮询那条常年被占着,发命令不该去挤它,各走各的。 * 两条连接各自一把锁**锁住整个「发请求 + 读响应」周期**, 不再只锁取连接那一下。 * 普通请求若发现缓存连接正忙(长轮询在途),临时另开一条用完即关。 * _drop_http(dedicated=) 区分清哪条;finally 里一定归还占用 (否则那条连接会被永久标记为忙)。 == 根因 2:回包失败会打死整个会话线程 == server.py 的 _client_loop 里: if reply is not None: session.send(reply) # <- 不在任何 try 里 ... except OSError: # <- 外层只捕获 OSError pass 而中转链路上 send 抛的是 RelayError,直接冒出去把线程打死。 **死一次就永久哑**,代价太大。 修法:session.send() 单独兜住所有异常,失败就断开这个会话 (让手表重连),绝不让线程消失。外层也补一个兜底 except。 == 修复效果 == 修复前:pair OK -> ping OK -> ping 卡死 -> 线程崩溃,永久哑 修复后:pair 21ms -> ping 82ms -> ping 4ms -> ping 6ms,零异常 _tests/test_relay_local.py:**之前完全卡死 -> 现在 21/21 全过** 这个测试之前一直是红的,说明「35 个阶段的回归全绿」这件事 本身就不成立过 —— 某个阶段卡住时看起来像环境问题而不是缺陷。 == 同批修掉验证阶段找到的其它问题 == 1. **TTL 清理 + 关掉接入开关 = 设备永久锁死**(严重) register() 用 `device_id in self.devices` 判断老设备,但超时清理 把设备从 devices 和 codes 都删了。电脑关机过夜(必然 > 6 小时) 第二天开机就会被自己的开关挡住,而且**重启也救不回来** (清 codes 时没落盘,重启后又当孤儿清一遍,自我维系)。 管理员在页面上还看不到这台设备,不知道该放开谁。 修法:状态文件里单独存 knownDevices 设备历史,超时清理绝不动它; register 改查这份历史;清理后补 _save();delete_device 才真正忘记。 2. **管理页 JS 语法错误 -> 所有按钮失效**(严重) admin.py 里 ADMIN_JS 是普通三引号字符串,我在 Python 源码里写的 `\n` 被变成真实换行,拼进 JS 就是字符串里的裸换行 -> 整段