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 +1 -1
- package/src/bug-repro.test.ts +390 -0
- package/src/channel.ts +43 -13
- package/src/reconnect-fixes.test.ts +541 -0
- package/src/socket.test.ts +12 -12
- package/src/socket.ts +88 -2
package/package.json
CHANGED
|
@@ -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?.(
|
|
647
|
+
log?.warn?.(`dmwork: [${account.accountId}] too many heartbeat failures, triggering reconnect...`);
|
|
648
648
|
consecutiveHeartbeatFailures = 0;
|
|
649
|
-
|
|
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.
|
|
678
|
+
// 5b. Cooldown reconnect timer — deduplicate 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?.(
|
|
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?.(
|
|
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?.(
|
|
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
|
+
});
|
package/src/socket.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
}
|