openclaw-channel-dmwork 0.5.11 → 0.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-channel-dmwork",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
4
4
  "description": "DMWork channel plugin for OpenClaw via WuKongIM WebSocket",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Bug fix verification tests for WebSocket reconnect v2 (Issue #139)
3
+ *
4
+ * These tests verify that three bugs have been FIXED:
5
+ * Bug A: Exponential backoff now works (reconnectAttempts not reset prematurely)
6
+ * Bug B: Kicked bots reconnect even during cooldown window
7
+ * Bug C: Rapid silent disconnects now trigger onError for token refresh
8
+ */
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
10
+
11
+ // Track mock WS instances created during tests
12
+ const mockWsInstances: any[] = [];
13
+
14
+ vi.mock("ws", () => {
15
+ class MockWS {
16
+ static OPEN = 1;
17
+ binaryType = "arraybuffer";
18
+ readyState = 1;
19
+ private handlers = new Map<string, Function[]>();
20
+
21
+ constructor(public url: string) {
22
+ (globalThis as any).__mockWsInstances?.push(this);
23
+ }
24
+
25
+ on(event: string, handler: Function) {
26
+ if (!this.handlers.has(event)) this.handlers.set(event, []);
27
+ this.handlers.get(event)!.push(handler);
28
+ }
29
+
30
+ send = vi.fn();
31
+
32
+ close() {
33
+ this.readyState = 3; // CLOSED
34
+ queueMicrotask(() => this.emit("close"));
35
+ }
36
+
37
+ emit(event: string, ...args: any[]) {
38
+ const handlers = this.handlers.get(event);
39
+ if (handlers) {
40
+ for (const h of handlers) h(...args);
41
+ }
42
+ }
43
+ }
44
+
45
+ return { default: MockWS, WebSocket: MockWS };
46
+ });
47
+
48
+ vi.mock("curve25519-js", () => ({
49
+ generateKeyPair: () => ({
50
+ private: new Uint8Array(32),
51
+ public: new Uint8Array(32),
52
+ }),
53
+ sharedKey: () => new Uint8Array(32),
54
+ }));
55
+
56
+ import { WKSocket } from "./socket.js";
57
+
58
+ // ─── Helpers ────────────────────────────────────────────────────────────────
59
+
60
+ /** Build a minimal CONNACK packet with reasonCode=1 (success) */
61
+ function buildConnackSuccess(): ArrayBuffer {
62
+ const serverVersion = 4;
63
+ const reasonCode = 1; // success
64
+ const serverKey = Buffer.from(new Uint8Array(32)).toString("base64");
65
+ const salt = "1234567890123456";
66
+
67
+ const body: number[] = [];
68
+ body.push(serverVersion);
69
+ for (let i = 0; i < 8; i++) body.push(0); // timeDiff
70
+ body.push(reasonCode);
71
+ const keyBytes = [...Buffer.from(serverKey)];
72
+ body.push((keyBytes.length >> 8) & 0xff, keyBytes.length & 0xff);
73
+ body.push(...keyBytes);
74
+ const saltBytes = [...Buffer.from(salt)];
75
+ body.push((saltBytes.length >> 8) & 0xff, saltBytes.length & 0xff);
76
+ body.push(...saltBytes);
77
+ for (let i = 0; i < 8; i++) body.push(0); // nodeId
78
+
79
+ const header = (2 << 4) | 1; // CONNACK with hasServerVersion
80
+ const packet = new Uint8Array([header, body.length, ...body]);
81
+ return packet.buffer;
82
+ }
83
+
84
+ /** Build a CONNACK packet with reasonCode=0 (kicked) */
85
+ function buildConnackKicked(): ArrayBuffer {
86
+ const serverVersion = 4;
87
+ const reasonCode = 0; // kicked
88
+
89
+ const body: number[] = [];
90
+ body.push(serverVersion);
91
+ for (let i = 0; i < 8; i++) body.push(0); // timeDiff
92
+ body.push(reasonCode);
93
+ // serverKey (empty)
94
+ body.push(0, 0);
95
+ // salt (empty)
96
+ body.push(0, 0);
97
+ for (let i = 0; i < 8; i++) body.push(0); // nodeId
98
+
99
+ const header = (2 << 4) | 1;
100
+ const packet = new Uint8Array([header, body.length, ...body]);
101
+ return packet.buffer;
102
+ }
103
+
104
+ // ─── Test Suites ────────────────────────────────────────────────────────────
105
+
106
+ describe("Bug A fix: Exponential backoff grows correctly", () => {
107
+ let setTimeoutCalls: { fn: Function; delay: number }[];
108
+ let originalSetTimeout: typeof setTimeout;
109
+ let dateNowSpy: ReturnType<typeof vi.spyOn>;
110
+
111
+ beforeEach(() => {
112
+ mockWsInstances.length = 0;
113
+ (globalThis as any).__mockWsInstances = mockWsInstances;
114
+ setTimeoutCalls = [];
115
+ originalSetTimeout = global.setTimeout;
116
+ global.setTimeout = vi.fn((fn: Function, delay?: number) => {
117
+ setTimeoutCalls.push({ fn, delay: delay ?? 0 });
118
+ return 999 as any;
119
+ }) as any;
120
+ });
121
+
122
+ afterEach(() => {
123
+ global.setTimeout = originalSetTimeout;
124
+ dateNowSpy?.mockRestore();
125
+ delete (globalThis as any).__mockWsInstances;
126
+ vi.restoreAllMocks();
127
+ });
128
+
129
+ it("delays should GROW after 5 connect→CONNACK→close cycles", () => {
130
+ /**
131
+ * With the fix, reconnectAttempts only resets after the 30s stable timer
132
+ * fires (which requires the connection to stay up for 30s). Connections
133
+ * that live <30s keep the backoff counter, so delays grow exponentially.
134
+ *
135
+ * We mock Date.now so each connection appears to last 6s (>5s threshold)
136
+ * to isolate Bug A from Bug C's rapid-disconnect detection.
137
+ */
138
+ let now = 10000;
139
+ dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
140
+
141
+ const socket = new WKSocket({
142
+ wsUrl: "ws://test:5200",
143
+ uid: "bot1",
144
+ token: "tok1",
145
+ onMessage: vi.fn(),
146
+ onConnected: vi.fn(),
147
+ onDisconnected: vi.fn(),
148
+ });
149
+
150
+ const reconnectDelays: number[] = [];
151
+
152
+ for (let cycle = 0; cycle < 5; cycle++) {
153
+ socket.connect();
154
+ const ws = mockWsInstances[mockWsInstances.length - 1];
155
+ ws.emit("open");
156
+
157
+ // CONNACK success → sets lastConnectTime = now, starts stable timer
158
+ ws.emit("message", buildConnackSuccess());
159
+
160
+ // Advance time by 6s (above 5s rapid-disconnect threshold,
161
+ // but well below the 30s stable timer)
162
+ now += 6000;
163
+
164
+ setTimeoutCalls = [];
165
+ ws.emit("close");
166
+
167
+ // The only setTimeout from the close handler is scheduleReconnect
168
+ if (setTimeoutCalls.length > 0) {
169
+ reconnectDelays.push(setTimeoutCalls[0].delay);
170
+ }
171
+ }
172
+
173
+ // All 5 cycles should have produced reconnect delays
174
+ expect(reconnectDelays).toHaveLength(5);
175
+
176
+ // Delays should strictly increase (exponential backoff working)
177
+ for (let i = 1; i < reconnectDelays.length; i++) {
178
+ expect(reconnectDelays[i]).toBeGreaterThan(reconnectDelays[i - 1]);
179
+ }
180
+
181
+ // First delay ~3000ms (base), with ±25% jitter → [2250, 3750]
182
+ expect(reconnectDelays[0]).toBeGreaterThanOrEqual(2250);
183
+ expect(reconnectDelays[0]).toBeLessThanOrEqual(4500);
184
+
185
+ // Last delay should be much larger (~48000ms at attempt 4)
186
+ expect(reconnectDelays[4]).toBeGreaterThan(20000);
187
+
188
+ socket.disconnect();
189
+ });
190
+ });
191
+
192
+
193
+ describe("Bug B fix: Kicked during cooldown still reconnects", () => {
194
+ it("channel.ts onError handler reconnects even when cooldown is active", () => {
195
+ /**
196
+ * With the fix, the else branch in channel.ts onError reconnects the
197
+ * bot with current credentials when cooldown is active, instead of
198
+ * doing nothing and letting the bot die permanently.
199
+ */
200
+ let lastTokenRefreshAt = 0;
201
+ const TOKEN_REFRESH_COOLDOWN_MS = 60_000;
202
+ let isRefreshingToken = false;
203
+ let stopped = false;
204
+ let tokenRefreshCount = 0;
205
+ let reconnectCount = 0;
206
+
207
+ // Simulate the FIXED onError handler from channel.ts
208
+ function onError(err: Error) {
209
+ const cooldownElapsed = Date.now() - lastTokenRefreshAt > TOKEN_REFRESH_COOLDOWN_MS;
210
+ if (cooldownElapsed && !isRefreshingToken && !stopped &&
211
+ (err.message.includes("Kicked") || err.message.includes("Connect failed"))) {
212
+ isRefreshingToken = true;
213
+ lastTokenRefreshAt = Date.now();
214
+ tokenRefreshCount++;
215
+ // Would do: socket.disconnect() + socket.updateCredentials() + socket.connect()
216
+ reconnectCount++;
217
+ isRefreshingToken = false;
218
+ } else if (!isRefreshingToken && !stopped &&
219
+ (err.message.includes("Kicked") || err.message.includes("Connect failed"))) {
220
+ // FIX: Cooldown active — skip token refresh but still reconnect
221
+ // Would do: socket.disconnect() + socket.connect()
222
+ reconnectCount++;
223
+ }
224
+ }
225
+
226
+ // First kick — cooldown not active yet, full refresh happens
227
+ onError(new Error("Kicked by server"));
228
+ expect(tokenRefreshCount).toBe(1);
229
+ expect(reconnectCount).toBe(1);
230
+
231
+ // Second kick — within 60s cooldown
232
+ onError(new Error("Kicked by server"));
233
+
234
+ // FIX VERIFIED: reconnect still happens (via else branch), just no token refresh
235
+ expect(tokenRefreshCount).toBe(1); // no new refresh (cooldown active)
236
+ expect(reconnectCount).toBe(2); // reconnect DID happen!
237
+ });
238
+
239
+ it("socket reconnects after channel.ts calls disconnect+connect on kick", () => {
240
+ /**
241
+ * When CONNACK returns kicked (reasonCode=0), needReconnect is set to false
242
+ * and the close event won't trigger scheduleReconnect. But channel.ts can
243
+ * call socket.disconnect() + socket.connect() to revive the bot.
244
+ */
245
+ const originalSetTimeout = global.setTimeout;
246
+ const setTimeoutCalls: { fn: Function; delay: number }[] = [];
247
+ global.setTimeout = vi.fn((fn: Function, delay?: number) => {
248
+ setTimeoutCalls.push({ fn, delay: delay ?? 0 });
249
+ return 999 as any;
250
+ }) as any;
251
+
252
+ mockWsInstances.length = 0;
253
+ (globalThis as any).__mockWsInstances = mockWsInstances;
254
+
255
+ const onError = vi.fn();
256
+ const socket = new WKSocket({
257
+ wsUrl: "ws://test:5200",
258
+ uid: "bot1",
259
+ token: "tok1",
260
+ onMessage: vi.fn(),
261
+ onError,
262
+ });
263
+
264
+ // Initial connection → kicked
265
+ socket.connect();
266
+ const ws1 = mockWsInstances[0];
267
+ ws1.emit("open");
268
+ ws1.emit("message", buildConnackKicked());
269
+
270
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({
271
+ message: "Kicked by server",
272
+ }));
273
+
274
+ // needReconnect is false after kick, close won't trigger scheduleReconnect
275
+ setTimeoutCalls.length = 0;
276
+ ws1.emit("close");
277
+ expect(setTimeoutCalls.length).toBe(0);
278
+
279
+ // FIX: channel.ts calls disconnect + connect → bot reconnects
280
+ socket.disconnect();
281
+ socket.connect();
282
+
283
+ // Verify a new WebSocket was created (bot is alive!)
284
+ expect(mockWsInstances.length).toBeGreaterThan(1);
285
+ const ws2 = mockWsInstances[mockWsInstances.length - 1];
286
+ expect(ws2).not.toBe(ws1);
287
+
288
+ socket.disconnect();
289
+ global.setTimeout = originalSetTimeout;
290
+ delete (globalThis as any).__mockWsInstances;
291
+ vi.restoreAllMocks();
292
+ });
293
+ });
294
+
295
+
296
+ describe("Bug C fix: Rapid silent disconnects trigger onError", () => {
297
+ it("onError fires after 3 consecutive rapid disconnects", () => {
298
+ /**
299
+ * With the fix, the close handler tracks rapid disconnects (connections
300
+ * lasting <5s). After 3 consecutive rapid disconnects, it calls onError
301
+ * with "Connect failed: rapid disconnect detected" so channel.ts can
302
+ * refresh the token.
303
+ */
304
+ const originalSetTimeout = global.setTimeout;
305
+ global.setTimeout = vi.fn((fn: Function, delay?: number) => {
306
+ return 999 as any;
307
+ }) as any;
308
+
309
+ mockWsInstances.length = 0;
310
+ (globalThis as any).__mockWsInstances = mockWsInstances;
311
+
312
+ const onError = vi.fn();
313
+ const socket = new WKSocket({
314
+ wsUrl: "ws://test:5200",
315
+ uid: "bot1",
316
+ token: "tok1",
317
+ onMessage: vi.fn(),
318
+ onError,
319
+ onConnected: vi.fn(),
320
+ onDisconnected: vi.fn(),
321
+ });
322
+
323
+ // Simulate 3 rapid connect→CONNACK→immediate-close cycles
324
+ // (connection lasting <5s each time, simulating server restart)
325
+ for (let i = 0; i < 3; i++) {
326
+ socket.connect();
327
+ const ws = mockWsInstances[mockWsInstances.length - 1];
328
+ ws.emit("open");
329
+ ws.emit("message", buildConnackSuccess());
330
+ // Connection closes immediately (server restart, no Kicked packet)
331
+ ws.emit("close");
332
+ }
333
+
334
+ // FIX VERIFIED: onError IS called after 3 rapid disconnects
335
+ expect(onError).toHaveBeenCalledTimes(1);
336
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({
337
+ message: "Connect failed: rapid disconnect detected",
338
+ }));
339
+
340
+ socket.disconnect();
341
+ global.setTimeout = originalSetTimeout;
342
+ delete (globalThis as any).__mockWsInstances;
343
+ vi.restoreAllMocks();
344
+ });
345
+
346
+ it("rapid-disconnect loop with stale token triggers onError for refresh", () => {
347
+ /**
348
+ * Simulates the production scenario: server restarts, bot reconnects with
349
+ * stale token, server silently closes again. After 3 such cycles, onError
350
+ * fires so channel.ts can refresh the token.
351
+ */
352
+ const originalSetTimeout = global.setTimeout;
353
+ global.setTimeout = vi.fn((fn: Function, delay?: number) => {
354
+ return 999 as any;
355
+ }) as any;
356
+
357
+ mockWsInstances.length = 0;
358
+ (globalThis as any).__mockWsInstances = mockWsInstances;
359
+
360
+ const onError = vi.fn();
361
+ const socket = new WKSocket({
362
+ wsUrl: "ws://test:5200",
363
+ uid: "bot1",
364
+ token: "stale-token",
365
+ onMessage: vi.fn(),
366
+ onError,
367
+ });
368
+
369
+ // 3 rapid connect→close cycles (simulating stale token scenario)
370
+ for (let i = 0; i < 3; i++) {
371
+ socket.connect();
372
+ const ws = mockWsInstances[mockWsInstances.length - 1];
373
+ ws.emit("open");
374
+ ws.emit("message", buildConnackSuccess());
375
+ ws.emit("close");
376
+ }
377
+
378
+ // FIX VERIFIED: After 3 short-lived connections, onError fires
379
+ // This allows channel.ts to refresh the token
380
+ expect(onError).toHaveBeenCalledTimes(1);
381
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({
382
+ message: "Connect failed: rapid disconnect detected",
383
+ }));
384
+
385
+ socket.disconnect();
386
+ global.setTimeout = originalSetTimeout;
387
+ delete (globalThis as any).__mockWsInstances;
388
+ vi.restoreAllMocks();
389
+ });
390
+ });
package/src/channel.ts CHANGED
@@ -640,13 +640,18 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
640
640
  botToken: account.config.botToken!,
641
641
  }).then(() => {
642
642
  consecutiveHeartbeatFailures = 0; // Reset on success
643
- }).catch((err) => {
643
+ }).catch(async (err) => {
644
644
  consecutiveHeartbeatFailures++;
645
- log?.error?.(`dmwork: heartbeat failed (${consecutiveHeartbeatFailures}/${MAX_HEARTBEAT_FAILURES}): ${String(err)}`);
645
+ log?.error?.(`dmwork: [${account.accountId}] heartbeat failed (${consecutiveHeartbeatFailures}/${MAX_HEARTBEAT_FAILURES}): ${String(err)}`);
646
646
  if (consecutiveHeartbeatFailures >= MAX_HEARTBEAT_FAILURES && !stopped) {
647
- log?.warn?.("dmwork: too many heartbeat failures, triggering reconnect...");
647
+ log?.warn?.(`dmwork: [${account.accountId}] too many heartbeat failures, triggering reconnect...`);
648
648
  consecutiveHeartbeatFailures = 0;
649
- socket.disconnect();
649
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
650
+ const backoffMs = 3000 + Math.floor(Math.random() * 2000);
651
+ await new Promise(r => setTimeout(r, backoffMs));
652
+ if (stopped) return;
653
+ await socket.disconnectAndWait();
654
+ socket.stopReconnectTimer();
650
655
  socket.connect();
651
656
  }
652
657
  });
@@ -670,7 +675,10 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
670
675
  const TOKEN_REFRESH_COOLDOWN_MS = 60_000; // 60 seconds
671
676
  let isRefreshingToken = false; // Guard against concurrent refreshes (#43)
672
677
 
673
- // 5b. Heartbeat failure trackingreconnect after consecutive failures (#42)
678
+ // 5b. Cooldown reconnect timerdeduplicate to prevent self-kick storms (#139)
679
+ let cooldownReconnectTimer: ReturnType<typeof setTimeout> | null = null;
680
+
681
+ // 5c. Heartbeat failure tracking — reconnect after consecutive failures (#42)
674
682
  let consecutiveHeartbeatFailures = 0;
675
683
  const MAX_HEARTBEAT_FAILURES = 3;
676
684
 
@@ -740,18 +748,20 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
740
748
  },
741
749
 
742
750
  onConnected: () => {
743
- log?.info?.(`dmwork: WebSocket connected to ${wsUrl}`);
751
+ log?.info?.(`dmwork: [${account.accountId}] WebSocket connected to ${wsUrl}`);
744
752
  statusSink({ lastError: null });
753
+ consecutiveHeartbeatFailures = 0;
745
754
  startHeartbeat();
746
755
  },
747
756
 
748
757
  onDisconnected: () => {
749
- log?.warn?.("dmwork: WebSocket disconnected, will reconnect...");
758
+ log?.warn?.(`dmwork: [${account.accountId}] WebSocket disconnected, will reconnect...`);
759
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
750
760
  statusSink({ lastError: "disconnected" });
751
761
  },
752
762
 
753
763
  onError: async (err: Error) => {
754
- log?.error?.(`dmwork: WebSocket error: ${err.message}`);
764
+ log?.error?.(`dmwork: [${account.accountId}] WebSocket error: ${err.message}`);
755
765
  statusSink({ lastError: err.message });
756
766
 
757
767
  // If kicked or connect failed, try refreshing the IM token with a cooldown
@@ -762,30 +772,49 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
762
772
  (err.message.includes("Kicked") || err.message.includes("Connect failed"))) {
763
773
  isRefreshingToken = true;
764
774
  lastTokenRefreshAt = Date.now();
765
- log?.warn?.("dmwork: connection rejected — refreshing IM token...");
775
+ log?.warn?.(`dmwork: [${account.accountId}] connection rejected — refreshing IM token...`);
766
776
  try {
777
+ await socket.disconnectAndWait();
767
778
  const fresh = await registerBot({
768
779
  apiUrl: account.config.apiUrl,
769
780
  botToken: account.config.botToken!,
770
781
  forceRefresh: true,
771
782
  });
772
783
  credentials = fresh;
773
- log?.info?.("dmwork: got fresh IM token, reconnecting WS...");
774
- socket.disconnect();
784
+ log?.info?.(`dmwork: [${account.accountId}] got fresh IM token, reconnecting WS...`);
775
785
  socket.updateCredentials(fresh.robot_id, fresh.im_token);
776
786
  // Stagger reconnect to avoid thundering herd when multiple bots
777
787
  // refresh tokens simultaneously after server-wide token expiry
778
788
  const staggerMs = Math.floor(Math.random() * 5000);
779
- log?.info?.(`dmwork: staggering reconnect by ${staggerMs}ms`);
789
+ log?.info?.(`dmwork: [${account.accountId}] staggering reconnect by ${staggerMs}ms`);
780
790
  await new Promise(r => setTimeout(r, staggerMs));
781
791
  if (stopped) return; // account was stopped during stagger delay
782
792
  socket.connect();
783
793
  } catch (refreshErr) {
784
- log?.error?.(`dmwork: token refresh failed: ${String(refreshErr)}`);
794
+ log?.error?.(`dmwork: [${account.accountId}] token refresh failed: ${String(refreshErr)}`);
785
795
  // Keep cooldown active even on failure to prevent rapid retry hammering
786
796
  } finally {
787
797
  isRefreshingToken = false;
788
798
  }
799
+ } else if (!isRefreshingToken && !stopped &&
800
+ (err.message.includes("Kicked") || err.message.includes("Connect failed"))) {
801
+ // Cooldown active — skip token refresh but still reconnect with current credentials.
802
+ // Deduplicate: clear any pending cooldown reconnect timer to prevent self-kick storms
803
+ // where multiple setTimeout callbacks fire simultaneously, each calling connect(),
804
+ // causing the same bot to have multiple WS connections that kick each other (#139).
805
+ if (cooldownReconnectTimer) {
806
+ clearTimeout(cooldownReconnectTimer);
807
+ }
808
+ log?.warn?.(`dmwork: [${account.accountId}] cooldown active, scheduling reconnect with current credentials...`);
809
+ const backoffMs = 5000 + Math.floor(Math.random() * 5000);
810
+ cooldownReconnectTimer = setTimeout(async () => {
811
+ cooldownReconnectTimer = null;
812
+ if (!stopped) {
813
+ await socket.disconnectAndWait();
814
+ socket.stopReconnectTimer();
815
+ socket.connect();
816
+ }
817
+ }, backoffMs);
789
818
  }
790
819
  },
791
820
  });
@@ -799,6 +828,7 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
799
828
  stopped = true;
800
829
  socket.disconnect();
801
830
  if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
831
+ if (cooldownReconnectTimer) { clearTimeout(cooldownReconnectTimer); cooldownReconnectTimer = null; }
802
832
  ctx.setStatus({
803
833
  accountId: account.accountId,
804
834
  running: false,
@@ -0,0 +1,541 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // Track mock WS instances created during tests
4
+ const mockWsInstances: any[] = [];
5
+
6
+ // Mock ws module
7
+ vi.mock("ws", () => {
8
+ class MockWS {
9
+ static OPEN = 1;
10
+ binaryType = "arraybuffer";
11
+ readyState = 1;
12
+ private handlers = new Map<string, Function[]>();
13
+
14
+ constructor(public url: string) {
15
+ (globalThis as any).__mockWsInstances?.push(this);
16
+ }
17
+
18
+ on(event: string, handler: Function) {
19
+ if (!this.handlers.has(event)) this.handlers.set(event, []);
20
+ this.handlers.get(event)!.push(handler);
21
+ }
22
+
23
+ send = vi.fn();
24
+
25
+ close() {
26
+ queueMicrotask(() => this.emit("close"));
27
+ }
28
+
29
+ terminate() {
30
+ queueMicrotask(() => this.emit("close"));
31
+ }
32
+
33
+ emit(event: string, ...args: any[]) {
34
+ const handlers = this.handlers.get(event);
35
+ if (handlers) {
36
+ for (const h of handlers) h(...args);
37
+ }
38
+ }
39
+ }
40
+
41
+ return { default: MockWS, WebSocket: MockWS };
42
+ });
43
+
44
+ // Mock curve25519-js to avoid real crypto in tests
45
+ vi.mock("curve25519-js", () => ({
46
+ generateKeyPair: () => ({
47
+ private: new Uint8Array(32),
48
+ public: new Uint8Array(32),
49
+ }),
50
+ sharedKey: () => new Uint8Array(32),
51
+ }));
52
+
53
+ import { WKSocket } from "./socket.js";
54
+
55
+ // Helper to build a CONNACK packet
56
+ function buildConnackPacket(reasonCode: number): ArrayBuffer {
57
+ const serverVersion = 4;
58
+ const serverKey = Buffer.from(new Uint8Array(32)).toString("base64");
59
+ const salt = "1234567890123456";
60
+
61
+ const body: number[] = [];
62
+ body.push(serverVersion);
63
+ for (let i = 0; i < 8; i++) body.push(0); // timeDiff
64
+ body.push(reasonCode);
65
+ const keyBytes = [...Buffer.from(serverKey)];
66
+ body.push((keyBytes.length >> 8) & 0xff, keyBytes.length & 0xff);
67
+ body.push(...keyBytes);
68
+ const saltBytes = [...Buffer.from(salt)];
69
+ body.push((saltBytes.length >> 8) & 0xff, saltBytes.length & 0xff);
70
+ body.push(...saltBytes);
71
+ for (let i = 0; i < 8; i++) body.push(0); // nodeId
72
+
73
+ const header = (2 << 4) | 1; // CONNACK with hasServerVersion
74
+ const packet = new Uint8Array([header, body.length, ...body]);
75
+ return packet.buffer;
76
+ }
77
+
78
+ // Helper to build a DISCONNECT packet
79
+ function buildDisconnectPacket(reasonCode = 0, reason = "kicked"): ArrayBuffer {
80
+ const body: number[] = [];
81
+ body.push(reasonCode);
82
+ // Write reason string (length-prefixed)
83
+ const reasonBytes = [...Buffer.from(reason)];
84
+ body.push((reasonBytes.length >> 8) & 0xff, reasonBytes.length & 0xff);
85
+ body.push(...reasonBytes);
86
+
87
+ const header = (9 << 4) | 0; // DISCONNECT
88
+ const packet = new Uint8Array([header, body.length, ...body]);
89
+ return packet.buffer;
90
+ }
91
+
92
+ function createSocket(overrides: Partial<ConstructorParameters<typeof WKSocket>[0]> = {}) {
93
+ return new WKSocket({
94
+ wsUrl: "ws://test:5200",
95
+ uid: "bot1",
96
+ token: "tok1",
97
+ onMessage: vi.fn(),
98
+ ...overrides,
99
+ });
100
+ }
101
+
102
+ describe("reconnect fixes", () => {
103
+ let originalSetTimeout: typeof setTimeout;
104
+ let setTimeoutCalls: { fn: Function; delay: number }[];
105
+
106
+ beforeEach(() => {
107
+ mockWsInstances.length = 0;
108
+ (globalThis as any).__mockWsInstances = mockWsInstances;
109
+ setTimeoutCalls = [];
110
+ originalSetTimeout = global.setTimeout;
111
+
112
+ global.setTimeout = vi.fn((fn: Function, delay?: number) => {
113
+ setTimeoutCalls.push({ fn, delay: delay ?? 0 });
114
+ return 999 as any;
115
+ }) as any;
116
+ });
117
+
118
+ afterEach(() => {
119
+ global.setTimeout = originalSetTimeout;
120
+ delete (globalThis as any).__mockWsInstances;
121
+ vi.restoreAllMocks();
122
+ });
123
+
124
+ // ─── Fix #1: Stale socket guards ──────────────────────────────────────
125
+
126
+ describe("#1 — stale socket guards", () => {
127
+ it("should ignore message events from a previous WebSocket instance", () => {
128
+ const onMessage = vi.fn();
129
+ const socket = createSocket({ onMessage });
130
+ socket.connect();
131
+
132
+ const oldWs = mockWsInstances[0];
133
+
134
+ // Simulate open + CONNACK on old WS so AES keys are set
135
+ oldWs.emit("open");
136
+ oldWs.emit("message", buildConnackPacket(1));
137
+
138
+ // Disconnect and reconnect — creates a new WS instance
139
+ socket.disconnect();
140
+ socket.connect();
141
+ const newWs = mockWsInstances[1];
142
+ expect(newWs).toBeDefined();
143
+
144
+ // Fire message on the OLD WebSocket — should be ignored due to stale guard
145
+ const callsBefore = onMessage.mock.calls.length;
146
+ oldWs.emit("message", buildConnackPacket(1));
147
+ expect(onMessage.mock.calls.length).toBe(callsBefore);
148
+ });
149
+
150
+ it("should ignore open events from a previous WebSocket instance", () => {
151
+ const socket = createSocket();
152
+ socket.connect();
153
+
154
+ const oldWs = mockWsInstances[0];
155
+
156
+ socket.disconnect();
157
+ socket.connect();
158
+ const newWs = mockWsInstances[1];
159
+
160
+ // Fire open on old WS — should not send CONNECT packet on old WS
161
+ const sendsBefore = oldWs.send.mock.calls.length;
162
+ oldWs.emit("open");
163
+ expect(oldWs.send.mock.calls.length).toBe(sendsBefore);
164
+ });
165
+
166
+ it("should ignore error events from a previous WebSocket instance", () => {
167
+ const socket = createSocket();
168
+ socket.connect();
169
+
170
+ const oldWs = mockWsInstances[0];
171
+
172
+ socket.disconnect();
173
+ socket.connect();
174
+
175
+ // Fire error on old WS — should not crash or affect new WS
176
+ expect(() => oldWs.emit("error", new Error("stale error"))).not.toThrow();
177
+ });
178
+ });
179
+
180
+ // ─── Fix #2: CONNACK=0 closes WS ─────────────────────────────────────
181
+
182
+ describe("#2 — CONNACK=0 closes WS", () => {
183
+ it("should close WebSocket when CONNACK reasonCode=0 (kicked)", () => {
184
+ const onError = vi.fn();
185
+ const socket = createSocket({ onError });
186
+ socket.connect();
187
+
188
+ const ws = mockWsInstances[0];
189
+ ws.emit("open");
190
+
191
+ // Spy on close
192
+ const closeSpy = vi.spyOn(ws, "close");
193
+
194
+ ws.emit("message", buildConnackPacket(0));
195
+
196
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({
197
+ message: "Kicked by server",
198
+ }));
199
+ expect(closeSpy).toHaveBeenCalled();
200
+ });
201
+ });
202
+
203
+ // ─── Fix #2: DISCONNECT packet closes WS ──────────────────────────────
204
+
205
+ describe("#2 — DISCONNECT packet closes WS", () => {
206
+ it("should close WebSocket on DISCONNECT packet", () => {
207
+ const onError = vi.fn();
208
+ const socket = createSocket({ onError });
209
+ socket.connect();
210
+
211
+ const ws = mockWsInstances[0];
212
+ ws.emit("open");
213
+
214
+ // First send successful CONNACK so state is connected
215
+ ws.emit("message", buildConnackPacket(1));
216
+
217
+ // Spy on close after CONNACK
218
+ const closeSpy = vi.spyOn(ws, "close");
219
+
220
+ // Now send DISCONNECT
221
+ ws.emit("message", buildDisconnectPacket());
222
+
223
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({
224
+ message: "Kicked by server",
225
+ }));
226
+ expect(closeSpy).toHaveBeenCalled();
227
+ });
228
+ });
229
+
230
+ // ─── Fix #4: disconnectAndWait() ──────────────────────────────────────
231
+
232
+ describe("#4 — disconnectAndWait()", () => {
233
+ it("should wait for WS close event before resolving", async () => {
234
+ // Use real setTimeout for async tests
235
+ global.setTimeout = originalSetTimeout;
236
+
237
+ const socket = createSocket();
238
+ socket.connect();
239
+
240
+ const ws = mockWsInstances[0];
241
+ expect(ws).toBeDefined();
242
+
243
+ // disconnectAndWait should resolve after close event fires
244
+ const promise = socket.disconnectAndWait();
245
+
246
+ // The mock WS close() queues a microtask to emit 'close'
247
+ await promise;
248
+ // If we get here, promise resolved successfully
249
+ expect(true).toBe(true);
250
+ });
251
+
252
+ it("should resolve on timeout if close event never fires", async () => {
253
+ global.setTimeout = originalSetTimeout;
254
+
255
+ const socket = createSocket();
256
+ socket.connect();
257
+
258
+ const ws = mockWsInstances[0];
259
+
260
+ // Override close to NOT emit close event
261
+ ws.close = vi.fn(); // no-op, no close event
262
+ ws.terminate = vi.fn(); // track terminate calls
263
+
264
+ const start = Date.now();
265
+ await socket.disconnectAndWait(100); // short timeout for test
266
+ const elapsed = Date.now() - start;
267
+
268
+ expect(elapsed).toBeGreaterThanOrEqual(90); // waited ~100ms
269
+ expect(ws.terminate).toHaveBeenCalled();
270
+ });
271
+
272
+ it("should resolve immediately if no WS exists", async () => {
273
+ global.setTimeout = originalSetTimeout;
274
+
275
+ const socket = createSocket();
276
+ // Don't connect — no WS exists
277
+
278
+ const start = Date.now();
279
+ await socket.disconnectAndWait();
280
+ const elapsed = Date.now() - start;
281
+
282
+ expect(elapsed).toBeLessThan(50); // resolved immediately
283
+ });
284
+ });
285
+
286
+ // ─── Fix #3: Heartbeat timer cleared on disconnect ────────────────────
287
+
288
+ describe("#3 — heartbeat timer cleared on disconnect", () => {
289
+ it("should clear heartbeat timer when onDisconnected fires", () => {
290
+ // This tests the channel-layer pattern: heartbeatTimer cleared in onDisconnected
291
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
292
+ let heartbeatCleared = false;
293
+
294
+ const originalClearInterval = global.clearInterval;
295
+ global.clearInterval = vi.fn((id) => {
296
+ if (id === heartbeatTimer) heartbeatCleared = true;
297
+ originalClearInterval(id);
298
+ }) as any;
299
+
300
+ try {
301
+ // Simulate startHeartbeat
302
+ heartbeatTimer = setInterval(() => {}, 60_000);
303
+
304
+ // Simulate onDisconnected callback
305
+ if (heartbeatTimer) {
306
+ clearInterval(heartbeatTimer);
307
+ heartbeatTimer = null;
308
+ }
309
+
310
+ expect(heartbeatCleared).toBe(true);
311
+ expect(heartbeatTimer).toBeNull();
312
+ } finally {
313
+ global.clearInterval = originalClearInterval;
314
+ }
315
+ });
316
+
317
+ it("should restart heartbeat timer on connect", () => {
318
+ // Simulate the onConnected/onDisconnected pattern
319
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
320
+
321
+ const startHeartbeat = () => {
322
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
323
+ heartbeatTimer = setInterval(() => {}, 60_000);
324
+ };
325
+
326
+ // onConnected
327
+ startHeartbeat();
328
+ expect(heartbeatTimer).not.toBeNull();
329
+
330
+ const firstTimer = heartbeatTimer;
331
+
332
+ // onDisconnected
333
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
334
+ expect(heartbeatTimer).toBeNull();
335
+
336
+ // onConnected again
337
+ startHeartbeat();
338
+ expect(heartbeatTimer).not.toBeNull();
339
+ expect(heartbeatTimer).not.toBe(firstTimer);
340
+
341
+ // Cleanup
342
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
343
+ });
344
+ });
345
+
346
+ // ─── Fix #5: Token refresh disconnects before API call ────────────────
347
+
348
+ describe("#5 — token refresh disconnects before API call", () => {
349
+ it("should call disconnectAndWait before registerBot in refresh path", async () => {
350
+ // Verify the ordering pattern: disconnect -> refresh -> connect
351
+ const callOrder: string[] = [];
352
+
353
+ const mockSocket = {
354
+ disconnectAndWait: async () => { callOrder.push("disconnectAndWait"); },
355
+ updateCredentials: () => { callOrder.push("updateCredentials"); },
356
+ connect: () => { callOrder.push("connect"); },
357
+ };
358
+
359
+ const mockRegisterBot = async () => {
360
+ callOrder.push("registerBot");
361
+ return { robot_id: "bot1", im_token: "tok1" };
362
+ };
363
+
364
+ // Simulate the token refresh path
365
+ await (async () => {
366
+ await mockSocket.disconnectAndWait();
367
+ const fresh = await mockRegisterBot();
368
+ mockSocket.updateCredentials();
369
+ mockSocket.connect();
370
+ })();
371
+
372
+ expect(callOrder).toEqual([
373
+ "disconnectAndWait",
374
+ "registerBot",
375
+ "updateCredentials",
376
+ "connect",
377
+ ]);
378
+ });
379
+ });
380
+
381
+ // ─── Fix #6: Heartbeat failure has backoff delay ──────────────────────
382
+
383
+ describe("#6 — heartbeat failure backoff delay", () => {
384
+ it("should delay reconnect after heartbeat failures", async () => {
385
+ global.setTimeout = originalSetTimeout;
386
+
387
+ // Simulate the heartbeat failure backoff pattern
388
+ const start = Date.now();
389
+ const backoffMs = 3000 + Math.floor(Math.random() * 2000);
390
+
391
+ expect(backoffMs).toBeGreaterThanOrEqual(3000);
392
+ expect(backoffMs).toBeLessThan(5000);
393
+
394
+ // Verify pattern is non-zero delay (not immediate reconnect)
395
+ expect(backoffMs).toBeGreaterThan(0);
396
+ });
397
+
398
+ it("should apply random jitter to avoid thundering herd", () => {
399
+ const delays = new Set<number>();
400
+ for (let i = 0; i < 100; i++) {
401
+ delays.add(3000 + Math.floor(Math.random() * 2000));
402
+ }
403
+ // Should produce multiple distinct values (not all the same)
404
+ expect(delays.size).toBeGreaterThan(1);
405
+ });
406
+ });
407
+
408
+ // ─── Fix #7: consecutiveHeartbeatFailures reset on connect ────────────
409
+
410
+ describe("#7 — consecutiveHeartbeatFailures reset on connect", () => {
411
+ it("should reset failure counter on successful connection", () => {
412
+ let consecutiveHeartbeatFailures = 0;
413
+
414
+ // Simulate failures building up
415
+ consecutiveHeartbeatFailures = 2;
416
+ expect(consecutiveHeartbeatFailures).toBe(2);
417
+
418
+ // Simulate onConnected callback
419
+ consecutiveHeartbeatFailures = 0;
420
+ expect(consecutiveHeartbeatFailures).toBe(0);
421
+
422
+ // Next failure should start from 0
423
+ consecutiveHeartbeatFailures++;
424
+ expect(consecutiveHeartbeatFailures).toBe(1);
425
+ });
426
+
427
+ it("should not trigger reconnect at 1 failure after reset", () => {
428
+ const MAX_HEARTBEAT_FAILURES = 3;
429
+ let consecutiveHeartbeatFailures = 2;
430
+
431
+ // Reset on connect
432
+ consecutiveHeartbeatFailures = 0;
433
+
434
+ // Single failure after reset
435
+ consecutiveHeartbeatFailures++;
436
+
437
+ // Should NOT trigger reconnect
438
+ expect(consecutiveHeartbeatFailures >= MAX_HEARTBEAT_FAILURES).toBe(false);
439
+ });
440
+ });
441
+
442
+ // ─── Fix #8: Ping timeout path (covered by #3) ───────────────────────
443
+
444
+ describe("#8 — ping timeout calls onDisconnected", () => {
445
+ it("should fire onDisconnected on ping timeout so channel clears heartbeat", () => {
446
+ // Verify that the WKSocket ping timeout path calls onDisconnected
447
+ const onDisconnected = vi.fn();
448
+ const socket = createSocket({ onDisconnected });
449
+ socket.connect();
450
+
451
+ const ws = mockWsInstances[0];
452
+ ws.emit("open");
453
+ ws.emit("message", buildConnackPacket(1));
454
+
455
+ expect(onDisconnected).not.toHaveBeenCalled();
456
+
457
+ // Now simulate ping timeout: the socket's internal restartHeart timer
458
+ // fires >3 times → closes WS → calls onDisconnected
459
+ // We can verify by triggering close event which is what happens
460
+ ws.emit("close");
461
+
462
+ expect(onDisconnected).toHaveBeenCalled();
463
+ });
464
+ });
465
+
466
+ // ─── Fix #9: No dual reconnect ───────────────────────────────────────
467
+
468
+ describe("#9 — no dual reconnect", () => {
469
+ it("should expose stopReconnectTimer as public method", () => {
470
+ const socket = createSocket();
471
+ expect(typeof socket.stopReconnectTimer).toBe("function");
472
+ });
473
+
474
+ it("should cancel socket-level reconnect timer", () => {
475
+ const socket = createSocket();
476
+ socket.connect();
477
+
478
+ const ws = mockWsInstances[0];
479
+
480
+ // Trigger close to schedule a reconnect
481
+ setTimeoutCalls = [];
482
+ ws.emit("close");
483
+ expect(setTimeoutCalls.length).toBe(1); // reconnect scheduled
484
+
485
+ // Now cancel from channel layer
486
+ socket.stopReconnectTimer();
487
+
488
+ // Execute the scheduled callback — it should be a no-op because
489
+ // the timer was cleared (though in our mock, clearTimeout doesn't
490
+ // actually prevent execution, we verify the method exists and works)
491
+ expect(typeof socket.stopReconnectTimer).toBe("function");
492
+ });
493
+
494
+ it("cooldown path should cancel socket reconnect before calling connect", async () => {
495
+ // Verify the pattern: stopReconnectTimer() called before connect()
496
+ const callOrder: string[] = [];
497
+ const mockSocket = {
498
+ disconnectAndWait: async () => { callOrder.push("disconnectAndWait"); },
499
+ stopReconnectTimer: () => { callOrder.push("stopReconnectTimer"); },
500
+ connect: () => { callOrder.push("connect"); },
501
+ };
502
+
503
+ // Simulate cooldown reconnect path
504
+ await (async () => {
505
+ await mockSocket.disconnectAndWait();
506
+ mockSocket.stopReconnectTimer();
507
+ mockSocket.connect();
508
+ })();
509
+
510
+ expect(callOrder).toEqual([
511
+ "disconnectAndWait",
512
+ "stopReconnectTimer",
513
+ "connect",
514
+ ]);
515
+ });
516
+ });
517
+
518
+ // ─── disconnectAndWait clears state ───────────────────────────────────
519
+
520
+ describe("disconnectAndWait clears reconnect state", () => {
521
+ it("should clear lastConnectTime and rapidDisconnectCount", async () => {
522
+ global.setTimeout = originalSetTimeout;
523
+
524
+ const socket = createSocket();
525
+ socket.connect();
526
+
527
+ const ws = mockWsInstances[0];
528
+ ws.emit("open");
529
+ ws.emit("message", buildConnackPacket(1));
530
+
531
+ // After successful connect, lastConnectTime is set
532
+ // disconnectAndWait should clear it
533
+ await socket.disconnectAndWait();
534
+
535
+ // Reconnect and verify no rapid disconnect tracking from before
536
+ socket.connect();
537
+ const ws2 = mockWsInstances[1];
538
+ expect(ws2).toBeDefined();
539
+ });
540
+ });
541
+ });
@@ -161,20 +161,19 @@ describe("WKSocket reconnection", () => {
161
161
  });
162
162
 
163
163
  describe("reconnectAttempts reset on successful CONNACK", () => {
164
- it("should reset reconnect attempts counter after successful connection", () => {
164
+ it("should reset reconnect attempts only after 30s stable timer fires", () => {
165
165
  const onConnected = vi.fn();
166
166
  const socket = createSocket({ onConnected });
167
167
  socket.connect();
168
168
 
169
169
  const ws = mockWsInstances[0];
170
170
 
171
- // Build up reconnect attempts
171
+ // Build up reconnect attempts (5 close events → reconnectAttempts = 5)
172
172
  for (let i = 0; i < 5; i++) {
173
173
  ws.emit("close");
174
174
  }
175
175
 
176
176
  // Build a minimal CONNACK packet:
177
- // Header byte: (CONNACK=2 << 4) | 1 (hasServerVersion flag)
178
177
  const serverVersion = 4;
179
178
  const reasonCode = 1; // success
180
179
  const serverKey = Buffer.from(new Uint8Array(32)).toString("base64");
@@ -182,19 +181,15 @@ describe("WKSocket reconnection", () => {
182
181
 
183
182
  const body: number[] = [];
184
183
  body.push(serverVersion);
185
- // timeDiff (8 bytes)
186
- for (let i = 0; i < 8; i++) body.push(0);
184
+ for (let i = 0; i < 8; i++) body.push(0); // timeDiff
187
185
  body.push(reasonCode);
188
- // serverKey as string (2-byte length prefix + data)
189
186
  const keyBytes = [...Buffer.from(serverKey)];
190
187
  body.push((keyBytes.length >> 8) & 0xff, keyBytes.length & 0xff);
191
188
  body.push(...keyBytes);
192
- // salt as string
193
189
  const saltBytes = [...Buffer.from(salt)];
194
190
  body.push((saltBytes.length >> 8) & 0xff, saltBytes.length & 0xff);
195
191
  body.push(...saltBytes);
196
- // nodeId (8 bytes for version >= 4)
197
- for (let i = 0; i < 8; i++) body.push(0);
192
+ for (let i = 0; i < 8; i++) body.push(0); // nodeId
198
193
 
199
194
  const header = (2 << 4) | 1; // CONNACK with hasServerVersion
200
195
  const packet = new Uint8Array([header, body.length, ...body]);
@@ -202,12 +197,17 @@ describe("WKSocket reconnection", () => {
202
197
  // Trigger the open event first so DH keypair is generated
203
198
  ws.emit("open");
204
199
 
205
- // Now send CONNACK
200
+ // Send CONNACK — starts 30s stable timer but does NOT reset reconnectAttempts
206
201
  ws.emit("message", packet.buffer);
207
-
208
202
  expect(onConnected).toHaveBeenCalled();
209
203
 
210
- // After successful CONNACK, next close+reconnect should use base delay
204
+ // Find the 30s stable timer callback and execute it
205
+ // (simulates 30s of stable connection)
206
+ const stableTimerCall = setTimeoutCalls.find(c => c.delay === 30_000);
207
+ expect(stableTimerCall).toBeDefined();
208
+ stableTimerCall!.fn();
209
+
210
+ // NOW after stable timer fires, next close+reconnect should use base delay
211
211
  setTimeoutCalls = [];
212
212
  ws.emit("close");
213
213
 
package/src/socket.ts CHANGED
@@ -261,6 +261,9 @@ export class WKSocket extends EventEmitter {
261
261
  private pingRetryCount = 0;
262
262
  private readonly pingMaxRetry = 3;
263
263
  private reconnectAttempts = 0;
264
+ private stableTimer: ReturnType<typeof setTimeout> | null = null;
265
+ private lastConnectTime = 0;
266
+ private rapidDisconnectCount = 0;
264
267
 
265
268
  // Per-instance crypto state (set after CONNACK)
266
269
  private aesKey = "";
@@ -291,17 +294,54 @@ export class WKSocket extends EventEmitter {
291
294
  disconnect(): void {
292
295
  this.needReconnect = false;
293
296
  this.connected = false;
297
+ this.lastConnectTime = 0;
298
+ this.rapidDisconnectCount = 0;
294
299
  this.stopHeart();
295
300
  this.stopReconnectTimer();
301
+ this.clearStableTimer();
296
302
  if (this.ws) {
297
303
  try { this.ws.close(); } catch { /* ignore */ }
298
304
  this.ws = null;
299
305
  }
300
306
  }
301
307
 
308
+ /** Disconnect and wait for the old WS to fully close before resolving. */
309
+ async disconnectAndWait(timeoutMs = 2000): Promise<void> {
310
+ this.needReconnect = false;
311
+ this.connected = false;
312
+ this.stopHeart();
313
+ this.stopReconnectTimer();
314
+ this.clearStableTimer();
315
+
316
+ const oldWs = this.ws;
317
+ this.ws = null;
318
+ this.lastConnectTime = 0;
319
+ this.rapidDisconnectCount = 0;
320
+
321
+ if (!oldWs) return;
322
+
323
+ return new Promise<void>((resolve) => {
324
+ let resolved = false;
325
+ const done = () => {
326
+ if (resolved) return;
327
+ resolved = true;
328
+ resolve();
329
+ };
330
+ oldWs.on("close", done);
331
+ try { oldWs.close(); } catch { /* ignore */ }
332
+ setTimeout(() => {
333
+ if (!resolved) {
334
+ try { (oldWs as any).terminate?.(); } catch { /* ignore */ }
335
+ done();
336
+ }
337
+ }, timeoutMs);
338
+ });
339
+ }
340
+
302
341
  // ─── Internal Connection Logic ──────────────────────────────────────────
303
342
 
304
343
  private doConnect(): void {
344
+ this.clearStableTimer();
305
345
  if (this.ws) {
306
346
  try { this.ws.close(); } catch { /* ignore */ }
307
347
  this.ws = null;
@@ -313,6 +353,7 @@ export class WKSocket extends EventEmitter {
313
353
  this.ws = ws;
314
354
 
315
355
  ws.on("open", () => {
356
+ if (this.ws !== ws) return; // stale guard
316
357
  this.tempBuffer = [];
317
358
  // Generate DH key pair
318
359
  const seed = Uint8Array.from(stringToUint(generateDeviceID()));
@@ -334,6 +375,7 @@ export class WKSocket extends EventEmitter {
334
375
  });
335
376
 
336
377
  ws.on("message", (data: ArrayBuffer | Buffer) => {
378
+ if (this.ws !== ws) return; // stale guard
337
379
  const bytes = new Uint8Array(data instanceof ArrayBuffer ? data : data.buffer);
338
380
  this.handleRawData(bytes);
339
381
  });
@@ -349,12 +391,34 @@ export class WKSocket extends EventEmitter {
349
391
  this.opts.onDisconnected?.();
350
392
  }
351
393
  this.stopHeart();
394
+ this.clearStableTimer();
395
+
396
+ // Track rapid disconnects: if connection lasted <5s, it's unstable
397
+ if (this.lastConnectTime > 0) {
398
+ const duration = Date.now() - this.lastConnectTime;
399
+ if (duration < 5000) {
400
+ this.rapidDisconnectCount++;
401
+ } else {
402
+ this.rapidDisconnectCount = 0;
403
+ }
404
+ this.lastConnectTime = 0;
405
+ }
406
+
407
+ // If 3+ consecutive rapid disconnects, trigger onError for token refresh
408
+ if (this.rapidDisconnectCount >= 3) {
409
+ this.needReconnect = false;
410
+ this.rapidDisconnectCount = 0;
411
+ this.opts.onError?.(new Error("Connect failed: rapid disconnect detected"));
412
+ return;
413
+ }
414
+
352
415
  if (this.needReconnect) {
353
416
  this.scheduleReconnect();
354
417
  }
355
418
  });
356
419
 
357
420
  ws.on("error", (err) => {
421
+ if (this.ws !== ws) return; // stale guard
358
422
  console.debug("[WKSocket] ws error:", err.message);
359
423
  // The 'close' event will follow, which handles reconnect
360
424
  });
@@ -376,13 +440,30 @@ export class WKSocket extends EventEmitter {
376
440
  }, delay);
377
441
  }
378
442
 
379
- private stopReconnectTimer(): void {
443
+ stopReconnectTimer(): void {
380
444
  if (this.reconnectTimer) {
381
445
  clearTimeout(this.reconnectTimer);
382
446
  this.reconnectTimer = null;
383
447
  }
384
448
  }
385
449
 
450
+ private startStableTimer(): void {
451
+ this.clearStableTimer();
452
+ this.stableTimer = setTimeout(() => {
453
+ if (this.connected) {
454
+ this.reconnectAttempts = 0;
455
+ this.rapidDisconnectCount = 0;
456
+ }
457
+ }, 30_000);
458
+ }
459
+
460
+ private clearStableTimer(): void {
461
+ if (this.stableTimer) {
462
+ clearTimeout(this.stableTimer);
463
+ this.stableTimer = null;
464
+ }
465
+ }
466
+
386
467
  // ─── Heartbeat ──────────────────────────────────────────────────────────
387
468
 
388
469
  private restartHeart(): void {
@@ -393,6 +474,7 @@ export class WKSocket extends EventEmitter {
393
474
  if (this.pingRetryCount > this.pingMaxRetry) {
394
475
  console.debug("[WKSocket] ping timeout, reconnecting...");
395
476
  this.stopHeart();
477
+ this.clearStableTimer();
396
478
  if (this.ws) {
397
479
  try { this.ws.close(); } catch { /* ignore */ }
398
480
  this.ws = null;
@@ -552,13 +634,15 @@ export class WKSocket extends EventEmitter {
552
634
  this.aesIV = salt && salt.length > 16 ? salt.substring(0, 16) : salt;
553
635
 
554
636
  this.connected = true;
555
- this.reconnectAttempts = 0;
637
+ this.lastConnectTime = Date.now();
556
638
  this.restartHeart();
639
+ this.startStableTimer();
557
640
  this.opts.onConnected?.();
558
641
  } else if (reasonCode === 0) {
559
642
  // Kicked
560
643
  this.connected = false;
561
644
  this.needReconnect = false;
645
+ if (this.ws) { try { this.ws.close(); } catch {} this.ws = null; }
562
646
  this.opts.onError?.(new Error("Kicked by server"));
563
647
  this.opts.onDisconnected?.();
564
648
  } else {
@@ -629,6 +713,8 @@ export class WKSocket extends EventEmitter {
629
713
  this.connected = false;
630
714
  this.needReconnect = false;
631
715
  this.stopHeart();
716
+ this.clearStableTimer();
717
+ if (this.ws) { try { this.ws.close(); } catch {} this.ws = null; }
632
718
  this.opts.onError?.(new Error("Kicked by server"));
633
719
  this.opts.onDisconnected?.();
634
720
  }