mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 14:50:45 +00:00
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 吃这个值,
没有别的超时会先把请求掐掉。)
559 lines
20 KiB
Java
559 lines
20 KiB
Java
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();
|
||
}
|
||
}
|
||
}
|