feat: Python/Java SDK 接入清单与打包文档

对真实 nixmsg 跑 DEVELOPMENT 第 9 节接入清单(跳过仅 JS 跨域),补 README/示例,并修 Paho/HiveMQ 真机联调死锁与鉴权分类。
This commit is contained in:
Nixevol
2026-09-30 08:57:06 +08:00
parent 2f750bcb0f
commit 1f715e4e51
15 changed files with 1952 additions and 148 deletions
+37 -4
View File
@@ -2,7 +2,7 @@
坐标:`asia.asio.nixmsg:nixmsg-sdk`
包名:`asia.asio.nixmsg`
字节码目标:Java 8
字节码目标:Java 8(`maven.compiler.release=8`)
接口:`CompletableFuture`
## Android
@@ -17,7 +17,15 @@ Maven / Gradle 仓库:
https://git.asio.asia/api/packages/nixevol/maven
```
HiveMQ MQTT Client(含 WebSocket:`webSocketConfig` + `netty-codec-http`)。
```xml
<dependency>
<groupId>asia.asio.nixmsg</groupId>
<artifactId>nixmsg-sdk</artifactId>
<version>0.1.0</version>
</dependency>
```
HiveMQ MQTT Client(WebSocket:`webSocketConfig` + `netty-codec-http`)。
## 最小示例
@@ -26,9 +34,34 @@ Client c = new Client();
c.onSession(token -> { /* 应用保存 */ });
c.onMessage(msg -> System.out.println(msg.id + " " + msg.body.data));
c.connect("ws://127.0.0.1:7443/mqtt", "device-1", "secret", null)
.thenCompose(v -> c.send(new Types.Target("endpoint", "device-2"), new Types.Body("hello"), new Types.SendOptions()))
.thenCompose(v -> {
Types.SendOptions opt = new Types.SendOptions();
opt.delayMs = 0L;
return c.send(new Types.Target("endpoint", "device-2"), new Types.Body("hello"), opt);
})
.join();
c.close();
```
许可证见 `LICENSE`(专有)。
命令行示例类:`asia.asio.nixmsg.examples.MinimalExample`。
## 打包(不发布)
```bash
mvn package -DskipTests
# 产物 target/nixmsg-sdk-0.1.0.jar;勿部署到 Maven 仓库;正式发布由总控在阶段 3 执行
```
确认字节码为 8:`javap -v target/classes/asia/asio/nixmsg/Client.class | findstr major`(应为 52)。
## 测试
```bash
mvn test
```
含假传输单元测试与 DEVELOPMENT 第 9 节接入清单(会编译并启动真实 `nixmsg`)。可用环境变量 `NIXMSG_BIN` 指定已编译二进制。跳过仅 JS 的跨域项。
## 许可证
见 `LICENSE`(Proprietary)。
@@ -27,7 +27,9 @@ import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
@@ -82,6 +84,8 @@ public final class Client {
private final Object connWait = new Object();
private Thread worker;
private final Object wake = new Object();
private final BlockingQueue<byte[]> downQueue = new LinkedBlockingQueue<byte[]>();
private final Thread downWorker;
private Consumer<String> sessionHandler;
private Consumer<IncomingMessage> messageHandler;
@@ -106,6 +110,9 @@ public final class Client {
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();
}
public void onSession(Consumer<String> handler) { this.sessionHandler = handler; }
@@ -121,6 +128,11 @@ public final class Client {
public String getSessionToken() { return sessionToken; }
public long getClockSkewMs() { return clockSkewMs; }
/** 同包测试用:断开底层传输以触发重连与发送队列重交。 */
void dropTransportForTest() {
transport.disconnect();
}
public CompletableFuture<Void> connect(String url, String endpointId, String password, String sessionToken) {
return connect(url, endpointId, password, sessionToken, false);
}
@@ -157,9 +169,17 @@ public final class Client {
}
long deadline = System.currentTimeMillis() + connectTimeoutMs + 5000;
synchronized (connWait) {
while (!connReady.get() && System.currentTimeMillis() < deadline) {
while (System.currentTimeMillis() < deadline) {
ConnectionState s = state;
if (s == ConnectionState.ONLINE
|| s == ConnectionState.AUTH_FAILED
|| s == ConnectionState.KICKED
|| s == ConnectionState.OFFLINE
|| handshakeError != null) {
break;
}
try {
connWait.wait(200);
connWait.wait(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
@@ -200,6 +220,7 @@ public final class Client {
transport.disconnect();
} catch (Exception ignored) {
}
downQueue.offer(new byte[0]); // 空载荷哨兵:downLoop 见 closed 退出
wakeUp();
}
@@ -716,57 +737,108 @@ public final class Client {
}
private void onDown(byte[] payload) {
// resp 立即完成 pending,避免 down 工作线程在 autoAck 等待时自死锁。
Map<String, Object> frame;
try {
frame = Protocol.loads(payload);
} catch (Exception e) {
downQueue.offer(payload);
return;
}
if ("resp".equals(str(frame.get("type"), ""))) {
dispatchResp(frame);
return;
}
downQueue.offer(payload);
}
private void downLoop() {
while (true) {
try {
byte[] payload = downQueue.take();
if (payload.length == 0 && closed) {
return;
}
Map<String, Object> frame;
try {
frame = Protocol.loads(payload);
} catch (Exception e) {
continue;
}
if ("resp".equals(str(frame.get("type"), ""))) {
dispatchResp(frame);
} else {
dispatchDownBody(frame);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} catch (Exception e) {
LOG.log(Level.WARNING, "处理下行帧失败", e);
}
}
}
private void dispatchResp(Map<String, Object> frame) {
String rid = str(frame.get("rid"), "");
Pending p;
synchronized (lock) {
p = pending.remove(rid);
}
if (p == null) {
return;
}
if (p.isSend) {
synchronized (lock) {
inflightSends = Math.max(0, inflightSends - 1);
}
if (!Boolean.TRUE.equals(frame.get("ok"))) {
Map<String, Object> err = asMap(frame.get("error"));
if ("rate_limited".equals(str(err.get("code"), ""))) {
synchronized (lock) {
p.rid = "";
p.response = null;
p.error = null;
}
wakeUp();
return;
}
p.error = new NixMsgException(str(err.get("code"), "bad_request"), str(err.get("message"), ""));
}
p.response = frame;
synchronized (lock) {
Iterator<SendItem> it = sendQueue.iterator();
while (it.hasNext()) {
if (it.next().pending == p) {
it.remove();
break;
}
}
}
p.future.complete(null);
wakeUp();
} else {
p.response = frame;
p.future.complete(null);
}
}
private void dispatchDown(byte[] payload) {
Map<String, Object> frame;
try {
frame = Protocol.loads(payload);
} catch (Exception e) {
return;
}
String type = str(frame.get("type"), "");
if ("resp".equals(type)) {
String rid = str(frame.get("rid"), "");
Pending p;
synchronized (lock) {
p = pending.remove(rid);
}
if (p != null) {
if (p.isSend) {
synchronized (lock) {
inflightSends = Math.max(0, inflightSends - 1);
}
if (!Boolean.TRUE.equals(frame.get("ok"))) {
Map<String, Object> err = asMap(frame.get("error"));
if ("rate_limited".equals(str(err.get("code"), ""))) {
synchronized (lock) {
p.rid = "";
p.response = null;
p.error = null;
// 保留原 future,重交成功后再 complete
}
wakeUp();
return;
}
p.error = new NixMsgException(str(err.get("code"), "bad_request"), str(err.get("message"), ""));
}
p.response = frame;
synchronized (lock) {
Iterator<SendItem> it = sendQueue.iterator();
while (it.hasNext()) {
if (it.next().pending == p) {
it.remove();
break;
}
}
}
p.future.complete(null);
wakeUp();
} else {
p.response = frame;
p.future.complete(null);
}
}
if ("resp".equals(str(frame.get("type"), ""))) {
dispatchResp(frame);
return;
}
dispatchDownBody(frame);
}
private void dispatchDownBody(Map<String, Object> frame) {
String type = str(frame.get("type"), "");
if ("msg".equals(type)) {
handleMsg(frame);
return;
@@ -3,9 +3,10 @@ package asia.asio.nixmsg;
import com.hivemq.client.mqtt.MqttClient;
import com.hivemq.client.mqtt.MqttGlobalPublishFilter;
import com.hivemq.client.mqtt.datatypes.MqttQos;
import com.hivemq.client.mqtt.lifecycle.MqttDisconnectSource;
import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient;
import com.hivemq.client.mqtt.mqtt5.Mqtt5ClientBuilder;
import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5ConnAckException;
import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5DisconnectException;
import com.hivemq.client.mqtt.mqtt5.message.connect.connack.Mqtt5ConnAck;
import com.hivemq.client.mqtt.mqtt5.message.connect.connack.Mqtt5ConnAckReasonCode;
import com.hivemq.client.mqtt.mqtt5.message.disconnect.Mqtt5Disconnect;
@@ -16,6 +17,7 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
@@ -256,25 +258,24 @@ final class HiveMqTransport implements Transport {
.addDisconnectedListener(context -> {
String reason = "network";
boolean stop = false;
if (context.getSource() == MqttDisconnectSource.SERVER) {
try {
java.lang.reflect.Method m = context.getClass().getMethod("getMqttDisconnect");
Object disc = m.invoke(context);
if (disc instanceof Mqtt5Disconnect) {
Mqtt5DisconnectReasonCode rc = ((Mqtt5Disconnect) disc).getReasonCode();
if (rc == Mqtt5DisconnectReasonCode.SESSION_TAKEN_OVER) {
reason = "taken_over";
stop = true;
}
}
} catch (Exception ignored) {
}
}
Throwable cause = context.getCause();
if (cause != null && cause.getMessage() != null
&& cause.getMessage().toLowerCase().contains("taken over")) {
reason = "taken_over";
stop = true;
while (cause != null) {
if (cause instanceof Mqtt5DisconnectException) {
Mqtt5DisconnectReasonCode rc =
((Mqtt5DisconnectException) cause).getMqttMessage().getReasonCode();
if (rc == Mqtt5DisconnectReasonCode.SESSION_TAKEN_OVER) {
reason = "taken_over";
stop = true;
}
break;
}
String m = cause.getMessage() == null ? "" : cause.getMessage().toLowerCase(Locale.ROOT);
if (m.contains("taken over") || m.contains("session taken")) {
reason = "taken_over";
stop = true;
break;
}
cause = cause.getCause();
}
BiConsumer<String, Boolean> h = onDisconnected;
if (h != null) {
@@ -332,9 +333,29 @@ final class HiveMqTransport implements Transport {
}
}
} catch (Exception e) {
Mqtt5ConnAckReasonCode rc = extractConnAckReason(e);
String reason;
boolean stop;
if (rc != null) {
reason = classify(rc);
stop = isStop(rc);
} else {
String msg = exceptionText(e).toLowerCase(Locale.ROOT);
if (msg.contains("bad_user") || msg.contains("bad user") || msg.contains("not authorized")
|| msg.contains("not_authorized") || msg.contains("bad_username")
|| msg.contains("bad username") || msg.contains("banned")
|| msg.contains("connack") || msg.contains("connectionfailed")
|| msg.contains("mqtt5connack")) {
reason = "bad_credentials";
stop = true;
} else {
reason = "network";
stop = false;
}
}
BiConsumer<String, Boolean> h = onDisconnected;
if (h != null) {
h.accept("network", false);
h.accept(reason, stop);
}
}
}
@@ -386,6 +407,30 @@ final class HiveMqTransport implements Transport {
}
}
private static Mqtt5ConnAckReasonCode extractConnAckReason(Throwable e) {
Throwable t = e;
while (t != null) {
if (t instanceof Mqtt5ConnAckException) {
return ((Mqtt5ConnAckException) t).getMqttMessage().getReasonCode();
}
t = t.getCause();
}
return null;
}
private static String exceptionText(Throwable e) {
StringBuilder sb = new StringBuilder();
Throwable t = e;
while (t != null) {
sb.append(t.getClass().getName()).append(' ');
if (t.getMessage() != null) {
sb.append(t.getMessage()).append(' ');
}
t = t.getCause();
}
return sb.toString();
}
private static boolean isStop(Mqtt5ConnAckReasonCode code) {
return code == Mqtt5ConnAckReasonCode.BAD_USER_NAME_OR_PASSWORD
|| code == Mqtt5ConnAckReasonCode.NOT_AUTHORIZED
@@ -0,0 +1,38 @@
package asia.asio.nixmsg.examples;
import asia.asio.nixmsg.Client;
import asia.asio.nixmsg.Types.Body;
import asia.asio.nixmsg.Types.SendOptions;
import asia.asio.nixmsg.Types.Target;
/**
* 最小示例:连接、发送、关闭。
* <p>
* 运行(需本机已有 nixmsg,并准备好端号与密码):
* {@code java -cp ... asia.asio.nixmsg.examples.MinimalExample ws://127.0.0.1:PORT/mqtt device-1 password12 peer-id}
*/
public final class MinimalExample {
private MinimalExample() {}
public static void main(String[] args) throws Exception {
if (args.length < 4) {
System.err.println("用法: MinimalExample <wsUrl> <endpointId> <password> <peerId>");
System.exit(2);
}
String url = args[0];
String eid = args[1];
String password = args[2];
String peer = args[3];
Client c = new Client();
c.onSession(token -> System.out.println("session " + token.substring(0, Math.min(16, token.length())) + "..."));
c.onMessage(msg -> System.out.println("msg " + msg.from + " " + msg.id + " " + msg.body.data));
c.onConnection(ev -> System.out.println("conn " + ev.state + " " + ev.reason));
c.connectSync(url, eid, password, null, false);
SendOptions opt = new SendOptions();
opt.delayMs = 0L;
c.sendSync(new Target("endpoint", peer), new Body("hello from java"), opt);
Thread.sleep(2000L);
c.close();
}
}
@@ -0,0 +1,531 @@
package asia.asio.nixmsg;
import asia.asio.nixmsg.Types.Body;
import asia.asio.nixmsg.Types.ConnectionEvent;
import asia.asio.nixmsg.Types.ConnectionState;
import asia.asio.nixmsg.Types.IncomingMessage;
import asia.asio.nixmsg.Types.Receipt;
import asia.asio.nixmsg.Types.RegisterOptions;
import asia.asio.nixmsg.Types.RevokedEvent;
import asia.asio.nixmsg.Types.SendOptions;
import asia.asio.nixmsg.Types.SendResult;
import asia.asio.nixmsg.Types.Target;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/** DEVELOPMENT 第 9 节接入清单(真实服务器;跳过仅 JS 跨域)。 */
public class ChecklistTest {
private static TestHarness srv;
private static int seq;
@BeforeClass
public static void startServer() throws Exception {
srv = new TestHarness();
srv.start();
}
@AfterClass
public static void stopServer() {
if (srv != null) {
srv.stop();
}
}
private static synchronized String uid(String prefix) {
seq++;
return prefix + String.format("%04d", seq);
}
private static void register(String id) {
RegisterOptions opt = new RegisterOptions();
opt.id = id;
opt.loginPassword = "password12";
opt.name = id;
Client.registerSync(srv.wsUrl, srv.regCode, opt);
}
private static Client connect(String id) {
return connect(id, "password12", null);
}
private static Client connect(String id, String password, String token) {
Client c = new Client();
c.connectSync(srv.wsUrl, id, password, token, false);
assertEquals(ConnectionState.ONLINE, c.getState());
return c;
}
private static SendOptions immediate(String messageId) {
SendOptions o = new SendOptions();
o.delayMs = 0L;
o.messageId = messageId;
return o;
}
private static boolean waitUntil(Condition cond, long timeoutMs) throws InterruptedException {
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
if (cond.ok()) {
return true;
}
Thread.sleep(50L);
}
return cond.ok();
}
private interface Condition {
boolean ok();
}
private static final class MsgBox {
private final List<IncomingMessage> items = Collections.synchronizedList(new ArrayList<IncomingMessage>());
private final CountDownLatch latch = new CountDownLatch(1);
void onMessage(IncomingMessage m) {
items.add(m);
latch.countDown();
}
List<IncomingMessage> waitN(int n, long timeoutMs) throws InterruptedException {
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
if (items.size() >= n) {
return new ArrayList<IncomingMessage>(items);
}
Thread.sleep(50L);
}
return new ArrayList<IncomingMessage>(items);
}
}
@Test
public void test01Handshake() {
String id = uid("hs");
register(id);
Client c = new Client();
final List<String> tokens = new ArrayList<String>();
c.onSession(tokens::add);
c.connectSync(srv.wsUrl, id, "password12", null, false);
assertEquals(ConnectionState.ONLINE, c.getState());
assertTrue(c.getLimits().serverTimeMs > 0);
assertTrue(c.getLimits().maxBodyBytes >= 256 * 1024);
assertFalse(tokens.isEmpty());
assertTrue(tokens.get(0).startsWith("nst_"));
c.close();
}
@Test
public void test02DmOnce() throws Exception {
String a = uid("a2");
String b = uid("b2");
register(a);
register(b);
Client ca = connect(a);
Client cb = connect(b);
MsgBox box = new MsgBox();
cb.onMessage(box::onMessage);
String mid = Uuid7.next();
ca.sendSync(new Target("endpoint", b), new Body("hello-once"), immediate(mid));
List<IncomingMessage> got = box.waitN(1, 10_000);
assertEquals(1, got.size());
assertEquals(mid, got.get(0).id);
Thread.sleep(500);
assertEquals(1, box.items.size());
ca.close();
cb.close();
}
@Test
public void test03SendWhileDisconnected() throws Exception {
String a = uid("a3");
String b = uid("b3");
register(a);
register(b);
Client ca = connect(a);
Client cb = connect(b);
MsgBox box = new MsgBox();
cb.onMessage(box::onMessage);
String mid = Uuid7.next();
ca.dropTransportForTest();
assertTrue(waitUntil(() -> ca.getState() == ConnectionState.RECONNECTING, 5_000));
AtomicReference<Throwable> err = new AtomicReference<Throwable>();
AtomicReference<SendResult> result = new AtomicReference<SendResult>();
Thread th = new Thread(() -> {
try {
result.set(ca.sendSync(new Target("endpoint", b), new Body("queued"), immediate(mid)));
} catch (Throwable t) {
err.set(t);
}
});
th.start();
th.join(60_000L);
assertTrue(err.get() == null);
assertNotNull(result.get());
assertEquals(mid, result.get().id);
assertTrue(waitUntil(() -> ca.getState() == ConnectionState.ONLINE, 30_000));
List<IncomingMessage> got = box.waitN(1, 15_000);
assertEquals(1, got.size());
assertEquals(mid, got.get(0).id);
Thread.sleep(800);
assertEquals(1, box.items.size());
ca.close();
cb.close();
}
@Test
public void test04SameMessageId() throws Exception {
String a = uid("a4");
String b = uid("b4");
register(a);
register(b);
Client ca = connect(a);
Client cb = connect(b);
MsgBox box = new MsgBox();
cb.onMessage(box::onMessage);
String mid = Uuid7.next();
ca.sendSync(new Target("endpoint", b), new Body("idem"), immediate(mid));
assertEquals(1, box.waitN(1, 10_000).size());
ca.sendSync(new Target("endpoint", b), new Body("idem"), immediate(mid));
Thread.sleep(800);
assertEquals(1, box.items.size());
ca.close();
cb.close();
}
@Test
public void test05RecallWithinDelay() throws Exception {
String a = uid("a5");
String b = uid("b5");
register(a);
register(b);
Client ca = connect(a);
Client cb = connect(b);
MsgBox box = new MsgBox();
List<RevokedEvent> revoked = Collections.synchronizedList(new ArrayList<RevokedEvent>());
cb.onMessage(box::onMessage);
cb.onRevoked(revoked::add);
String mid = Uuid7.next();
SendOptions opt = immediate(mid);
opt.delayMs = 10_000L;
SendResult r = ca.sendSync(new Target("endpoint", b), new Body("will-recall"), opt);
assertEquals("scheduled", r.state);
ca.recall(mid).get(10, TimeUnit.SECONDS);
Thread.sleep(1200);
assertTrue(box.items.isEmpty());
assertTrue(revoked.isEmpty());
ca.close();
cb.close();
}
@Test
public void test06Scheduled2s() throws Exception {
String a = uid("a6");
String b = uid("b6");
register(a);
register(b);
Client ca = connect(a);
Client cb = connect(b);
MsgBox box = new MsgBox();
cb.onMessage(box::onMessage);
String mid = Uuid7.next();
SendOptions opt = immediate(mid);
opt.delayMs = 2000L;
long t0 = System.currentTimeMillis();
ca.sendSync(new Target("endpoint", b), new Body("later"), opt);
List<IncomingMessage> got = box.waitN(1, 12_000);
long elapsed = System.currentTimeMillis() - t0;
assertEquals(1, got.size());
assertTrue(elapsed >= 1500);
assertTrue(elapsed < 8000);
ca.close();
cb.close();
}
@Test
public void test07OfflineKeep() throws Exception {
String a = uid("a7");
String bok = uid("bok");
String bms = uid("bms");
register(a);
register(bok);
register(bms);
Client ca = connect(a);
String mid1 = Uuid7.next();
SendOptions keep = immediate(mid1);
keep.keep = true;
keep.ttlSeconds = 86400L;
ca.sendSync(new Target("endpoint", bok), new Body("keep-ok"), keep);
Thread.sleep(1000);
Client cb1 = connect(bok);
MsgBox box1 = new MsgBox();
cb1.onMessage(box1::onMessage);
assertEquals(1, box1.waitN(1, 10_000).size());
cb1.close();
List<Receipt> receipts = Collections.synchronizedList(new ArrayList<Receipt>());
ca.onReceipt(receipts::add);
String mid2 = Uuid7.next();
SendOptions keepExp = immediate(mid2);
keepExp.keep = true;
keepExp.ttlSeconds = 1L;
ca.sendSync(new Target("endpoint", bms), new Body("keep-expire"), keepExp);
Thread.sleep(3200);
Client cb2 = connect(bms);
MsgBox box2 = new MsgBox();
cb2.onMessage(box2::onMessage);
Thread.sleep(1500);
assertTrue(box2.items.isEmpty());
assertTrue(waitUntil(() -> {
for (Receipt r : receipts) {
if ("expired".equals(r.state) && mid2.equals(r.id)) {
return true;
}
}
return false;
}, 10_000));
ca.close();
cb2.close();
}
@Test
public void test08GroupNoEcho() throws Exception {
String a = uid("a8");
String b = uid("b8");
String c = uid("c8");
register(a);
register(b);
register(c);
Client ca = connect(a);
Client cb = connect(b);
Client cc = connect(c);
String gid = "g_" + a;
List<Map<String, String>> members = new ArrayList<Map<String, String>>();
members.add(Collections.singletonMap("id", b));
members.add(Collections.singletonMap("id", c));
ca.groupCreate("G", members, gid).get(15, TimeUnit.SECONDS);
Thread.sleep(400);
MsgBox boxA = new MsgBox();
MsgBox boxB = new MsgBox();
MsgBox boxC = new MsgBox();
ca.onMessage(boxA::onMessage);
cb.onMessage(boxB::onMessage);
cc.onMessage(boxC::onMessage);
String mid = Uuid7.next();
ca.sendSync(new Target("group", gid), new Body("hi-g"), immediate(mid));
assertEquals(1, boxB.waitN(1, 10_000).size());
assertEquals(1, boxC.waitN(1, 10_000).size());
Thread.sleep(800);
assertTrue(boxA.items.isEmpty());
ca.close();
cb.close();
cc.close();
}
@Test
public void test09TalkPassword() throws Exception {
String a = uid("a9");
String b = uid("b9");
register(a);
register(b);
Client ca = connect(a);
Client cb = connect(b);
cb.setTalkPassword("talk99").get(10, TimeUnit.SECONDS);
try {
ca.sendSync(new Target("endpoint", b), new Body("no"), immediate(Uuid7.next()));
fail("expected talk password error");
} catch (NixMsgException e) {
assertTrue(e.getCode().contains("talk_password"));
}
ca.unlock(b, "talk99").get(10, TimeUnit.SECONDS);
MsgBox box = new MsgBox();
cb.onMessage(box::onMessage);
ca.sendSync(new Target("endpoint", b), new Body("ok"), immediate(Uuid7.next()));
assertEquals(1, box.waitN(1, 10_000).size());
cb.setTalkPassword("talk00").get(10, TimeUnit.SECONDS);
try {
ca.sendSync(new Target("endpoint", b), new Body("fail"), immediate(Uuid7.next()));
fail("expected talk password error after change");
} catch (NixMsgException e) {
assertTrue(e.getCode().contains("talk_password"));
}
ca.setTalkPassword("alicepw").get(10, TimeUnit.SECONDS);
MsgBox box2 = new MsgBox();
ca.onMessage(box2::onMessage);
SendOptions first = immediate(Uuid7.next());
first.talkPassword = "alicepw";
cb.sendSync(new Target("endpoint", a), new Body("first"), first);
assertEquals(1, box2.waitN(1, 10_000).size());
MsgBox box3 = new MsgBox();
cb.onMessage(box3::onMessage);
ca.sendSync(new Target("endpoint", b), new Body("reply"), immediate(Uuid7.next()));
assertEquals(1, box3.waitN(1, 10_000).size());
ca.close();
cb.close();
}
@Test
public void test10KickNoReconnect() throws Exception {
String id = uid("k10");
register(id);
Client c1 = connect(id);
Client c2 = connect(id);
assertTrue(waitUntil(() -> c1.getState() == ConnectionState.KICKED, 15_000));
Thread.sleep(2500);
assertEquals(ConnectionState.KICKED, c1.getState());
assertEquals(ConnectionState.ONLINE, c2.getState());
c1.close();
c2.close();
}
@Test
public void test11BodyTooLarge() {
String id = uid("big");
register(id);
Client c = connect(id);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 256 * 1024 + 1; i++) {
sb.append('x');
}
try {
c.sendSync(new Target("endpoint", id), new Body(sb.toString()), immediate(Uuid7.next()));
fail("expected body_too_large");
} catch (NixMsgException e) {
assertEquals("body_too_large", e.getCode());
}
c.close();
}
@Test
public void test12Registration() throws Exception {
String code = srv.regCode;
srv.setRegistration(false, code);
try {
RegisterOptions opt = new RegisterOptions();
opt.id = uid("r12a");
opt.loginPassword = "password12";
Client.registerSync(srv.wsUrl, code, opt);
fail("closed");
} catch (NixMsgException e) {
assertEquals("registration_closed", e.getCode());
}
srv.setRegistration(true, code);
try {
RegisterOptions opt = new RegisterOptions();
opt.id = uid("r12b");
opt.loginPassword = "password12";
Client.registerSync(srv.wsUrl, "wrong-code-xx", opt);
fail("bad code");
} catch (NixMsgException e) {
assertEquals("registration_code_invalid", e.getCode());
}
String eid = uid("r12c");
RegisterOptions ok = new RegisterOptions();
ok.id = eid;
ok.loginPassword = "password12";
Client.registerSync(srv.wsUrl, code, ok);
Client c = connect(eid);
c.close();
String newCode = "s2java-new-code";
srv.setRegistration(true, newCode);
try {
RegisterOptions opt = new RegisterOptions();
opt.id = uid("r12d");
opt.loginPassword = "password12";
Client.registerSync(srv.wsUrl, code, opt);
fail("old code");
} catch (NixMsgException e) {
assertTrue(e.getCode().length() > 0);
}
Client c2 = connect(eid);
c2.close();
srv.setRegistration(true, code);
srv.regCode = code;
}
@Test
public void test13ChangeLoginPassword() throws Exception {
String id = uid("pw13");
register(id);
Client c = connect(id);
c.changeLoginPassword("password12", "password99").get(15, TimeUnit.SECONDS);
c.close();
Client c2 = connect(id, "password99", null);
c2.close();
Client c3 = new Client();
try {
c3.connectSync(srv.wsUrl, id, "password12", null, false);
fail("old password");
} catch (NixMsgException e) {
assertTrue(
"code=" + e.getCode() + " msg=" + e.getMessage(),
e.getCode().contains("bad_credentials")
|| e.getCode().contains("auth")
|| e.getCode().contains("session_invalid")
|| "busy".equals(e.getCode()) && c3.getState() == ConnectionState.AUTH_FAILED);
}
Thread.sleep(2000);
assertEquals(ConnectionState.AUTH_FAILED, c3.getState());
c3.close();
}
@Test
public void test15SessionToken() throws Exception {
String id = uid("tok");
register(id);
Client c = new Client();
List<String> tokens = new ArrayList<String>();
c.onSession(tokens::add);
c.connectSync(srv.wsUrl, id, "password12", null, false);
assertFalse(tokens.isEmpty());
String token = tokens.get(0);
c.close();
Client c2 = connect(id, null, token);
c2.close();
Client c3 = connect(id);
String newTok = c3.getSessionToken();
assertNotNull(newTok);
assertFalse(token.equals(newTok));
c3.close();
Client c4 = new Client();
NixMsgException c4err = null;
try {
c4.connectSync(srv.wsUrl, id, null, token, false);
fail("old token");
} catch (NixMsgException e) {
c4err = e;
}
assertTrue(waitUntil(() -> c4.getState() == ConnectionState.AUTH_FAILED, 10_000));
assertNotNull(c4err);
c4.close();
Client c5 = connect(id);
String tok5 = c5.getSessionToken();
c5.logout().get(10, TimeUnit.SECONDS);
Thread.sleep(300);
Client c6 = new Client();
NixMsgException c6err = null;
try {
c6.connectSync(srv.wsUrl, id, null, tok5, false);
fail("logout token");
} catch (NixMsgException e) {
c6err = e;
}
assertTrue(waitUntil(() -> c6.getState() == ConnectionState.AUTH_FAILED, 10_000));
assertNotNull(c6err);
c6.close();
}
}
@@ -0,0 +1,251 @@
package asia.asio.nixmsg;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
/** 真实 nixmsg:临时目录、127.0.0.1:0、admin init、开注册。 */
final class TestHarness {
private static final Gson GSON = new Gson();
final Path dataDir;
final Path configPath;
final Path binary;
final String adminPassword;
Process process;
String httpBase;
String wsUrl;
String regCode = "s2java-reg-code";
TestHarness() throws Exception {
binary = ensureBinary();
dataDir = Files.createTempDirectory("nixmsg-s2-java-");
configPath = dataDir.resolve("config.yaml");
String yaml = "listen: \"127.0.0.1:0\"\ndata_dir: \""
+ dataDir.toAbsolutePath().toString().replace('\\', '/') + "\"\n";
Files.write(configPath, yaml.getBytes(StandardCharsets.UTF_8));
adminPassword = adminInit();
}
static Path findRepoRoot() throws IOException {
Path p = Paths.get("").toAbsolutePath().normalize();
for (int i = 0; i < 12; i++) {
if (Files.isRegularFile(p.resolve("go.mod")) && Files.isDirectory(p.resolve("cmd").resolve("nixmsg"))) {
return p;
}
Path parent = p.getParent();
if (parent == null) {
break;
}
p = parent;
}
throw new IOException("找不到仓库根 go.mod(cwd=" + Paths.get("").toAbsolutePath() + ")");
}
static Path ensureBinary() throws Exception {
String env = System.getenv("NIXMSG_BIN");
if (env != null && !env.isEmpty() && Files.isRegularFile(Paths.get(env))) {
return Paths.get(env);
}
Path root = findRepoRoot();
Path cache = Paths.get(System.getProperty("java.io.tmpdir"), "nixmsg-s2-java-bin");
Files.createDirectories(cache);
boolean win = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win");
Path out = cache.resolve(win ? "nixmsg.exe" : "nixmsg");
if (!Files.isRegularFile(out)) {
List<String> cmd = new ArrayList<String>();
cmd.add("go");
cmd.add("build");
cmd.add("-o");
cmd.add(out.toString());
cmd.add("./cmd/nixmsg");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(root.toFile());
pb.environment().put("CGO_ENABLED", "0");
pb.redirectErrorStream(true);
Process p = pb.start();
String log = readAll(p.getInputStream());
if (!waitFor(p, 180) || p.exitValue() != 0) {
throw new IllegalStateException("go build 失败: " + log);
}
}
return out;
}
private String adminInit() throws Exception {
ProcessBuilder pb = new ProcessBuilder(binary.toString(), "admin", "init");
pb.environment().put("NIXMSG_CONFIG", configPath.toString());
pb.redirectErrorStream(true);
Process p = pb.start();
String out = readAll(p.getInputStream());
if (!waitFor(p, 60) || p.exitValue() != 0) {
throw new IllegalStateException("admin init 失败: " + out);
}
String[] lines = out.split("\\r?\\n");
for (String line : lines) {
String t = line.trim();
String lower = t.toLowerCase(Locale.ROOT);
if (lower.startsWith("admin password:")) {
return t.substring(t.indexOf(':') + 1).trim();
}
if (lower.startsWith("password:")) {
return t.substring(t.indexOf(':') + 1).trim();
}
}
throw new IllegalStateException("admin init 未解析密码: " + out);
}
void start() throws Exception {
ProcessBuilder pb = new ProcessBuilder(binary.toString(), "serve");
pb.environment().put("NIXMSG_CONFIG", configPath.toString());
File nul = new File(System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win") ? "NUL" : "/dev/null");
pb.redirectError(ProcessBuilder.Redirect.to(nul));
pb.redirectOutput(ProcessBuilder.Redirect.to(nul));
process = pb.start();
Path addrFile = dataDir.resolve("listen.addr");
long deadline = System.currentTimeMillis() + 20_000L;
String addr = null;
while (System.currentTimeMillis() < deadline) {
if (Files.isRegularFile(addrFile)) {
addr = new String(Files.readAllBytes(addrFile), StandardCharsets.UTF_8).trim();
if (!addr.isEmpty()) {
break;
}
}
if (!isAlive(process)) {
throw new IllegalStateException("serve 提前退出");
}
Thread.sleep(50L);
}
if (addr == null || addr.isEmpty()) {
stop();
throw new IllegalStateException("等待 listen.addr 超时");
}
httpBase = "http://" + addr;
wsUrl = "ws://" + addr + "/mqtt";
setRegistration(true, regCode);
}
void setRegistration(boolean enabled, String code) throws Exception {
CookieManager cm = new CookieManager();
CookieHandler.setDefault(cm);
postJson("/api/admin/login", "{\"username\":\"admin\",\"password\":" + GSON.toJson(adminPassword) + "}");
JsonObject body = new JsonObject();
body.addProperty("enabled", enabled);
if (code != null) {
body.addProperty("code", code);
}
JsonObject resp = putJson("/api/admin/registration", body.toString());
if (!resp.has("ok") || !resp.get("ok").getAsBoolean()) {
throw new IllegalStateException("registration put failed: " + resp);
}
}
private JsonObject postJson(String path, String json) throws Exception {
return mutate("POST", path, json);
}
private JsonObject putJson(String path, String json) throws Exception {
return mutate("PUT", path, json);
}
private JsonObject mutate(String method, String path, String json) throws Exception {
URL url = new URL(httpBase + path);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod(method);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("X-Nixmsg-Request", "1");
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
conn.setFixedLengthStreamingMode(bytes.length);
OutputStream os = conn.getOutputStream();
try {
os.write(bytes);
} finally {
os.close();
}
int code = conn.getResponseCode();
InputStream in = code >= 400 ? conn.getErrorStream() : conn.getInputStream();
String raw = in == null ? "{}" : readAll(in);
if (code >= 400) {
throw new IllegalStateException(method + " " + path + " -> " + code + " " + raw);
}
return new JsonParser().parse(raw).getAsJsonObject();
}
void stop() {
if (process != null && isAlive(process)) {
process.destroy();
try {
waitFor(process, 2);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
if (isAlive(process)) {
process.destroyForcibly();
}
}
process = null;
try {
if (Files.isDirectory(dataDir)) {
List<Path> paths = new ArrayList<Path>();
Files.walk(dataDir).sorted(Comparator.reverseOrder()).forEach(paths::add);
for (Path p : paths) {
try {
Files.deleteIfExists(p);
} catch (IOException ignored) {
}
}
}
} catch (IOException ignored) {
}
}
private static boolean waitFor(Process p, long seconds) throws InterruptedException {
return p.waitFor(seconds, TimeUnit.SECONDS);
}
private static boolean isAlive(Process p) {
try {
p.exitValue();
return false;
} catch (IllegalThreadStateException e) {
return true;
}
}
private static String readAll(InputStream in) throws IOException {
if (in == null) {
return "";
}
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
in.close();
return new String(bos.toByteArray(), StandardCharsets.UTF_8);
}
}