修「经中转几次后永久哑掉」——「按一下没反应」的真因

== 现象 ==

用户报：
  * 局域网能搜到电脑，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 就是字符串里的裸换行 ->
   整段 <script> 不执行 -> 改码/删除/批量删除/全选全变幽灵按键。
   元凶是我加改码功能那次提交。改成 `\\n`。

3. **--relay / --relay-status 崩溃**（严重）
   RelayLink.start(url) 的 url 是必填参数，我把默认地址改空白时
   run_agent.py 两处调用没跟着改。一处被 try 吞成「测试失败：missing
   1 required positional argument」（用户以为地址填错了），
   一处直接报栈。两处都补上 url。

4. 非法 deviceId（控制字符 / 路径分隔符）现在被拒（白名单：
   字母数字 + - _ . :）

5. enable_relay() 第二次调用不再谎报成功（加 RelayLink.running 属性，
   返回值反映真实运行状态；不再用「对象建过没有」判断）

6. snapshot() 清孤儿码后补 _save()，磁盘和内存不再脱节

== 局域网全面优化（task-2，另一个智能体完成）==

4 个真问题：
  * **网段判断写死 /24** —— 在 /16 网络上 10.8.1.5 和 10.8.2.5
    被判成不同网络，明明能直连却去走中转还费流量。
    改成按网卡真实掩码比较。
  * **广播只发一个包** —— 丢一个就整轮搜不到。改成 5 个时间点重发。
  * **只探一个网段** —— 多网卡手表搜不到另一网段的电脑。改成全探。
  * **走中转之后再也回不到局域网** —— 出门再回家永远挂中转。
    新增定期回切（先便宜地 TCP ping，通了才动真格，
    不破坏正在工作的中转连接）。

实测：局域网握手 2ms、50/50 = 100%、中位 0ms。
（跨网络那半之前拿不到数字，就是被上面那个线程崩溃挡住的 ——
  现已修复。）

顺带：instance.py 单实例锁端口加 YKXMZ_LOCK_PORT 覆盖（默认不变）。
用户自己那台被控端正跑在这台机器上占着 8767/TCP + 8766/UDP，
是这次排查里好几个「测试怎么都不通」的根因。

== 服务端一键部署脚本（task-1，另一个智能体完成）==

relay/deploy.sh + relay/deploy-tune.sh，随服务端 zip 发布：
  * 自动检查并安装 cloudflared
  * 部署程序、装 4 个 systemd 服务、网络调优、配隧道、自检打公网地址
  * **不传 --hostname 且没有 TUNNEL_TOKEN 时明确提示要填**，
    不会静默用一个写死的域名（用户明确说过这个中转站是他自己用的，
    不对外开放，绝不能把私人域名烧成默认值）
  * 重跑 = 升级，已有数据/配对码/管理密码/凭据都不动

顺带修了发布包一直缺 site_shots/ 的老 bug
（package_remote.py 用 iterdir()+is_file() 收文件，目录进不去，
新机器装完官网截图全是 404）。

== 默认中转地址改成空白 ==

用户要求：软件默认不带任何中转地址，由用户自己填。
四处硬编码全部清空（server.py / MainActivity.java / run_agent.py /
relay.py 的注释和文档示例），全仓已无非注释的硬编码地址。

== 文档同步（task-5，另一个智能体完成）==

README / BEFORE-INSTALL / AFTER-INSTALL / 打包模板：
  * 新增「软件里不带任何中转地址」告示，讲清跨网络要自己部署
  * 修正 Android 版本（写的是 9.0/API28，实际 API 24 / Android 7.0）
  * 删掉「地址已经内置好了」（现在是错的）
  * AGENT_README 原来把作者自己的中转地址当成「内置地址」发给用户，已重写
  * RELAY_README 原来写「两条隧道同时跑会互相争抢」，
    和真机/CHANGELOG 矛盾；按实测改成「两种协议同时在跑（故意的）」
