# -*- coding: utf-8 -*- r'''修「经中转 1~2 次交互后永久哑掉」—— 这是"按一下没反应"的真因。 == 复现出来的完整证据链 == 被控端日志里直接抛了未捕获异常,线程当场死掉: 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 线程还活着、还在长轮询 -> 中转那边看着"设备在线" * **手表看到的是"连上了,但按一下毫无反应"** —— 和用户报的现象完全一致 == 根因:一条 HTTP/1.1 连接被两个线程同时用 == _post() 里锁只包住了"取连接"这一段: with self._http_lock: connection = self._http # <- 锁到这里就放了 if connection is None: connection = self._open_http() self._http = connection try: connection.request("POST", path, ...) # <- 锁外发请求 于是两个线程会**同时**用同一条 connection: * relay-link 线程:跑 /v1/agent/poll,那条请求要挂 180 秒(长轮询) * client-relay 线程:同一时间要 POST /v1/agent/send HTTP/1.1 一条连接同一时刻只能有一个在途请求。第二个线程一进来 就撞上 http.client 的状态机,抛 CannotSendRequest("Idle")。 更糟的是 _post 的"重试一次"救不了:它 _drop_http() 之后, 另一个线程手里还攥着那条旧连接的对象在发,照样坏。 == 修法 == 1. **给"发命令"用一条独立的连接**(_post 加 dedicated 参数)。 长轮询那条连接常年被占着,发命令不该去挤它。 两条连接各自用一把锁**锁住整个请求周期**(不是只锁取连接那一下)。 2. **长轮询线程之外的请求,如果发现缓存连接正忙,就临时开一条**, 用完即关 —— 保证任何时刻一条 connection 只有一个在途请求。 3. **server.py 的 _client_loop 里 session.send() 加保护**。 这是最后一层兜底:不管底层出什么错,都不该让 client-relay 线程整个死掉 —— 死一次就永久哑,代价太大。 发送失败时踢掉这个会话(让手表重连),而不是让线程消失。 ''' from __future__ import annotations import sys from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") def patch(path: Path, old: str, new: str, label: str) -> bool: text = path.read_text(encoding="utf-8") if old not in text: print(" ✗ 没找到:%s" % label) return False if text.count(old) != 1: print(" ! 出现 %d 次:%s" % (text.count(old), label)) return False path.write_text(text.replace(old, new, 1), encoding="utf-8", newline="\n") print(" ✓ %s" % label) return True def main() -> int: ok_all = True relay = ROOT / "windows" / "lanremote" / "relay.py" # ---------------------------------------------------------- 1. 加字段 text = relay.read_text(encoding="utf-8") print(" ── 1. 加连接占用标记和发送专用连接 ──") old_fields = " self._http_lock = threading.RLock()" new_fields = """ self._http_lock = threading.RLock() # 缓存的那条连接现在有没有在途请求。 # /v1/agent/poll 是长轮询,一条请求挂 180 秒 —— # 那段时间里这条连接**不能**被别的线程再拿去发请求, # 否则 HTTP/1.1 状态机直接抛 CannotSendRequest("Idle")。 self._http_busy = False # 发命令专用连接。长轮询那条常年被占着, # 发命令不能去挤它 —— 各走各的。 self._http_send = None self._send_lock = threading.RLock()""" if old_fields not in text: print(" ✗ 找不到 _http_lock 字段定义") ok_all = False else: text = text.replace(old_fields, new_fields, 1) relay.write_text(text, encoding="utf-8", newline="\n") print(" ✓ 加了 _http_busy / _http_send / _send_lock") # ---------------------------------------------------------- 2. send_to_client 用专用连接 ok_all &= patch( relay, """ for start in range(0, len(data), 256 * 1024): piece = data[start:start + 256 * 1024] self._post("/v1/agent/send", piece, {"X-Token": self.token}, timeout=POLL_TIMEOUT)""", """ for start in range(0, len(data), 256 * 1024): piece = data[start:start + 256 * 1024] # dedicated=True:走发命令专用连接,不和长轮询挤一条。 # 这是「按一下没反应」的修复点 —— 以前这里和长轮询共用 # 一条 HTTP/1.1 连接,撞上状态机就抛 "Idle", # 异常一路冒到 client-relay 线程外面把它打死。 self._post("/v1/agent/send", piece, {"X-Token": self.token}, timeout=POLL_TIMEOUT, dedicated=True)""", "send_to_client 走专用连接") # ---------------------------------------------------------- 3. _drop_http 也清专用连接 ok_all &= patch( relay, """ def _drop_http(self) -> None: \"\"\"把缓存的连接关掉(出错后调用,下次会重开)。\"\"\" with self._http_lock: connection, self._http = self._http, None if connection is not None: try: connection.close() except Exception: # noqa: BLE001 pass""", """ def _drop_http(self, dedicated: bool = False) -> None: \"\"\"把缓存的连接关掉(出错后调用,下次会重开)。 dedicated=True 时清的是"发命令专用连接", 否则清长轮询用的那条。 \"\"\" if dedicated: with self._send_lock: connection, self._http_send = self._http_send, None if connection is not None: try: connection.close() except Exception: # noqa: BLE001 pass return with self._http_lock: connection, self._http = self._http, None self._http_busy = False if connection is not None: try: connection.close() except Exception: # noqa: BLE001 pass""", "_drop_http 支持专用连接 + 清 busy 标记") # ---------------------------------------------------------- 4. _post 主体 ok_all &= patch( relay, """ def _post(self, path: str, body: bytes, headers: dict, timeout: float = POLL_TIMEOUT, want_headers: bool = False): \"\"\"POST 一个请求,**复用同一条连接**。""", """ def _post(self, path: str, body: bytes, headers: dict, timeout: float = POLL_TIMEOUT, want_headers: bool = False, dedicated: bool = False): \"\"\"POST 一个请求,**复用同一条连接**。""", "_post 加 dedicated 参数") ok_all &= patch( relay, """ last_error = None for attempt in (1, 2): with self._http_lock: connection = self._http if connection is None: connection = self._open_http() self._http = connection try: connection.timeout = timeout""", """ last_error = None for attempt in (1, 2): # 取一条可以独占使用的连接: # * dedicated=True -> 发命令专用连接(自己一把锁锁全程) # * 普通请求 -> 缓存连接;**如果它正忙(长轮询在途) # 就临时开一条,用完即关** # 关键:锁必须盖住整个"发请求 + 读响应"周期, # 不能只锁"取连接"那一下 —— 否则两个线程会用同一条连接。 if dedicated: self._send_lock.acquire() connection = self._http_send if connection is None: connection = self._open_http() self._http_send = connection temporary = False else: with self._http_lock: if self._http is None: self._http = self._open_http() if self._http_busy: # 缓存连接正被长轮询占着,临时另开一条 connection = self._open_http() temporary = True else: connection = self._http self._http_busy = True temporary = False try: connection.timeout = timeout""", "_post 独占连接") ok_all &= patch( relay, """ except urllib.error.HTTPError: raise # HTTP 状态码是业务层的,不算连接问题 except Exception as exc: # noqa: BLE001 # 连接层面的问题:关掉,下一轮重开 last_error = exc self._drop_http() if attempt == 2: break self._log_quiet(f"连接断了,重开一次再试({exc})") raise RelayError(f"请求失败: {last_error}")""", """ except urllib.error.HTTPError: raise # HTTP 状态码是业务层的,不算连接问题 except Exception as exc: # noqa: BLE001 # 连接层面的问题:关掉,下一轮重开 last_error = exc self._drop_http(dedicated=dedicated) if attempt == 2: break self._log_quiet(f"连接断了,重开一次再试({exc})") finally: # 无论成功失败都要把"占用"还回去, # 否则这条连接会被永久标记为忙,之后每次请求都另开临时连接。 if dedicated: self._send_lock.release() elif not temporary: with self._http_lock: self._http_busy = False else: try: connection.close() except Exception: # noqa: BLE001 pass raise RelayError(f"请求失败: {last_error}")""", "_post 用完归还占用") # ---------------------------------------------------------- 5. server.py 兜底 ok_all &= patch( ROOT / "windows" / "lanremote" / "server.py", """ if reply is not None: session.send(reply) except OSError: pass""", """ if reply is not None: # ⚠ 这里必须兜住**所有**异常。 # 以前只在外层捕获 OSError,而 session.send() 在 # 中转链路上会抛 RelayError(实测:"请求失败: Idle")—— # 那个异常一路冒出去,把 client-relay 线程整个打死。 # 线程一死,电脑就再也不往手表回任何东西, # 而 relay-link 还在长轮询、中转看着"设备在线", # 用户看到的就是"连上了,按一下毫无反应"。 # 发失败最多踢掉这个会话让手表重连,绝不能丢线程。 try: session.send(reply) except Exception as exc: # noqa: BLE001 self._log(f"[错误] 回包失败,断开该会话:{exc}") break except OSError: pass except Exception as exc: # noqa: BLE001 self._log(f"[错误] 会话循环异常退出:{exc}")""", "server.py 兜住回包异常") print() if not ok_all: print(" 有失败项") return 1 print(" 都修好了") return 0 if __name__ == "__main__": sys.exit(main())