diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md
index 2c6a468..b195489 100644
--- a/docs/DEVIATIONS.md
+++ b/docs/DEVIATIONS.md
@@ -1189,6 +1189,15 @@
- 备选方案:照搬旧 JS 双重翻倍;否决。
- 影响:仅 sdk/python。
+### 复审修复 K-04
+
+- 日期:2026-09-30
+- 原条款:issue #61 及第二轮补充;DEVELOPMENT 第 9 节附录。
+- 实际做法:CONNACK 只按 MQTT 5 原因码判定认证失败,不再用异常文本把 `connack`/`connectionfailed` 当成坏密码;心跳 30;0x8B 可重试、0x8E 为 `taken_over`;ReconnectBackoff 单计数 n;断线后在途发送新 rid 重交;`rate_limited` 按该条计数退避且新 rid;回调经单一队列、不持 `lock`;logout 把请求失败返回应用;本地队列满 `queue_full`;`sendAt`/`delay` 互斥并用 64 位时长;URL 映射 http→ws、路径非空不追加 `/mqtt`;`java.util.Base64` 换成 API 24 可用的 `NixBase64`;`FakeTransport` 移到测试源码,LICENSE 打进 jar 的 `META-INF`。未发布 Maven。无独立取消未发出条目的 API,`close` 以 `closed` 结束队列(`testK00CancelUnsent`)。
+- 原因:与 K-00 对齐并修 S-02 文本误判认证失败、断线永不重交、回调持锁。
+- 备选方案:沿用异常文本回退与 JS 双重翻倍;否决。
+- 影响:仅 sdk/java。
+
## SDK 二 S2
### S2-PY/JAVA 1–3 2026-09-30
diff --git a/sdk/java/pom.xml b/sdk/java/pom.xml
index bc5f659..bd0f88b 100644
--- a/sdk/java/pom.xml
+++ b/sdk/java/pom.xml
@@ -52,6 +52,18 @@
+
+
+ src/main/resources
+
+
+ ${project.basedir}
+
+ LICENSE
+
+ META-INF
+
+
org.apache.maven.plugins
@@ -70,6 +82,16 @@
org.apache.maven.plugins
maven-jar-plugin
3.4.2
+
+
+ true
+
+
+
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+ 3.3.1
diff --git a/sdk/java/src/main/java/asia/asio/nixmsg/Client.java b/sdk/java/src/main/java/asia/asio/nixmsg/Client.java
index c541001..e10e68c 100644
--- a/sdk/java/src/main/java/asia/asio/nixmsg/Client.java
+++ b/sdk/java/src/main/java/asia/asio/nixmsg/Client.java
@@ -45,6 +45,10 @@ public final class Client {
private static final Logger LOG = Logger.getLogger("nixmsg");
private static final String DELIVERED = "delivered";
private static final String ACKED = "acked";
+ private static final Runnable CB_POISON = new Runnable() {
+ @Override
+ public void run() {}
+ };
private final Transport transport;
private final boolean autoAck;
@@ -73,19 +77,23 @@ public final class Client {
private boolean useTcp;
private HelloLimits limits = new HelloLimits();
private long clockSkewMs;
- private long onlineSinceMs;
- private long backoffMs = Types.BACKOFF_INITIAL_MS;
+ private final Types.ReconnectBackoff backoff = new Types.ReconnectBackoff();
private int inflightSends;
private List watchIds;
private boolean watchAll;
private volatile Throwable handshakeError;
private volatile String authReason = "";
+ private volatile String lastStopCode = "";
+ private volatile NixMsgException lastStopErr;
+ private int sendQueueLimit = Types.SEND_QUEUE_LIMIT;
private final AtomicBoolean connReady = new AtomicBoolean(false);
private final Object connWait = new Object();
private Thread worker;
private final Object wake = new Object();
private final BlockingQueue downQueue = new LinkedBlockingQueue();
private final Thread downWorker;
+ private final BlockingQueue cbQueue = new LinkedBlockingQueue();
+ private final Thread cbWorker;
private Consumer sessionHandler;
private Consumer messageHandler;
@@ -106,13 +114,16 @@ public final class Client {
public Client(Transport transport, boolean autoAck, int maxReceiveBytes, String clientName, long connectTimeoutMs) {
this.transport = transport;
this.autoAck = autoAck;
- this.maxReceiveBytes = Math.max(Types.MIN_MAX_RECEIVE, maxReceiveBytes);
+ this.maxReceiveBytes = maxReceiveBytes;
this.clientName = clientName;
this.connectTimeoutMs = connectTimeoutMs;
this.transport.setHandlers(this::onTransportConnected, this::onTransportDisconnected, this::onDown);
this.downWorker = new Thread(this::downLoop, "nixmsg-down");
this.downWorker.setDaemon(true);
this.downWorker.start();
+ this.cbWorker = new Thread(this::cbLoop, "nixmsg-cb");
+ this.cbWorker.setDaemon(true);
+ this.cbWorker.start();
}
public void onSession(Consumer handler) { this.sessionHandler = handler; }
@@ -128,6 +139,14 @@ public final class Client {
public String getSessionToken() { return sessionToken; }
public long getClockSkewMs() { return clockSkewMs; }
+ String lastStopCodeForTest() {
+ return lastStopCode == null ? "" : lastStopCode;
+ }
+
+ void setSendQueueLimitForTest(int n) {
+ sendQueueLimit = n;
+ }
+
/** 同包测试用:断开底层传输以触发重连与发送队列重交。 */
void dropTransportForTest() {
transport.disconnect();
@@ -145,11 +164,14 @@ public final class Client {
if (password == null && sessionToken == null) {
throw new IllegalArgumentException("需要 password 或 sessionToken");
}
+ if (maxReceiveBytes < Types.MIN_MAX_RECEIVE) {
+ throw new NixMsgException("bad_request", "max_receive_bytes 不能小于 1024");
+ }
synchronized (lock) {
if (closed) {
throw new NixMsgException("closed", "已关闭");
}
- this.url = useTcp ? url : Protocol.normalizeMqttWsUrl(url);
+ this.url = Protocol.normalizeMqttWsUrl(url, useTcp);
this.endpointId = endpointId;
this.password = password;
this.sessionToken = sessionToken;
@@ -158,6 +180,9 @@ public final class Client {
this.userClose = false;
this.wantConnected = true;
this.handshakeError = null;
+ this.lastStopCode = "";
+ this.lastStopErr = null;
+ backoff.reset();
connReady.set(false);
setState(ConnectionState.CONNECTING, "");
if (worker == null || !worker.isAlive()) {
@@ -167,14 +192,13 @@ public final class Client {
}
wakeUp();
}
- long deadline = System.currentTimeMillis() + connectTimeoutMs + 5000;
+ long deadline = System.currentTimeMillis() + connectTimeoutMs;
synchronized (connWait) {
while (System.currentTimeMillis() < deadline) {
ConnectionState s = state;
if (s == ConnectionState.ONLINE
|| s == ConnectionState.AUTH_FAILED
|| s == ConnectionState.KICKED
- || s == ConnectionState.OFFLINE
|| handshakeError != null) {
break;
}
@@ -187,10 +211,13 @@ public final class Client {
}
}
if (handshakeError != null) {
+ if (handshakeError instanceof NixMsgException) {
+ throw (NixMsgException) handshakeError;
+ }
if (handshakeError instanceof RuntimeException) {
throw (RuntimeException) handshakeError;
}
- throw new NixMsgException("busy", handshakeError.getMessage());
+ throw new NixMsgException("not_connected", handshakeError.getMessage());
}
if (state != ConnectionState.ONLINE) {
if (state == ConnectionState.AUTH_FAILED) {
@@ -199,7 +226,11 @@ public final class Client {
if (state == ConnectionState.KICKED) {
throw new NixMsgException("taken_over", "会话被接管");
}
- throw new NixMsgException("busy", "连接未成功: " + state);
+ synchronized (lock) {
+ stopReconnect = true;
+ wantConnected = false;
+ }
+ throw new NixMsgException("not_connected", "连接未成功: " + state);
}
}
@@ -220,21 +251,27 @@ public final class Client {
transport.disconnect();
} catch (Exception ignored) {
}
- downQueue.offer(new byte[0]); // 空载荷哨兵:downLoop 见 closed 退出
+ downQueue.offer(new byte[0]);
+ cbQueue.offer(CB_POISON);
wakeUp();
}
public CompletableFuture logout() {
return CompletableFuture.runAsync(() -> {
+ Throwable reqErr = null;
try {
request(mapOf("type", "self.logout"), true);
- } catch (Exception ignored) {
+ } catch (Exception e) {
+ reqErr = e;
}
+ NixMsgException stop = new NixMsgException("logged_out", "已退出登录");
synchronized (lock) {
stopReconnect = true;
wantConnected = false;
sessionToken = null;
- failAll(new NixMsgException("auth_failed", "已退出登录"));
+ lastStopCode = "logged_out";
+ lastStopErr = stop;
+ failAll(stop);
}
try {
transport.disconnect();
@@ -242,6 +279,12 @@ public final class Client {
}
setState(ConnectionState.OFFLINE, "");
wakeUp();
+ if (reqErr != null) {
+ if (reqErr instanceof RuntimeException) {
+ throw (RuntimeException) reqErr;
+ }
+ throw new NixMsgException("not_connected", reqErr.getMessage());
+ }
});
}
@@ -261,8 +304,12 @@ public final class Client {
if (closed) {
throw new NixMsgException("closed", "已关闭");
}
- if (sendQueue.size() >= Types.SEND_QUEUE_LIMIT) {
- throw new NixMsgException("quota_exceeded", "发送队列已满");
+ if (stopReconnect) {
+ throw lastStopErr != null ? lastStopErr : new NixMsgException(
+ lastStopCode.isEmpty() ? "not_connected" : lastStopCode, "已停止重连");
+ }
+ if (sendQueue.size() >= sendQueueLimit) {
+ throw new NixMsgException("queue_full", "发送队列已满");
}
int maxBody = limits.maxBodyBytes > 0 ? limits.maxBodyBytes : Types.DEFAULT_MAX_BODY;
int maxMeta = limits.maxMetaBytes > 0 ? limits.maxMetaBytes : Types.DEFAULT_MAX_META;
@@ -285,8 +332,17 @@ public final class Client {
if (!meta.isEmpty()) {
frame.put("meta", meta);
}
+ if (options.sendAtMs != null && options.delayMs != null
+ || options.sendAt != null && options.delayMs != null
+ || options.sendAtDate != null && options.delayMs != null) {
+ throw new NixMsgException("bad_request", "sendAt 与 delay 互斥");
+ }
if (options.sendAtMs != null) {
frame.put("send_at_ms", options.sendAtMs);
+ } else if (options.sendAt != null) {
+ frame.put("send_at_ms", options.sendAt + clockSkewMs);
+ } else if (options.sendAtDate != null) {
+ frame.put("send_at_ms", options.sendAtDate.getTime() + clockSkewMs);
} else if (options.delayMs != null) {
frame.put("delay_ms", options.delayMs);
}
@@ -332,8 +388,7 @@ public final class Client {
public CompletableFuture recall(String messageId) {
return CompletableFuture.supplyAsync(() -> {
- Map resp = request(mapOf("type", "recall", "id", messageId), true);
- Map data = asMap(resp.get("data"));
+ Map data = request(mapOf("type", "recall", "id", messageId), true);
return new RecallResult(str(data.get("result"), ""),
(int) longVal(data.get("recalled"), 0),
(int) longVal(data.get("accepted"), 0),
@@ -389,6 +444,10 @@ public final class Client {
}
public CompletableFuture