修「局域网连接被中转劫持」+ 长轮询 15s→60s

用户报两个问题:
  1. 局域网能搜到电脑,IP/端口/配对码都对,点连接**没有任何反应**
  2. 走中转 1426ms 延时,一会儿就 502

========== 一、局域网被劫持(真 bug)==========

MainActivity.connect() 里:

    boolean usedLan = !relayUrl().isEmpty() ? false : true;
    if (!relay.isEmpty()) {
        reply = client.connectViaRelay(...);      // 走中转
    } else {
        reply = client.connect(host, port, ...);  // 才走局域网
    }

**只看「中转设置里填没填地址」,填了就一律走中转,
完全无视用户填的局域网 IP 和端口。**

而中转单次超时是 40 秒 —— 用户点完就卡在「正在连接…」
四十秒,在他看来就是"没有任何反应"。

影响两条路:
  * 「局域网连接」页面填 IP 直连(connectLanTyped)
  * 首页「已配对的电脑」点一下重连(quickConnect)

修法:给 connect() 加 preferLan 参数。明确要走局域网时
**先直连(6 秒超时,局域网本该是毫秒级),失败再退回中转**,
保证最终能连上。

顺带两个体验改进:
  * 设备列表里,保存的 IP 和手表不同网段时标「⚠ 地址可能已过期」
    (用户截图里那个 192.168.2.64 就是过期地址,电脑早换成
      192.168.10.103 了)
  * quickConnect 判断出地址过期就跳过直连,直接走中转查最新地址 ——
    省掉那 6 秒白等

========== 二、长轮询 15 秒 -> 60 秒 ==========

实测隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源):

    本机直连 60 秒的 poll  ->  60.0s
    走公网同一个 poll      ->  61.5~62.0s

这个开销是**按次数**收的:
    15 秒一循环 -> 一天 5760 次/端,光路费 2.4~3.2 小时
    60 秒一循环 -> 一天 1440 次/端,光费 0.6~0.8 小时
而且请求越频繁,撞上丢包/重连窗口的概率越大。

**不会让命令变慢**:长轮询是"有数据立刻返回"(device.event 唤醒),
拉长只是减少空转重开。实测 60 秒长轮询穿隧道 4/4 通过;
Cloudflare 单请求上限 100 秒,留了 40 秒余量。

三层同步改(客户端超时必须显著大于服务端 hold):
  * relay/relay.py                    RELAY_POLL 默认 25 -> 60
  * windows/lanremote/relay.py        POLL_TIMEOUT 40 -> 90
  * android .../RelayTransport.java   POLL_TIMEOUT_MS 40000 -> 90000

========== 三、A/B 实测(还在跑更严谨的一轮)==========

短测发现基线 http2+4 虽然 100% 成功,但 **P90 是 16.8 秒**
(每 10 次有 1 次卡十几秒 —— 正是用户说的"没反应"),
而 quic+6 的 P90 只有 2.8 秒。正在用真实长轮询模式
跑 10 分钟对照确认。
This commit is contained in:
xmz7891
2026-10-07 14:11:33 +08:00
parent 94252ca156
commit b1119d0da6
16 changed files with 1455 additions and 8 deletions
@@ -658,7 +658,16 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
final String token = Json.str(device, "token", "");
final String label = Json.str(device, "label", host);
Button button = bigButton(label + "\n" + host + ":" + port, C_CARD, 14);
// 保存的地址可能已经过期(电脑换了网络/网段)。
// 和手表不在同一网段时直接在按钮上标出来 ——
// 不然用户点下去只会等 6 秒直连失败 + 40 秒中转超时,
// 完全不知道问题出在哪儿。
String stale = "";
if (!host.isEmpty() && !hostInMySubnet(host)) {
stale = " ⚠ 地址可能已过期";
}
Button button = bigButton(label + "\n" + host + ":" + port + stale,
C_CARD, 14);
button.setGravity(Gravity.CENTER_VERTICAL | Gravity.LEFT);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(58));
@@ -2191,8 +2200,10 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
lastCode = code;
lanDirect = true; // 断了自动切回中转
appendLog("局域网直连 " + host + ":" + port);
// preferLan = true:用户就是冲着"局域网直连"来的,
// 必须真的直连,不能因为有中转地址就偷偷走中转。
connect(host, port, code, null,
DeviceStore.deviceKey(host, port, store.deviceId()));
DeviceStore.deviceKey(host, port, store.deviceId()), true);
}
// ==================================================================
@@ -2363,7 +2374,42 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
// 不再往输入框里写东西(那两个框已经删了)——
// 直接连。host/port 对中转路径没有意义,
// 但 key 仍然用来区分"哪台已配对的电脑"。
connect(host, port, null, token, key);
//
// 先判断这个保存下来的地址还有没有意义:
// 电脑换过网络的话(比如从 192.168.2.x 换到 192.168.10.x),
// 直连必然失败。那就别浪费那 6 秒,直接走中转 ——
// 中转连上后会报回电脑的最新局域网地址,记录自动刷新。
boolean sameNet = host.isEmpty() || hostInMySubnet(host);
if (!sameNet) {
appendLog("记录的地址 " + host + " 和手表不在同一网段"
+ "(电脑可能换了网络),直接走中转查最新地址");
toast("电脑地址已过期,正在通过中转查询最新地址…");
}
// preferLan = true 时先按记录里的地址直连,失败自动退回中转。
connect(host, port, null, token, key, sameNet);
}
/**
* host 是不是和手表在同一个 /24 网段。
*
* <p>读不到手表自己的网卡时返回 true(保守:宁可按原地址试一次,
* 也不要因为拿不到权限就误判成"过期",那会让本可以直连的场景
* 白走一趟中转)。
*/
private boolean hostInMySubnet(String host) {
if (host == null || host.isEmpty()) {
return true;
}
java.util.List<String> mine = localIpv4List();
if (mine.isEmpty()) {
return true;
}
for (String myIp : mine) {
if (sameSubnet(host, myIp)) {
return true;
}
}
return false;
}
/** 手表自己在局域网里的 IPv4 地址(可能多张网卡)。拿不到就空。 */
@@ -2462,6 +2508,23 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
private void connect(final String host, final int port, final String code,
final String token, final String key) {
connect(host, port, code, token, key, false);
}
/**
* @param preferLan 明确要求"先走局域网"。
*
* <p>用户点「局域网连接」填了 IP,或者点首页已配对电脑时,
* 应该先按他给的地址直连。原来的实现只看中转地址填没填 ——
* 填了就一律走中转,**完全无视用户填的局域网 IP**,
* 而中转超时 40 秒,用户点完只看到「正在连接…」,以为没反应。
*
* <p>设 true 时:先直连(局域网该是毫秒级,超时给 6 秒),
* 不成再退回中转,保证最终能连上。
*/
private void connect(final String host, final int port, final String code,
final String token, final String key,
final boolean preferLan) {
if (busy) {
return;
}
@@ -2495,7 +2558,32 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
try {
final String relay = relayUrl();
Map<String, Object> reply;
if (!relay.isEmpty()) {
if (preferLan) {
// ---- 用户明确要走局域网:先直连,失败再退中转 ----
// 局域网直连本该是毫秒级,所以超时给短一点。
// 原来这里会走中转(40 秒超时),用户以为卡死了。
appendLog("局域网直连 " + host + ":" + port + " …");
reply = client.connect(host, port, code, token,
MainActivity.this, 6000);
ok = Json.bool(reply, "ok", false);
if (ok) {
usedLan = true;
} else if (!relay.isEmpty()) {
String lanWhy = Json.str(reply, "error",
"局域网直连失败");
appendLog("局域网直连不成(" + lanWhy
+ "),改走中转…");
ui.post(new Runnable() {
@Override
public void run() {
setStatus("局域网连不上,改用中转…", C_WARN);
}
});
reply = client.connectViaRelay(relay, code, token,
MainActivity.this, 40000);
usedLan = false;
}
} else if (!relay.isEmpty()) {
// 跨网络:电脑和手表都在 NAT 后面,直连不可能,
// 两边各自连出去、由中转按配对码对接。
// 走公网要几个 HTTP 往返,超时给宽一点。
@@ -36,8 +36,14 @@ public class RelayTransport implements Transport {
private static final int FLUSH_BYTES = 32 * 1024;
/** 攒不住了也要发,别让命令卡住 */
private static final int FLUSH_MILLIS = 40;
/** 长轮询:中转那边挂 25 秒,客户端超时给宽一点 */
private static final int POLL_TIMEOUT_MS = 40000;
/**
* 长轮询:中转那边挂 60 秒,客户端超时给宽一点。
*
* <p>必须显著大于服务端的 hold 时间,否则会在服务端正常返回
* 之前自己先超时 —— 那就变成"每次轮询都失败"了。
* 90 秒 = 60 + 30 秒余量。
*/
private static final int POLL_TIMEOUT_MS = 90000;
/** 发送/接收的单次请求超时 */
private static final int SEND_TIMEOUT_MS = 30000;
/** 网络出错后的退避 */