fix: 按 K-00 约定修复 JS SDK 断线重交与退避
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Client, FakeTransport, APIError, buildCleanConnectFlags, register } from "../src/index.js";
|
||||
import { Client, APIError, buildCleanConnectFlags, register } from "../src/index.js";
|
||||
import { FakeTransport } from "../src/fake.js";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
async function connectFake(fake: FakeTransport): Promise<Client> {
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
APIError,
|
||||
Client,
|
||||
DEFAULT_KEEPALIVE,
|
||||
ReconnectBackoff,
|
||||
disableJitterForTest,
|
||||
normalizeMQTTURL,
|
||||
restoreJitterForTest,
|
||||
} from "../src/index.js";
|
||||
import { FakeTransport } from "../src/fake.js";
|
||||
|
||||
afterEach(() => {
|
||||
restoreJitterForTest();
|
||||
});
|
||||
|
||||
async function connectFake(fake: FakeTransport, opts: Record<string, unknown> = {}): Promise<Client> {
|
||||
const c = new Client();
|
||||
await c.connect("ws://example.test/mqtt", "ep1", { password: "p" }, { transport: fake, ...opts });
|
||||
return c;
|
||||
}
|
||||
|
||||
describe("K00", () => {
|
||||
it("TestK00FirstConnectTimeout", async () => {
|
||||
const fake = new FakeTransport();
|
||||
fake.autoHello = false;
|
||||
const c = new Client();
|
||||
await expect(
|
||||
c.connect("ws://example.test/mqtt", "ep1", { password: "p" }, {
|
||||
transport: fake,
|
||||
connectTimeoutMs: 150,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "not_connected" });
|
||||
fake.autoHello = true;
|
||||
await c.connect("ws://example.test/mqtt", "ep1", { password: "p" }, {
|
||||
transport: fake,
|
||||
connectTimeoutMs: 2000,
|
||||
});
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("TestK00AuthErrorCodes", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
fake.simulateAuthFail("bad_credentials");
|
||||
const deadline = Date.now() + 1000;
|
||||
while (Date.now() < deadline && c.lastStopCodeForTest() !== "bad_credentials") {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
expect(c.lastStopCodeForTest()).toBe("bad_credentials");
|
||||
await expect(
|
||||
c.send({ kind: "endpoint", id: "b" }, { enc: "utf8", data: "x" }),
|
||||
).rejects.toMatchObject({ code: "bad_credentials" });
|
||||
});
|
||||
|
||||
it("TestK00TakenOverReason", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
let got = "";
|
||||
c.onConnectionHandler((ev) => {
|
||||
if (ev.state === "kicked") got = ev.reason ?? "";
|
||||
});
|
||||
fake.simulateKick();
|
||||
const deadline = Date.now() + 1000;
|
||||
while (Date.now() < deadline && got !== "taken_over") {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
expect(got).toBe("taken_over");
|
||||
expect(c.lastStopCodeForTest()).toBe("taken_over");
|
||||
});
|
||||
|
||||
it("TestK00Disconnect8BRetryable", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
fake.simulateServerDisconnect(0x8b);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(c.lastStopCodeForTest()).not.toBe("taken_over");
|
||||
await fake.simulateConnectOK();
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("TestK00QueueFull", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake, { sendQueueSize: 1 });
|
||||
const p = c.send({ kind: "endpoint", id: "b" }, { enc: "utf8", data: "1" });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await expect(
|
||||
c.send({ kind: "endpoint", id: "b" }, { enc: "utf8", data: "2" }),
|
||||
).rejects.toMatchObject({ code: "queue_full" });
|
||||
fake.replyOK(String(fake.findUp("send")[0]?.rid), { id: "x", state: "accepted" });
|
||||
await p;
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("TestK00RequestReturnsData", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
const timer = setInterval(() => {
|
||||
for (const fr of fake.findUp("self.get")) {
|
||||
fake.replyOK(String(fr.rid), { id: "ep1", name: "n", default_delay_ms: 0 });
|
||||
}
|
||||
}, 5);
|
||||
const info = (await c.getSelf()) as { id: string; name: string };
|
||||
clearInterval(timer);
|
||||
expect(info.id).toBe("ep1");
|
||||
expect(info.name).toBe("n");
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("TestK00SendAtAndDelayConflict", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
await expect(
|
||||
c.send(
|
||||
{ kind: "endpoint", id: "b" },
|
||||
{ enc: "utf8", data: "x" },
|
||||
{ sendAt: new Date(1), delayMs: 1000 },
|
||||
),
|
||||
).rejects.toMatchObject({ code: "bad_request" });
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("TestK00SendAfterStopped", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
fake.simulateKick();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await expect(
|
||||
c.send({ kind: "endpoint", id: "b" }, { enc: "utf8", data: "x" }),
|
||||
).rejects.toMatchObject({ code: "taken_over" });
|
||||
});
|
||||
|
||||
it("TestK00LogoutReturnsError", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
fake.simulateServerDisconnect(0x8b);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await expect(c.logout()).rejects.toMatchObject({ code: "not_connected" });
|
||||
await expect(
|
||||
c.send({ kind: "endpoint", id: "b" }, { enc: "utf8", data: "x" }),
|
||||
).rejects.toBeInstanceOf(APIError);
|
||||
});
|
||||
|
||||
it("TestK00DurationInt64", () => {
|
||||
const sd = JSON.parse(`{"id":"m1","send_at_ms":123,"state":"scheduled"}`) as {
|
||||
id: string;
|
||||
send_at_ms: number;
|
||||
state: string;
|
||||
};
|
||||
expect(sd.id).toBe("m1");
|
||||
expect(sd.send_at_ms).toBe(123);
|
||||
expect(sd.state).toBe("scheduled");
|
||||
const ms = 30 * 24 * 3600 * 1000;
|
||||
expect(ms).toBe(2592000000);
|
||||
});
|
||||
|
||||
it("TestK00MaxReceiveBytesMin", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = new Client();
|
||||
await expect(
|
||||
c.connect("ws://example.test/mqtt", "ep1", { password: "p" }, {
|
||||
transport: fake,
|
||||
maxReceiveBytes: 512,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "bad_request" });
|
||||
});
|
||||
|
||||
it("TestK00URLMapping", () => {
|
||||
const a = new URL(normalizeMQTTURL("https://host:7443/", false));
|
||||
expect(a.protocol).toBe("wss:");
|
||||
expect(a.pathname).toBe("/mqtt");
|
||||
const b = new URL(normalizeMQTTURL("http://host/app", false));
|
||||
expect(b.protocol).toBe("ws:");
|
||||
expect(b.pathname).toBe("/app");
|
||||
expect(() => normalizeMQTTURL("mqtt://host:1883", false)).toThrow();
|
||||
expect(normalizeMQTTURL("mqtt://host:1883", true)).toContain("mqtt:");
|
||||
});
|
||||
|
||||
it("TestK00CancelUnsent", async () => {
|
||||
const fake = new FakeTransport();
|
||||
fake.autoHello = false;
|
||||
const c = new Client();
|
||||
const connecting = c
|
||||
.connect("ws://example.test/mqtt", "ep1", { password: "p" }, {
|
||||
transport: fake,
|
||||
connectTimeoutMs: 2000,
|
||||
})
|
||||
.catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
const ac = new AbortController();
|
||||
const p = c.send({ kind: "endpoint", id: "b" }, { enc: "utf8", data: "x" }, { signal: ac.signal });
|
||||
ac.abort();
|
||||
await expect(p).rejects.toBeTruthy();
|
||||
expect(c.resendPayloadForTest()).toBeUndefined();
|
||||
await c.close();
|
||||
await connecting;
|
||||
});
|
||||
|
||||
it("TestK00RateLimitedBackoff", async () => {
|
||||
disableJitterForTest();
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
const rids: string[] = [];
|
||||
let id0 = "";
|
||||
let sendAt: unknown;
|
||||
const timer = setInterval(() => {
|
||||
const sends = fake.findUp("send");
|
||||
if (!sends.length) return;
|
||||
const last = sends[sends.length - 1]!;
|
||||
const rid = String(last.rid);
|
||||
if (!rids.length) {
|
||||
id0 = String(last.id);
|
||||
sendAt = last.send_at_ms;
|
||||
rids.push(rid);
|
||||
fake.replyErr(rid, "rate_limited", "slow");
|
||||
return;
|
||||
}
|
||||
if (rid === rids[rids.length - 1]) return;
|
||||
rids.push(rid);
|
||||
if (rids.length < 3) {
|
||||
fake.replyErr(rid, "rate_limited", "slow");
|
||||
return;
|
||||
}
|
||||
expect(last.id).toBe(id0);
|
||||
expect(last.send_at_ms).toBe(sendAt);
|
||||
fake.replyOK(rid, { id: id0, send_at_ms: sendAt, state: "scheduled" });
|
||||
}, 5);
|
||||
const at = new Date(1_700_000_000_000);
|
||||
await c.send({ kind: "endpoint", id: "b" }, { enc: "utf8", data: "hi" }, { sendAt: at });
|
||||
clearInterval(timer);
|
||||
expect(rids).toHaveLength(3);
|
||||
expect(new Set(rids).size).toBe(3);
|
||||
await c.close();
|
||||
}, 10000);
|
||||
|
||||
it("TestK00ReconnectBackoff", () => {
|
||||
const b = new ReconnectBackoff();
|
||||
expect(b.nextWaitNoJitter()).toBe(0);
|
||||
const got: number[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
b.markOffline();
|
||||
got.push(b.nextWaitNoJitter());
|
||||
}
|
||||
expect(got).toEqual([1000, 2000, 4000, 8000, 16000, 30000]);
|
||||
b.markOnline();
|
||||
b.setOnlineAtForTest(Date.now());
|
||||
b.markOffline();
|
||||
expect(b.nextWaitNoJitter()).toBe(30000);
|
||||
const b2 = new ReconnectBackoff();
|
||||
b2.nextWaitNoJitter();
|
||||
b2.markOnline();
|
||||
b2.setOnlineAtForTest(Date.now() - 61_000);
|
||||
b2.markOffline();
|
||||
expect(b2.nextWaitNoJitter()).toBe(1000);
|
||||
});
|
||||
|
||||
it("TestK00KeepaliveDefault", () => {
|
||||
expect(DEFAULT_KEEPALIVE).toBe(30);
|
||||
});
|
||||
|
||||
it("TestK00NoReceiveMaximum", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
const cs = fake.getConnects();
|
||||
expect(cs.length).toBeGreaterThan(0);
|
||||
expect(cs[0]?.receiveMaximumSet).toBe(false);
|
||||
await c.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Client, disableJitterForTest, restoreJitterForTest } from "../src/index.js";
|
||||
import { FakeTransport } from "../src/fake.js";
|
||||
|
||||
afterEach(() => {
|
||||
restoreJitterForTest();
|
||||
});
|
||||
|
||||
async function connectFake(fake: FakeTransport): Promise<Client> {
|
||||
const c = new Client();
|
||||
await c.connect("ws://example.test/mqtt", "ep1", { password: "p" }, { transport: fake });
|
||||
return c;
|
||||
}
|
||||
|
||||
describe("K02", () => {
|
||||
it("TestK01InflightResendAfterDisconnect", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
let firstRID = "";
|
||||
let firstID = "";
|
||||
let firstSendAt: unknown;
|
||||
const timer = setInterval(() => {
|
||||
const sends = fake.findUp("send");
|
||||
if (!sends.length) return;
|
||||
const last = sends[sends.length - 1]!;
|
||||
const rid = String(last.rid);
|
||||
if (!firstRID) {
|
||||
firstRID = rid;
|
||||
firstID = String(last.id);
|
||||
firstSendAt = last.send_at_ms;
|
||||
void fake.simulateReconnect();
|
||||
return;
|
||||
}
|
||||
if (rid !== firstRID) {
|
||||
expect(last.id).toBe(firstID);
|
||||
expect(last.send_at_ms).toBe(firstSendAt);
|
||||
fake.replyOK(rid, { id: firstID, send_at_ms: firstSendAt, state: "accepted" });
|
||||
}
|
||||
}, 5);
|
||||
const at = new Date(1_700_000_000_111);
|
||||
await c.send({ kind: "endpoint", id: "b" }, { enc: "utf8", data: "hi" }, { sendAt: at });
|
||||
clearInterval(timer);
|
||||
await c.close();
|
||||
}, 10000);
|
||||
|
||||
it("unhandledRejection stays zero on ack publish throw", async () => {
|
||||
const rejections: unknown[] = [];
|
||||
const onRej = (e: unknown) => {
|
||||
rejections.push(e);
|
||||
};
|
||||
process.on("unhandledRejection", onRej);
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
fake.publishUpImpl = async (s) => {
|
||||
if (s.includes('"type":"ack"')) throw new Error("ack publish fail");
|
||||
};
|
||||
c.onMessageHandler(() => {});
|
||||
fake.injectDown(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
type: "msg",
|
||||
id: "m1",
|
||||
from: "a",
|
||||
to: { kind: "endpoint", id: "ep1" },
|
||||
body: { enc: "utf8", data: "hi" },
|
||||
send_at_ms: 1,
|
||||
}),
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
process.off("unhandledRejection", onRej);
|
||||
expect(rejections).toHaveLength(0);
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("ack fail then same msg re-acks without second callback", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
let calls = 0;
|
||||
c.onMessageHandler(() => {
|
||||
calls++;
|
||||
});
|
||||
const msg = JSON.stringify({
|
||||
v: 1,
|
||||
type: "msg",
|
||||
id: "m1",
|
||||
from: "a",
|
||||
to: { kind: "endpoint", id: "ep1" },
|
||||
body: { enc: "utf8", data: "hi" },
|
||||
send_at_ms: 1,
|
||||
});
|
||||
fake.injectDown(msg);
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
for (const fr of fake.findUp("ack")) {
|
||||
fake.replyErr(String(fr.rid), "not_found", "gone");
|
||||
}
|
||||
fake.injectDown(msg);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
expect(calls).toBe(1);
|
||||
expect(fake.findUp("ack").length).toBeGreaterThanOrEqual(2);
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("revoked after msg skips callback", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
let msgs = 0;
|
||||
let revs = 0;
|
||||
c.onMessageHandler(() => {
|
||||
msgs++;
|
||||
});
|
||||
c.onRevokedHandler(() => {
|
||||
revs++;
|
||||
});
|
||||
fake.injectDown(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
type: "msg",
|
||||
id: "m1",
|
||||
from: "a",
|
||||
to: { kind: "endpoint", id: "ep1" },
|
||||
body: { enc: "utf8", data: "hi" },
|
||||
send_at_ms: 1,
|
||||
}),
|
||||
);
|
||||
fake.injectDown(JSON.stringify({ v: 1, type: "revoked", id: "m1", from: "a", reason: "recalled" }));
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
expect(msgs).toBe(0);
|
||||
expect(revs).toBe(1);
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("TestK01WatchRestored", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
const timer = setInterval(() => {
|
||||
for (const fr of fake.findUp("presence.watch")) {
|
||||
fake.replyOK(String(fr.rid), {});
|
||||
}
|
||||
}, 5);
|
||||
await c.watchPresence(["a", "b"]);
|
||||
const n1 = fake.findUp("presence.watch").length;
|
||||
await fake.simulateReconnect();
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline && fake.findUp("presence.watch").length <= n1) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
clearInterval(timer);
|
||||
expect(fake.findUp("presence.watch").length).toBeGreaterThan(n1);
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("TestK01FatalOnce", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
let n = 0;
|
||||
c.onConnectionHandler((ev) => {
|
||||
if (ev.state === "auth_failed" && ev.reason === "disabled") n++;
|
||||
});
|
||||
const fatal = JSON.stringify({ v: 1, type: "fatal", reason: "disabled" });
|
||||
fake.injectDown(fatal);
|
||||
fake.injectDown(fatal);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(n).toBe(1);
|
||||
});
|
||||
|
||||
it("TestK01CallbackNoDeadlock", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
const timer = setInterval(() => {
|
||||
for (const typ of ["ack", "self.login_password"]) {
|
||||
for (const fr of fake.findUp(typ)) {
|
||||
fake.replyOK(String(fr.rid), typ === "ack" ? { result: "accepted" } : {});
|
||||
}
|
||||
}
|
||||
}, 5);
|
||||
let done = false;
|
||||
c.onMessageHandler(async (msg) => {
|
||||
await c.changeLoginPassword("old", "newpass12");
|
||||
await c.ack(msg);
|
||||
done = true;
|
||||
});
|
||||
fake.injectDown(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
type: "msg",
|
||||
id: "m1",
|
||||
from: "a",
|
||||
to: { kind: "endpoint", id: "ep1" },
|
||||
body: { enc: "utf8", data: "hi" },
|
||||
send_at_ms: 1,
|
||||
}),
|
||||
);
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline && !done) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
clearInterval(timer);
|
||||
expect(done).toBe(true);
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("rate_limited rids unique with disabled jitter", async () => {
|
||||
disableJitterForTest();
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
const rids: string[] = [];
|
||||
const timer = setInterval(() => {
|
||||
const sends = fake.findUp("send");
|
||||
if (!sends.length) return;
|
||||
const last = sends[sends.length - 1]!;
|
||||
const rid = String(last.rid);
|
||||
if (rids.includes(rid)) return;
|
||||
rids.push(rid);
|
||||
if (rids.length < 3) fake.replyErr(rid, "rate_limited", "slow");
|
||||
else fake.replyOK(rid, { id: last.id, send_at_ms: last.send_at_ms, state: "scheduled" });
|
||||
}, 5);
|
||||
await c.send(
|
||||
{ kind: "endpoint", id: "b" },
|
||||
{ enc: "utf8", data: "hi" },
|
||||
{ sendAt: new Date(1_700_000_000_000) },
|
||||
);
|
||||
clearInterval(timer);
|
||||
expect(new Set(rids).size).toBe(3);
|
||||
await c.close();
|
||||
}, 10000);
|
||||
});
|
||||
Reference in New Issue
Block a user