Skip to content

Commit e672b61

Browse files
authored
fix(whatsapp): stop reconnecting quiet sockets
Fixes #70678.\n\nKeeps quiet but healthy WhatsApp linked-device sessions connected by tracking WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Also cleans up transport activity listeners on failed connection-open paths.\n\nCarries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.\n\nValidation:\n- pnpm test:serial extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts extensions/whatsapp/src/connection-controller.test.ts\n- pnpm check:changed\n- codex review --base origin/main
1 parent 4a3030d commit e672b61

6 files changed

Lines changed: 200 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
1616
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @codex.
1717
- Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00.
1818
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans. Thanks @codex.
19+
- WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.
1920

2021
## 2026.4.26
2122

docs/channels/whatsapp.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch
146146
## Runtime model
147147

148148
- Gateway owns the WhatsApp socket and reconnect loop.
149+
- The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window.
149150
- Outbound sends require an active WhatsApp listener for the target account.
150151
- Status and broadcast chats are ignored (`@status`, `@broadcast`).
151152
- Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session).
@@ -510,6 +511,10 @@ Behavior notes:
510511
<Accordion title="Linked but disconnected / reconnect loop">
511512
Symptom: linked account with repeated disconnects or reconnect attempts.
512513

514+
Quiet accounts can stay connected past the normal message timeout; the watchdog
515+
restarts when WhatsApp Web transport activity stops, the socket closes, or
516+
application-level activity stays silent beyond the longer safety window.
517+
513518
Fix:
514519

515520
```bash

extensions/whatsapp/src/auto-reply.test-harness.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import "./test-helpers.js";
2+
import { EventEmitter } from "node:events";
23
import fs from "node:fs/promises";
34
import os from "node:os";
45
import path from "node:path";
@@ -42,25 +43,57 @@ type WebAutoReplyMonitorHarness = {
4243
controller: AbortController;
4344
run: Promise<unknown>;
4445
};
46+
type MockSessionSocket = {
47+
ev: { on: ReturnType<typeof vi.fn>; off: ReturnType<typeof vi.fn> };
48+
ws: EventEmitter & { close: ReturnType<typeof vi.fn> };
49+
user: { id: string };
50+
};
4551

4652
export const TEST_NET_IP = "93.184.216.34";
53+
const WEB_AUTO_REPLY_SOCKETS_KEY = Symbol.for("openclaw:webAutoReplySessionSockets");
54+
55+
function getSessionSockets(): MockSessionSocket[] {
56+
const store = globalThis as Record<PropertyKey, unknown>;
57+
if (!Array.isArray(store[WEB_AUTO_REPLY_SOCKETS_KEY])) {
58+
store[WEB_AUTO_REPLY_SOCKETS_KEY] = [];
59+
}
60+
return store[WEB_AUTO_REPLY_SOCKETS_KEY] as MockSessionSocket[];
61+
}
4762

4863
vi.mock("./session.js", async () => {
4964
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
5065
return {
5166
...actual,
52-
createWaSocket: vi.fn(async () => ({
53-
ev: {
54-
on: vi.fn(),
55-
off: vi.fn(),
56-
},
57-
ws: { close: vi.fn() },
58-
user: { id: "123@s.whatsapp.net" },
59-
})),
67+
createWaSocket: vi.fn(async () => {
68+
const ws = new EventEmitter() as MockSessionSocket["ws"];
69+
ws.close = vi.fn();
70+
const sock: MockSessionSocket = {
71+
ev: {
72+
on: vi.fn(),
73+
off: vi.fn(),
74+
},
75+
ws,
76+
user: { id: "123@s.whatsapp.net" },
77+
};
78+
getSessionSockets().push(sock);
79+
return sock;
80+
}),
6081
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
6182
};
6283
});
6384

85+
export function getLastWebAutoReplySessionSocket(): MockSessionSocket {
86+
const last = getSessionSockets().at(-1);
87+
if (!last) {
88+
throw new Error("No WhatsApp Web auto-reply test socket created");
89+
}
90+
return last;
91+
}
92+
93+
export function resetWebAutoReplySessionSockets() {
94+
getSessionSockets().length = 0;
95+
}
96+
6497
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
6598
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
6699
appendCronStyleCurrentTimeLine: (text: string) => text,
@@ -166,6 +199,7 @@ export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) {
166199

167200
beforeEach(async () => {
168201
vi.clearAllMocks();
202+
resetWebAutoReplySessionSockets();
169203
_resetBaileysMocks();
170204
_resetLoadConfigMock();
171205
if (opts?.pinDns) {

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
createMockWebListener,
1313
createScriptedWebListenerFactory,
1414
createWebListenerFactoryCapture,
15+
getLastWebAutoReplySessionSocket,
1516
installWebAutoReplyTestHomeHooks,
1617
installWebAutoReplyUnitTestHooks,
1718
makeSessionStore,
@@ -255,6 +256,92 @@ describe("web auto-reply connection", () => {
255256
}
256257
});
257258

259+
it("keeps quiet linked-device sessions open when transport frames keep arriving", async () => {
260+
vi.useFakeTimers();
261+
try {
262+
const sleep = vi.fn(async () => {});
263+
const scripted = createScriptedWebListenerFactory();
264+
const { controller, run } = startWebAutoReplyMonitor({
265+
monitorWebChannelFn: monitorWebChannel as never,
266+
listenerFactory: scripted.listenerFactory,
267+
sleep,
268+
heartbeatSeconds: 60,
269+
messageTimeoutMs: 30,
270+
watchdogCheckMs: 5,
271+
});
272+
273+
await vi.waitFor(
274+
() => {
275+
expect(scripted.getListenerCount()).toBe(1);
276+
},
277+
{ timeout: 250, interval: 2 },
278+
);
279+
280+
const socket = getLastWebAutoReplySessionSocket();
281+
await vi.advanceTimersByTimeAsync(20);
282+
socket.ws.emit("frame");
283+
await vi.advanceTimersByTimeAsync(20);
284+
socket.ws.emit("frame");
285+
await vi.advanceTimersByTimeAsync(20);
286+
287+
expect(scripted.getListenerCount()).toBe(1);
288+
289+
controller.abort();
290+
scripted.resolveClose(0, { status: 499, isLoggedOut: false });
291+
await Promise.resolve();
292+
await run;
293+
} finally {
294+
vi.useRealTimers();
295+
}
296+
});
297+
298+
it("does not let transport frames mask application silence forever", async () => {
299+
vi.useFakeTimers();
300+
try {
301+
const sleep = vi.fn(async () => {});
302+
const scripted = createScriptedWebListenerFactory();
303+
const { controller, run } = startWebAutoReplyMonitor({
304+
monitorWebChannelFn: monitorWebChannel as never,
305+
listenerFactory: scripted.listenerFactory,
306+
sleep,
307+
heartbeatSeconds: 60,
308+
messageTimeoutMs: 30,
309+
watchdogCheckMs: 5,
310+
});
311+
312+
await vi.waitFor(
313+
() => {
314+
expect(scripted.getListenerCount()).toBe(1);
315+
},
316+
{ timeout: 250, interval: 2 },
317+
);
318+
319+
const socket = getLastWebAutoReplySessionSocket();
320+
for (let elapsedMs = 0; elapsedMs < 140; elapsedMs += 20) {
321+
socket.ws.emit("frame");
322+
await vi.advanceTimersByTimeAsync(20);
323+
}
324+
325+
await vi.waitFor(
326+
() => {
327+
expect(scripted.getListenerCount()).toBeGreaterThanOrEqual(2);
328+
},
329+
{ timeout: 250, interval: 2 },
330+
);
331+
332+
controller.abort();
333+
scripted.resolveClose(scripted.getListenerCount() - 1, {
334+
status: 499,
335+
isLoggedOut: false,
336+
error: "aborted",
337+
});
338+
await Promise.resolve();
339+
await run;
340+
} finally {
341+
vi.useRealTimers();
342+
}
343+
});
344+
258345
it("gives a reconnected listener a fresh watchdog window", async () => {
259346
vi.useFakeTimers();
260347
try {

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ export async function monitorWebChannel(
280280
reconnectAttempts: snapshot.reconnectAttempts,
281281
messagesHandled: snapshot.handledMessages,
282282
lastInboundAt: snapshot.lastInboundAt,
283+
lastTransportActivityAt: snapshot.lastTransportActivityAt,
283284
authAgeMs,
284285
uptimeMs: snapshot.uptimeMs,
285286
...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30
@@ -297,20 +298,28 @@ export async function monitorWebChannel(
297298
}
298299
},
299300
onWatchdogTimeout: (snapshot) => {
300-
const watchdogBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
301-
const minutesSinceLastMessage = Math.floor((Date.now() - watchdogBaselineAt) / 60000);
301+
const now = Date.now();
302+
const transportSilentMs = now - snapshot.lastTransportActivityAt;
303+
const appBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
304+
const minutesSinceTransportActivity = Math.floor(transportSilentMs / 60000);
305+
const minutesSinceAppActivity = Math.floor((now - appBaselineAt) / 60000);
306+
const watchdogReason =
307+
transportSilentMs > messageTimeoutMs ? "transport-inactive" : "app-silent";
302308
statusController.noteWatchdogStale();
303309
heartbeatLogger.warn(
304310
{
305311
connectionId: snapshot.connectionId,
306-
minutesSinceLastMessage,
312+
watchdogReason,
313+
minutesSinceTransportActivity,
314+
minutesSinceAppActivity,
307315
lastInboundAt: snapshot.lastInboundAt ? new Date(snapshot.lastInboundAt) : null,
316+
lastTransportActivityAt: new Date(snapshot.lastTransportActivityAt),
308317
messagesHandled: snapshot.handledMessages,
309318
},
310-
"Message timeout detected - forcing reconnect",
319+
"WhatsApp watchdog timeout detected - forcing reconnect",
311320
);
312321
whatsappHeartbeatLog.warn(
313-
`No messages received in ${minutesSinceLastMessage}m - restarting connection`,
322+
`WhatsApp watchdog timeout (${watchdogReason}) - restarting connection`,
314323
);
315324
},
316325
});

extensions/whatsapp/src/connection-controller.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@ export type WhatsAppLiveConnection = {
4040
heartbeat: TimerHandle | null;
4141
watchdogTimer: TimerHandle | null;
4242
lastInboundAt: number | null;
43+
lastTransportActivityAt: number;
4344
handledMessages: number;
4445
unregisterUnhandled: (() => void) | null;
46+
unregisterTransportActivity: (() => void) | null;
4547
backgroundTasks: Set<Promise<unknown>>;
4648
closePromise: Promise<WebListenerCloseReason>;
4749
resolveClose: (reason: WebListenerCloseReason) => void;
@@ -51,6 +53,7 @@ export type WhatsAppConnectionSnapshot = {
5153
connectionId: string;
5254
startedAt: number;
5355
lastInboundAt: number | null;
56+
lastTransportActivityAt: number;
5457
handledMessages: number;
5558
reconnectAttempts: number;
5659
uptimeMs: number;
@@ -83,6 +86,12 @@ function createNeverResolvePromise<T>(): Promise<T> {
8386
return new Promise<T>(() => {});
8487
}
8588

89+
type SocketActivityEmitter = {
90+
on?: (event: string, listener: (...args: unknown[]) => void) => void;
91+
off?: (event: string, listener: (...args: unknown[]) => void) => void;
92+
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
93+
};
94+
8695
function createLiveConnection(params: {
8796
connectionId: string;
8897
sock: WASocket;
@@ -108,8 +117,10 @@ function createLiveConnection(params: {
108117
heartbeat: null,
109118
watchdogTimer: null,
110119
lastInboundAt: null,
120+
lastTransportActivityAt: Date.now(),
111121
handledMessages: 0,
112122
unregisterUnhandled: null,
123+
unregisterTransportActivity: null,
113124
backgroundTasks: new Set<Promise<unknown>>(),
114125
closePromise,
115126
resolveClose: resolveClosePromise,
@@ -232,6 +243,7 @@ export class WhatsAppConnectionController {
232243
private readonly heartbeatSeconds: number;
233244
private readonly keepAlive: boolean;
234245
private readonly messageTimeoutMs: number;
246+
private readonly appSilenceTimeoutMs: number;
235247
private readonly watchdogCheckMs: number;
236248
private readonly verbose: boolean;
237249
private readonly abortSignal?: AbortSignal;
@@ -262,6 +274,7 @@ export class WhatsAppConnectionController {
262274
this.keepAlive = params.keepAlive;
263275
this.heartbeatSeconds = params.heartbeatSeconds;
264276
this.messageTimeoutMs = params.messageTimeoutMs;
277+
this.appSilenceTimeoutMs = Math.max(params.messageTimeoutMs, params.messageTimeoutMs * 4);
265278
this.watchdogCheckMs = params.watchdogCheckMs;
266279
this.reconnectPolicy = params.reconnectPolicy;
267280
this.abortSignal = params.abortSignal;
@@ -311,6 +324,14 @@ export class WhatsAppConnectionController {
311324
}
312325
this.current.handledMessages += 1;
313326
this.current.lastInboundAt = timestamp;
327+
this.current.lastTransportActivityAt = timestamp;
328+
}
329+
330+
noteTransportActivity(timestamp = Date.now()): void {
331+
if (!this.current) {
332+
return;
333+
}
334+
this.current.lastTransportActivityAt = timestamp;
314335
}
315336

316337
getCurrentSnapshot(
@@ -323,6 +344,7 @@ export class WhatsAppConnectionController {
323344
connectionId: connection.connectionId,
324345
startedAt: connection.startedAt,
325346
lastInboundAt: connection.lastInboundAt,
347+
lastTransportActivityAt: connection.lastTransportActivityAt,
326348
handledMessages: connection.handledMessages,
327349
reconnectAttempts: this.reconnectAttempts,
328350
uptimeMs: Date.now() - connection.startedAt,
@@ -369,6 +391,7 @@ export class WhatsAppConnectionController {
369391
const listener = await params.createListener({ sock, connection });
370392
connection.listener = listener;
371393
this.current = connection;
394+
connection.unregisterTransportActivity = this.attachTransportActivityListener(sock);
372395
registerWhatsAppConnectionController(this.accountId, this);
373396
this.startTimers(connection, {
374397
onHeartbeat: params.onHeartbeat,
@@ -383,6 +406,7 @@ export class WhatsAppConnectionController {
383406
if (connection?.unregisterUnhandled) {
384407
connection.unregisterUnhandled();
385408
}
409+
connection?.unregisterTransportActivity?.();
386410
throw err;
387411
}
388412
}
@@ -515,6 +539,7 @@ export class WhatsAppConnectionController {
515539
this.socketRef.current = null;
516540
}
517541
connection.unregisterUnhandled?.();
542+
connection.unregisterTransportActivity?.();
518543
if (connection.heartbeat) {
519544
clearInterval(connection.heartbeat);
520545
}
@@ -563,9 +588,14 @@ export class WhatsAppConnectionController {
563588
}, this.heartbeatSeconds * 1000);
564589

565590
connection.watchdogTimer = setInterval(() => {
566-
const baselineAt = connection.lastInboundAt ?? connection.startedAt;
567-
const staleForMs = Date.now() - baselineAt;
568-
if (staleForMs <= this.messageTimeoutMs) {
591+
const now = Date.now();
592+
const transportStaleForMs = now - connection.lastTransportActivityAt;
593+
const appBaselineAt = connection.lastInboundAt ?? connection.startedAt;
594+
const appSilentForMs = now - appBaselineAt;
595+
if (
596+
transportStaleForMs <= this.messageTimeoutMs &&
597+
appSilentForMs <= this.appSilenceTimeoutMs
598+
) {
569599
return;
570600
}
571601
const snapshot = this.getCurrentSnapshot(connection);
@@ -581,6 +611,24 @@ export class WhatsAppConnectionController {
581611
}, this.watchdogCheckMs);
582612
}
583613

614+
private attachTransportActivityListener(sock: WASocket): (() => void) | null {
615+
const ws = sock.ws as SocketActivityEmitter | undefined;
616+
if (!ws || typeof ws.on !== "function") {
617+
return null;
618+
}
619+
620+
const noteActivity = () => this.noteTransportActivity();
621+
ws.on("frame", noteActivity);
622+
623+
return () => {
624+
if (typeof ws.off === "function") {
625+
ws.off("frame", noteActivity);
626+
return;
627+
}
628+
ws.removeListener?.("frame", noteActivity);
629+
};
630+
}
631+
584632
private stopDisconnectRetries(): void {
585633
if (!this.disconnectRetryController.signal.aborted) {
586634
this.disconnectRetryController.abort();

0 commit comments

Comments
 (0)