Skip to content

Commit dd2083c

Browse files
mcaxtrMMMMSSSS8899
andauthored
fix(whastapp): bound connection startup waits (#90486)
* fix: add timeout to waitForWaConnection to prevent indefinite hangs If Baileys fails to emit a 'connection.update' event with either 'open' or 'close' status (e.g. due to network issues or internal errors), the waitForWaConnection promise hangs forever, blocking the entire monitor loop. Add a configurable timeout (default 60s) that rejects the promise and cleans up the event listener if no connection state is received in time. The timeout is backward-compatible as an optional parameter with a sensible default. * test: add coverage for waitForWaConnection timeout path - Test that promise rejects with descriptive error after timeout - Test that event listener is cleaned up after timeout - Test that timer is cleared when connection opens before timeout * fix: default timeoutMs to 0 to preserve QR login behavior The 60s default broke the QR login flow in login-qr.ts, which calls waitForWaConnection without a timeout and expects to wait up to 3 minutes while the user scans. Change the default to 0 (wait forever, matching original behavior) and pass the 60s timeout explicitly at the monitor callsite where it's actually needed. * fix: bound whatsapp connection startup waits * fix: align web channel wait contract * fix: retry whatsapp setup timeouts * fix: satisfy whatsapp status lint * fix: preserve whatsapp wait compatibility --------- Co-authored-by: MMMMSSSS8899 <[email protected]>
1 parent 29f5e9d commit dd2083c

12 files changed

Lines changed: 275 additions & 77 deletions

extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
setRuntimeConfigSourceSnapshotMock,
2727
startWebAutoReplyMonitor,
2828
} from "./auto-reply.test-harness.js";
29+
import { waitForWaConnection } from "./session.js";
2930

3031
type DrainSelectionEntry = {
3132
channel: string;
@@ -225,6 +226,33 @@ describe("web auto-reply connection", () => {
225226
expectErrorContaining(runtime.error, "2/2 attempts");
226227
});
227228

229+
it("retries opening-phase connection wait timeouts through the reconnect policy", async () => {
230+
vi.mocked(waitForWaConnection).mockRejectedValueOnce({ output: { statusCode: 408 } });
231+
const listenerFactory = vi.fn(async () => createMockWebListener());
232+
const sleep = vi.fn(async () => {});
233+
const { runtime, controller, run } = startWebAutoReplyMonitor({
234+
monitorWebChannelFn: monitorWebChannel as never,
235+
listenerFactory,
236+
sleep,
237+
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 2, factor: 1.1 },
238+
});
239+
240+
await vi.waitFor(
241+
() => {
242+
expect(listenerFactory).toHaveBeenCalledTimes(1);
243+
},
244+
{ timeout: 250, interval: 2 },
245+
);
246+
controller.abort();
247+
await run;
248+
249+
expect(waitForWaConnection).toHaveBeenCalledTimes(2);
250+
expect(listenerFactory).toHaveBeenCalledTimes(1);
251+
expect(sleep).toHaveBeenCalled();
252+
expectErrorContaining(runtime.error, "status 408");
253+
expectErrorContaining(runtime.error, "Retry 1/2");
254+
});
255+
228256
it("keeps post-open Baileys 428 on the reconnect path", async () => {
229257
const sleep = vi.fn(async () => {});
230258
const scripted = createScriptedWebListenerFactory();

extensions/whatsapp/src/auto-reply/monitor.ts

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,7 @@ import {
3232
resolveReconnectPolicy,
3333
sleepWithAbort,
3434
} from "../reconnect.js";
35-
import {
36-
formatError,
37-
getStatusCode,
38-
getWebAuthAgeMs,
39-
logoutWeb,
40-
readWebSelfId,
41-
} from "../session.js";
35+
import { formatError, getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../session.js";
4236
import { resolveWhatsAppSocketTiming } from "../socket-timing.js";
4337
import { getRuntimeConfig, getRuntimeConfigSourceSnapshot } from "./config.runtime.js";
4438
import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js";
@@ -407,45 +401,73 @@ export async function monitorWebChannel(
407401
},
408402
});
409403
} catch (error) {
410-
if (getStatusCode(error) === 428) {
411-
const retryDecision = controller.consumeReconnectAttempt();
412-
statusController.noteReconnectAttempts(retryDecision.reconnectAttempts);
404+
const setupDecision = controller.resolveSetupErrorDecision(error);
405+
if (setupDecision === "aborted") {
406+
await controller.shutdown();
407+
break;
408+
}
409+
if (setupDecision) {
410+
statusController.noteReconnectAttempts(setupDecision.reconnectAttempts);
413411
statusController.noteClose({
414-
statusCode: 428,
412+
statusCode: setupDecision.normalized.statusCode,
415413
error: formatError(error),
416-
reconnectAttempts: retryDecision.reconnectAttempts,
417-
healthState: retryDecision.healthState,
414+
reconnectAttempts: setupDecision.reconnectAttempts,
415+
healthState: setupDecision.healthState,
418416
});
419-
if (retryDecision.action === "stop") {
417+
if (setupDecision.action === "stop") {
420418
reconnectLogger.warn(
421419
{
422420
connectionId,
423-
status: 428,
424-
reconnectAttempts: retryDecision.reconnectAttempts,
421+
status: setupDecision.normalized.statusLabel,
422+
reconnectAttempts: setupDecision.reconnectAttempts,
425423
maxAttempts: reconnectPolicy.maxAttempts,
426424
},
427-
"web reconnect: 428 during opening; max attempts reached",
428-
);
429-
runtime.error(
430-
`WhatsApp Web connection closed during setup (status 428) after ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts} attempts. Relink with \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` if the issue persists.`,
425+
"web reconnect: setup status error; max attempts reached",
431426
);
427+
if (setupDecision.healthState === "logged-out") {
428+
await clearTerminalWebAuthState({
429+
account,
430+
runtime,
431+
statusLabel: setupDecision.normalized.statusLabel,
432+
healthState: setupDecision.healthState,
433+
log: reconnectLogger,
434+
});
435+
runtime.error(
436+
`WhatsApp session logged out during setup. Run \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` to relink.`,
437+
);
438+
} else if (setupDecision.healthState === "conflict") {
439+
await clearTerminalWebAuthState({
440+
account,
441+
runtime,
442+
statusLabel: setupDecision.normalized.statusLabel,
443+
healthState: setupDecision.healthState,
444+
log: reconnectLogger,
445+
});
446+
runtime.error(
447+
`WhatsApp Web connection closed during setup (status ${setupDecision.normalized.statusLabel}: session conflict). Resolve conflicting WhatsApp Web sessions, then relink with \`${formatCliCommand("openclaw channels login --channel whatsapp")}\`. Stopping web monitoring.`,
448+
);
449+
} else {
450+
runtime.error(
451+
`WhatsApp Web connection closed during setup (status ${setupDecision.normalized.statusLabel}) after ${setupDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts} attempts. Relink with \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` if the issue persists.`,
452+
);
453+
}
432454
await controller.shutdown();
433455
break;
434456
}
435457
reconnectLogger.info(
436458
{
437459
connectionId,
438-
status: 428,
439-
reconnectAttempts: retryDecision.reconnectAttempts,
440-
delayMs: retryDecision.delayMs,
460+
status: setupDecision.normalized.statusLabel,
461+
reconnectAttempts: setupDecision.reconnectAttempts,
462+
delayMs: setupDecision.delayMs,
441463
},
442-
"web reconnect: 428 during opening; retrying",
464+
"web reconnect: setup status error; retrying",
443465
);
444466
runtime.error(
445-
`WhatsApp Web connection closed during setup (status 428). Retry ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(retryDecision.delayMs ?? 0)}.`,
467+
`WhatsApp Web connection closed during setup (status ${setupDecision.normalized.statusLabel}). Retry ${setupDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(setupDecision.delayMs ?? 0)}.`,
446468
);
447469
try {
448-
await controller.waitBeforeRetry(retryDecision.delayMs ?? 0);
470+
await controller.waitBeforeRetry(setupDecision.delayMs ?? 0);
449471
} catch {
450472
break;
451473
}

extensions/whatsapp/src/connection-controller.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "./connection-controller.js";
1010
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
1111
import { createWaSocket, waitForWaConnection } from "./session.js";
12+
import { DEFAULT_WHATSAPP_SOCKET_TIMING } from "./socket-timing.js";
1213

1314
vi.mock("./session.js", async () => {
1415
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
@@ -130,6 +131,9 @@ describe("WhatsAppConnectionController", () => {
130131
});
131132

132133
expect(callOrder).toEqual(["create", "wait-for-connection"]);
134+
expect(waitForWaConnectionMock).toHaveBeenCalledWith(expect.anything(), {
135+
timeoutMs: DEFAULT_WHATSAPP_SOCKET_TIMING.connectTimeoutMs,
136+
});
133137
});
134138

135139
it("restarts login once on status 408 and preserves replacement socket options", async () => {
@@ -180,6 +184,8 @@ describe("WhatsAppConnectionController", () => {
180184
});
181185
expect(onQr).toHaveBeenCalledWith("qr-after-timeout");
182186
expect(onSocketReplaced).toHaveBeenCalledWith(replacementSock);
187+
expect(waitForConnection).toHaveBeenNthCalledWith(1, initialSock, { timeout: "none" });
188+
expect(waitForConnection).toHaveBeenNthCalledWith(2, replacementSock, { timeout: "none" });
183189
});
184190

185191
it("still honors the post-pairing 515 restart after a status 408 recovery", async () => {
@@ -213,6 +219,11 @@ describe("WhatsAppConnectionController", () => {
213219
});
214220
expect(createSocket).toHaveBeenCalledTimes(2);
215221
expect(waitForConnection).toHaveBeenCalledTimes(3);
222+
expect(waitForConnection).toHaveBeenNthCalledWith(1, initialSock, { timeout: "none" });
223+
expect(waitForConnection).toHaveBeenNthCalledWith(2, afterTimeoutSock, { timeout: "none" });
224+
expect(waitForConnection).toHaveBeenNthCalledWith(3, afterPairingRestartSock, {
225+
timeout: "none",
226+
});
216227
expect(initialSock.end).toHaveBeenCalledOnce();
217228
expect(afterTimeoutSock.end).toHaveBeenCalledOnce();
218229
});

extensions/whatsapp/src/connection-controller.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DisconnectReason, type WASocket } from "baileys";
1+
import type { WASocket } from "baileys";
22
import { info } from "openclaw/plugin-sdk/runtime-env";
33
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
44
import {
@@ -14,11 +14,14 @@ import {
1414
logoutWeb,
1515
waitForWaConnection,
1616
} from "./session.js";
17-
import type { WhatsAppSocketTimingOptions } from "./socket-timing.js";
17+
import {
18+
DEFAULT_WHATSAPP_SOCKET_TIMING,
19+
type WhatsAppSocketTimingOptions,
20+
} from "./socket-timing.js";
1821

19-
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
22+
const LOGGED_OUT_STATUS = 401;
2023
const POST_PAIRING_RESTART_STATUS = 515;
21-
const TIMED_OUT_STATUS = DisconnectReason?.timedOut ?? 408;
24+
const TIMED_OUT_STATUS = 408;
2225
const WHATSAPP_LOGIN_RESTART_MESSAGE =
2326
"WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…";
2427
const WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE =
@@ -228,7 +231,7 @@ export async function waitForWhatsAppLoginResult(params: {
228231

229232
while (true) {
230233
try {
231-
await wait(currentSock);
234+
await wait(currentSock, { timeout: "none" });
232235
return {
233236
outcome: "connected",
234237
restarted: postPairingRestarted || timeoutRestarted,
@@ -306,7 +309,7 @@ export class WhatsAppConnectionController {
306309
private readonly abortSignal?: AbortSignal;
307310
private readonly sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
308311
private readonly isNonRetryableStatus: (statusCode: unknown) => boolean;
309-
private readonly socketTiming: WhatsAppSocketTimingOptions;
312+
private readonly socketTiming: Required<WhatsAppSocketTimingOptions>;
310313
private readonly abortPromise?: Promise<"aborted">;
311314
private readonly disconnectRetryController = new AbortController();
312315

@@ -342,7 +345,10 @@ export class WhatsAppConnectionController {
342345
this.abortSignal = params.abortSignal;
343346
this.sleep = params.sleep ?? ((ms: number, signal?: AbortSignal) => sleepWithAbort(ms, signal));
344347
this.isNonRetryableStatus = params.isNonRetryableStatus ?? (() => false);
345-
this.socketTiming = params.socketTiming ?? {};
348+
this.socketTiming = {
349+
...DEFAULT_WHATSAPP_SOCKET_TIMING,
350+
...params.socketTiming,
351+
};
346352
this.socketRef = { current: null };
347353
this.abortPromise =
348354
params.abortSignal &&
@@ -445,7 +451,7 @@ export class WhatsAppConnectionController {
445451
authDir: this.authDir,
446452
...this.socketTiming,
447453
});
448-
await waitForWaConnection(sock);
454+
await waitForWaConnection(sock, { timeoutMs: this.socketTiming.connectTimeoutMs });
449455

450456
this.socketRef.current = sock;
451457
const placeholderListener = {} as ManagedWhatsAppListener;
@@ -565,6 +571,19 @@ export class WhatsAppConnectionController {
565571
};
566572
}
567573

574+
resolveSetupErrorDecision(error: unknown): WhatsAppConnectionCloseDecision | "aborted" | null {
575+
const statusCode = getStatusCode(error);
576+
if (typeof statusCode !== "number") {
577+
return null;
578+
}
579+
580+
return this.resolveCloseDecision({
581+
status: statusCode,
582+
isLoggedOut: statusCode === LOGGED_OUT_STATUS,
583+
error,
584+
});
585+
}
586+
568587
consumeReconnectAttempt(): WhatsAppReconnectAttemptDecision {
569588
this.reconnectAttempts += 1;
570589
if (

extensions/whatsapp/src/inbound.media.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ vi.mock("./session.js", async () => {
221221
let monitorWebInbox: typeof import("./inbound.js").monitorWebInbox;
222222
let resetWebInboundDedupe: typeof import("./inbound.js").resetWebInboundDedupe;
223223
let createWaSocket: typeof import("./session.js").createWaSocket;
224+
let waitForWaConnection: typeof import("./session.js").waitForWaConnection;
224225

225226
async function waitForMessage(onMessage: ReturnType<typeof vi.fn>) {
226227
await vi.waitFor(() => expect(onMessage).toHaveBeenCalledTimes(1), {
@@ -262,7 +263,7 @@ describe("web inbound media saves with extension", () => {
262263
beforeAll(async () => {
263264
await fs.rm(HOME, { recursive: true, force: true });
264265
({ monitorWebInbox, resetWebInboundDedupe } = await import("./inbound.js"));
265-
({ createWaSocket } = await import("./session.js"));
266+
({ createWaSocket, waitForWaConnection } = await import("./session.js"));
266267
});
267268

268269
afterAll(async () => {
@@ -274,6 +275,30 @@ describe("web inbound media saves with extension", () => {
274275
}
275276
});
276277

278+
it("closes the socket when connection wait fails before inbox attach", async () => {
279+
const error = new Error("connection timeout");
280+
vi.mocked(waitForWaConnection).mockRejectedValueOnce(error);
281+
282+
await expect(
283+
monitorWebInbox({
284+
cfg: {
285+
channels: { whatsapp: { allowFrom: ["*"] } },
286+
messages: { messagePrefix: undefined, responsePrefix: undefined },
287+
web: { whatsapp: { connectTimeoutMs: 12_345 } },
288+
} as never,
289+
verbose: false,
290+
onMessage: vi.fn(),
291+
accountId: "default",
292+
authDir: path.join(HOME, "wa-auth"),
293+
}),
294+
).rejects.toThrow("connection timeout");
295+
296+
expect(vi.mocked(waitForWaConnection)).toHaveBeenCalledWith(currentMockSocket, {
297+
timeoutMs: 12_345,
298+
});
299+
expect(currentMockSocket?.ws.close).toHaveBeenCalledOnce();
300+
});
301+
277302
it("stores image extension and keeps document filename", async () => {
278303
const onMessage = vi.fn();
279304
const listener = await monitorWebInbox({

extensions/whatsapp/src/inbound/monitor.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,11 +1294,17 @@ export async function attachWebInboxToSocket(
12941294
}
12951295

12961296
export async function monitorWebInbox(options: MonitorWebInboxOptions) {
1297+
const socketTiming = resolveWhatsAppSocketTiming(options.cfg);
12971298
const sock = await createWaSocket(false, options.verbose, {
12981299
authDir: options.authDir,
1299-
...resolveWhatsAppSocketTiming(options.cfg),
1300+
...socketTiming,
13001301
});
1301-
await waitForWaConnection(sock);
1302+
try {
1303+
await waitForWaConnection(sock, { timeoutMs: socketTiming.connectTimeoutMs });
1304+
} catch (err) {
1305+
closeInboundMonitorSocket(sock);
1306+
throw err;
1307+
}
13021308
return attachWebInboxToSocket({
13031309
...options,
13041310
sock,

extensions/whatsapp/src/qa-driver.runtime.test.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,7 @@ describe("startWhatsAppQaDriverSession", () => {
8787
await session.close();
8888
});
8989

90-
it("clears the connection timeout after a successful connection", async () => {
91-
vi.useFakeTimers();
90+
it("passes the connection timeout to the shared connection waiter", async () => {
9291
const sock = createMockSocket();
9392
mocks.createWaSocket.mockResolvedValue(sock);
9493
mocks.waitForWaConnection.mockResolvedValue(undefined);
@@ -98,30 +97,26 @@ describe("startWhatsAppQaDriverSession", () => {
9897
connectionTimeoutMs: 45_000,
9998
});
10099

101-
expect(vi.getTimerCount()).toBe(0);
100+
expect(mocks.waitForWaConnection).toHaveBeenCalledWith(sock, { timeoutMs: 45_000 });
102101

103102
await session.close();
104103
});
105104

106105
it("closes the socket and removes listeners when connection setup times out", async () => {
107-
vi.useFakeTimers();
108106
const sock = createMockSocket();
107+
const timeoutError = new Error("timed out waiting for WhatsApp QA driver session");
109108
mocks.createWaSocket.mockResolvedValue(sock);
110-
mocks.waitForWaConnection.mockReturnValue(new Promise(() => {}));
109+
mocks.waitForWaConnection.mockRejectedValue(timeoutError);
111110

112-
const started = startWhatsAppQaDriverSession({
113-
authDir: "/tmp/openclaw-whatsapp-auth",
114-
connectionTimeoutMs: 10,
115-
});
116-
const rejection = started.catch((error: unknown) => error);
117-
118-
await vi.advanceTimersByTimeAsync(10);
111+
await expect(
112+
startWhatsAppQaDriverSession({
113+
authDir: "/tmp/openclaw-whatsapp-auth",
114+
connectionTimeoutMs: 10,
115+
}),
116+
).rejects.toThrow("timed out waiting for WhatsApp QA driver session");
119117

120-
const error = await rejection;
121-
expect(error).toBeInstanceOf(Error);
122-
expect((error as Error).message).toContain("timed out waiting for WhatsApp QA driver session");
118+
expect(mocks.waitForWaConnection).toHaveBeenCalledWith(sock, { timeoutMs: 10 });
123119
expect(sock.ev.listenerCount("messages.upsert")).toBe(0);
124120
expect(sock.end).toHaveBeenCalledOnce();
125-
expect(vi.getTimerCount()).toBe(0);
126121
});
127122
});

0 commit comments

Comments
 (0)