Files
ykxmz/android/src/com/lanremote/watch/RelayTransport.java
T
xmz7891 b2bfa6ffbf 长轮询客户端超时 120 -> 180 秒(实测出现 126.6 秒的长尾)
relay-stability 跑了 30 次真实长轮询,第 1 次耗时 126604ms:

    服务端 hold 是 60 秒
    剩下 66 秒全是网络停顿(4G 抖动 + 重传退避)
    而客户端超时写的是 120000ms
    -> 126.6 > 120,那次会被**误判成失败**,尽管服务端已经正常返回

其余 29 次都是 61~62 秒,很正常。不是 hold 太长,是网络尾巴太长。

为什么放宽超时而不是缩短 hold:
  A. hold 缩到 45 秒、超时保持 120 -> 最坏 111 秒,安全
     但请求数多 33%,而每次请求都要吃隧道 1~2 秒开销
  B. hold 保持 60 秒、超时放宽到 180 -> 最坏 180 秒,安全
选 B —— 这条链路的根本问题是丢包抖动,**请求越少越不容易撞上**。
放宽超时只是"别把慢但成功的请求冤枉掉",不增加任何请求。

客户端已有"连续失败 3 次才判离线"(OFFLINE_AFTER=3),
所以偶尔一次长等待不会让界面闪"已断开"。

超时余量的演进(全是实测逼出来的):
  90 秒  -> 实测 93589ms  不够
  120 秒 -> 实测 126604ms 不够
  180 秒 -> 60 hold + 120 余量

两边同步改:
  windows/lanremote/relay.py                     POLL_TIMEOUT 120 -> 180
  android .../RelayTransport.java                POLL_TIMEOUT_MS 120000 -> 180000
(Java 侧确认 setConnectTimeout 另有 15 秒上限、setReadTimeout 吃这个值,
  没有别的超时会先把请求掐掉。)
2026-10-07 17:36:20 +08:00

559 lines
20 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.lanremote.watch;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
/**
* 走中转服务器的传输层(跨网络用)。
*
* 原理:
* 手表没法直连电脑(两边都在 NAT 后面),所以两边都主动连出去:
*
* 手表 --HTTPS--> 中转服务器 <--HTTPS-- 电脑
*
* 中转只按配对码把两条连接对上,中间传的是**原始字节**,它不认识内容。
* 所以电脑那边的配对、令牌校验、权限判断全部继续生效 ——
* 中转被攻破也进不了用户的电脑。
*
* 为什么用长轮询而不是 WebSocket:
* 长轮询在任何代理 / 4G 网络下都最稳,而且 Android 自带
* HttpURLConnection 就能做,不需要引任何第三方库
* (这个项目手表端是**零第三方依赖**的纯 Java)。
*
* 数据流:
* 上行(手表->电脑):write() 攒进缓冲区 -> 发送线程 POST /v1/client/send
* 下行(电脑->手表):轮询线程 GET 长轮询 -> 塞进 inbox -> read() 取走
*/
public class RelayTransport implements Transport {
/** 单次发送攒够这么多就立刻发,别等定时器 */
private static final int FLUSH_BYTES = 32 * 1024;
/** 攒不住了也要发,别让命令卡住 */
private static final int FLUSH_MILLIS = 40;
/**
* 长轮询:中转那边挂 60 秒,客户端超时给宽一点。
*
* <p>必须显著大于服务端的 hold 时间,否则会在服务端正常返回
* 之前自己先超时 —— 那就变成"每次轮询都失败"了。
*
* <p>180 秒 = 60(hold) + 120(网络余量)。
* 余量一直在加大,因为实测的长尾一直在刷新:
* 90 秒时实测到 93589ms,120 秒时实测到 126604ms
* (30 次里 1 次,其余都是 61~62 秒)。
* 4G 丢包触发的重传退避能拖出一分钟以上的停顿,这是链路特性。
* 放宽超时只是"别把慢但成功的请求冤枉掉",不增加请求数。
*/
private static final int POLL_TIMEOUT_MS = 180000;
/** 发送/接收的单次请求超时 */
private static final int SEND_TIMEOUT_MS = 30000;
/** 网络出错后的退避 */
private static final int RETRY_MIN_MS = 1500;
private static final int RETRY_MAX_MS = 15000;
/**
* 连续失败几次才判定"离线"。
*
* <p>单次失败就报离线太敏感:4G 链路上随手就有 2~4% 的偶发失败,
* 而链路通常一两秒就自己恢复,界面却已经闪了一下"已断开"。
* 改成连续 3 次(至少 4.5 秒都不通)才判离线 ——
* 真的断了仍然会如实显示。
*/
private static final int OFFLINE_AFTER = 3;
/** 单次读到的字节上限 */
private static final int MAX_BODY = 8 * 1024 * 1024;
private final String baseUrl;
private final String pairingCode;
private final String clientName;
private volatile String session = "";
private volatile String deviceName = "";
private volatile boolean closed;
/** 是否被另一台手表顶替(这种情况下不该重连) */
private volatile boolean replaced;
private volatile String lastError = "";
private volatile boolean online;
/** 收到的字节,按块排队,read() 按需取 */
private final LinkedBlockingQueue<byte[]> inbox =
new LinkedBlockingQueue<byte[]>();
private byte[] current;
private int currentOffset;
/** 待发送的字节 */
private final ByteArrayOutputStream pending = new ByteArrayOutputStream();
private final Object pendingLock = new Object();
private Thread pollThread;
/** 连续失败次数(成功一次就清零)。 */
private volatile int failStreak;
private Thread sendThread;
private final Object closingLock = new Object();
public RelayTransport(String baseUrl, String pairingCode, String clientName) {
String url = baseUrl == null ? "" : baseUrl.trim();
while (url.endsWith("/")) {
url = url.substring(0, url.length() - 1);
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://" + url;
}
this.baseUrl = url;
this.pairingCode = pairingCode == null ? "" : pairingCode.trim();
this.clientName = clientName == null || clientName.isEmpty()
? "手表" : clientName;
}
public String getDeviceName() {
return deviceName;
}
public String getLastError() {
return lastError;
}
public boolean isOnline() {
return online && !closed;
}
/** 是不是被另一台手表接管了。界面据此提示"已被接管"而不是"连接失败"。 */
public boolean isReplaced() {
return replaced;
}
public String getBaseUrl() {
return baseUrl;
}
// ------------------------------------------------------------------
// Transport
// ------------------------------------------------------------------
@Override
public InputStream input() {
return new InputStream() {
@Override
public int read() throws IOException {
byte[] one = new byte[1];
int got = read(one, 0, 1);
return got <= 0 ? -1 : (one[0] & 0xFF);
}
@Override
public int read(byte[] buffer, int offset, int length)
throws IOException {
if (length <= 0) {
return 0;
}
while (current == null || currentOffset >= current.length) {
if (closed) {
return -1;
}
try {
current = inbox.poll(500, TimeUnit.MILLISECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return -1;
}
currentOffset = 0;
}
int available = current.length - currentOffset;
int take = Math.min(available, length);
System.arraycopy(current, currentOffset, buffer, offset, take);
currentOffset += take;
if (currentOffset >= current.length) {
current = null;
currentOffset = 0;
}
return take;
}
@Override
public int available() {
int total = 0;
if (current != null) {
total += current.length - currentOffset;
}
for (byte[] piece : inbox) {
total += piece.length;
}
return total;
}
};
}
@Override
public OutputStream output() {
return new OutputStream() {
@Override
public void write(int value) {
write(new byte[]{(byte) value}, 0, 1);
}
@Override
public void write(byte[] buffer, int offset, int length) {
if (length <= 0) {
return;
}
synchronized (pendingLock) {
pending.write(buffer, offset, length);
if (pending.size() >= FLUSH_BYTES) {
pendingLock.notifyAll();
}
}
}
};
}
@Override
public boolean isClosed() {
return closed;
}
@Override
public void close() {
synchronized (closingLock) {
if (closed) {
return;
}
closed = true;
}
// 尽量把手里的东西发出去,再把会话摘掉
flushNow();
if (!session.isEmpty()) {
try {
request("/v1/client/detach", new byte[0], true, 8000);
} catch (IOException ignored) {
// 断网时摘不掉也没关系,中转会自己清
}
}
if (pollThread != null) {
pollThread.interrupt();
}
if (sendThread != null) {
sendThread.interrupt();
}
synchronized (pendingLock) {
pendingLock.notifyAll();
}
}
@Override
public String describe() {
return "中转服务器";
}
public String getSession() {
return session;
}
// ------------------------------------------------------------------
// 连接
// ------------------------------------------------------------------
/** 接入中转。成功返回 true。 */
/** 被控端上报的局域网 IP(可能多个网卡)。判断同网段用。 */
private java.util.List<String> lanIps = new java.util.ArrayList<String>();
/** 被控端监听端口。0 表示它没报(那就没法直连)。 */
private int lanPort;
public java.util.List<String> getLanIps() {
return lanIps;
}
public int getLanPort() {
return lanPort;
}
public boolean attach() {
try {
String body = "{\"code\":\"" + Json.escape(pairingCode)
+ "\",\"name\":\"" + Json.escape(clientName) + "\"}";
HttpResult result = request("/v1/client/attach",
body.getBytes(RemoteClient.UTF8), false, 30000);
if (result.code != 200) {
lastError = parseError(result.body, "接入被拒(" + result.code + ")");
return false;
}
java.util.Map<String, Object> reply =
Json.parseObject(new String(result.body, RemoteClient.UTF8));
if (!Json.bool(reply, "ok", false)) {
lastError = Json.str(reply, "error", "接入被拒");
return false;
}
session = Json.str(reply, "session", "");
deviceName = Json.str(reply, "deviceName", "");
// 中转顺便告诉我们「这台电脑在哪个局域网、开在哪个端口」。
// 手表拿它判断要不要切成局域网直连。
lanIps = Json.stringList(reply, "lanIps");
lanPort = Json.integer(reply, "port", 0);
if (session.isEmpty()) {
lastError = "中转没给会话令牌";
return false;
}
online = true;
lastError = "";
} catch (Exception exc) {
lastError = "连不上中转:" + exc.getClass().getSimpleName()
+ (exc.getMessage() == null ? "" : " " + exc.getMessage());
return false;
}
pollThread = new Thread(new Runnable() {
@Override
public void run() {
pollLoop();
}
}, "relay-poll");
pollThread.setDaemon(true);
pollThread.start();
sendThread = new Thread(new Runnable() {
@Override
public void run() {
sendLoop();
}
}, "relay-send");
sendThread.setDaemon(true);
sendThread.start();
return true;
}
// ------------------------------------------------------------------
// 轮询:电脑 -> 手表
// ------------------------------------------------------------------
private void pollLoop() {
int backoff = RETRY_MIN_MS;
while (!closed) {
try {
HttpResult result = request("/v1/client/poll", new byte[0], true,
POLL_TIMEOUT_MS);
if (result.code == 409) {
// 被另一台手表接管了。这里**必须断开**,不能重连 ——
// 否则两台手表会互相把对方顶掉,永远握不上手。
online = false;
lastError = "已被另一台手表接管";
replaced = true;
closed = true;
return;
}
if (result.code == 401 || result.code == 403) {
// 会话过期(中转重启等)—— 这种可以自动重连
online = false;
lastError = "会话已失效,正在重新接入";
if (!reattach()) {
sleep(backoff);
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
continue;
}
backoff = RETRY_MIN_MS;
continue;
}
if (result.code != 200) {
// 偶发的 502/504 不算断线,连续几次才算
failStreak++;
if (failStreak >= OFFLINE_AFTER) {
online = false;
}
lastError = "轮询返回 " + result.code;
sleep(backoff);
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
continue;
}
online = true;
failStreak = 0;
lastError = "";
backoff = RETRY_MIN_MS;
if (result.body.length > 0) {
inbox.offer(result.body);
}
} catch (Exception exc) {
if (closed) {
return;
}
// 连接超时/重置很常见(隧道在重连),连续几次才算真断
failStreak++;
if (failStreak >= OFFLINE_AFTER) {
online = false;
}
lastError = "轮询出错:" + exc.getClass().getSimpleName();
sleep(backoff);
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
}
}
}
private boolean reattach() {
try {
String body = "{\"code\":\"" + Json.escape(pairingCode)
+ "\",\"name\":\"" + Json.escape(clientName) + "\"}";
HttpResult result = request("/v1/client/attach",
body.getBytes(RemoteClient.UTF8), false, 30000);
java.util.Map<String, Object> reply =
Json.parseObject(new String(result.body, RemoteClient.UTF8));
String fresh = Json.str(reply, "session", "");
if (Json.bool(reply, "ok", false) && !fresh.isEmpty()) {
session = fresh;
deviceName = Json.str(reply, "deviceName", deviceName);
return true;
}
lastError = Json.str(reply, "error", "重新接入失败");
return false;
} catch (Exception exc) {
lastError = "重新接入出错:" + exc.getClass().getSimpleName();
return false;
}
}
// ------------------------------------------------------------------
// 发送:手表 -> 电脑
// ------------------------------------------------------------------
private void sendLoop() {
while (!closed) {
byte[] chunk = takePending();
if (chunk == null) {
continue;
}
try {
HttpResult result = request("/v1/client/send", chunk, true,
SEND_TIMEOUT_MS);
if (result.code != 200) {
lastError = "发送返回 " + result.code;
}
} catch (Exception exc) {
if (closed) {
return;
}
lastError = "发送出错:" + exc.getClass().getSimpleName();
sleep(1500);
}
}
}
private byte[] takePending() {
synchronized (pendingLock) {
if (pending.size() == 0) {
try {
pendingLock.wait(FLUSH_MILLIS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return null;
}
if (pending.size() == 0) {
return null;
}
}
byte[] chunk = pending.toByteArray();
pending.reset();
return chunk;
}
}
private void flushNow() {
byte[] chunk;
synchronized (pendingLock) {
if (pending.size() == 0) {
return;
}
chunk = pending.toByteArray();
pending.reset();
}
try {
request("/v1/client/send", chunk, true, SEND_TIMEOUT_MS);
} catch (Exception ignored) {
// 关闭时发不出去就算了
}
}
// ------------------------------------------------------------------
// HTTP
// ------------------------------------------------------------------
private static final class HttpResult {
final int code;
final byte[] body;
HttpResult(int code, byte[] body) {
this.code = code;
this.body = body;
}
}
private HttpResult request(String path, byte[] body, boolean withSession,
int timeoutMs) throws IOException {
// 走 TlsCompat:老手表缺根证书时用内置的那两个补上。
// 不这么改会报 SSLHandshakeException: Chain validation failed
// (廉价手表的系统根证书库常缺 GTS Root R4)。
HttpURLConnection connection = TlsCompat.open(baseUrl + path);
try {
connection.setRequestMethod("POST");
connection.setDoOutput(body.length > 0);
connection.setConnectTimeout(Math.min(15000, timeoutMs));
connection.setReadTimeout(timeoutMs);
connection.setUseCaches(false);
connection.setRequestProperty("Content-Type",
"application/octet-stream");
connection.setRequestProperty("User-Agent", "LanRemote-Watch/1.5");
if (withSession) {
connection.setRequestProperty("X-Session", session);
}
if (body.length > 0) {
connection.setFixedLengthStreamingMode(body.length);
OutputStream out = connection.getOutputStream();
out.write(body);
out.flush();
out.close();
}
int code = connection.getResponseCode();
InputStream stream = code >= 400
? connection.getErrorStream() : connection.getInputStream();
byte[] payload = stream == null ? new byte[0] : readAll(stream);
return new HttpResult(code, payload);
} finally {
connection.disconnect();
}
}
private static byte[] readAll(InputStream stream) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] piece = new byte[16384];
int total = 0;
int got;
while ((got = stream.read(piece)) > 0) {
total += got;
if (total > MAX_BODY) {
break;
}
buffer.write(piece, 0, got);
}
return buffer.toByteArray();
}
private static String parseError(byte[] body, String fallback) {
try {
java.util.Map<String, Object> reply =
Json.parseObject(new String(body, RemoteClient.UTF8));
String error = Json.str(reply, "error", "");
return error.isEmpty() ? fallback : error;
} catch (Exception ignored) {
return fallback;
}
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
}
}