fix: 按 K-00 约定修复 JS SDK 断线重交与退避

This commit is contained in:
Nixevol
2026-09-30 16:24:13 +08:00
parent f6f8ccf269
commit 55aa0ccf53
10 changed files with 1049 additions and 184 deletions
+269
View File
@@ -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();
});
});