From 69225003820aeb8c260bafc6a61f34225edc859d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 02:28:56 +0100 Subject: [PATCH] fix: end WhatsApp sockets during teardown --- CHANGELOG.md | 1 + .../src/connection-controller.test.ts | 16 ++++++++++++-- .../whatsapp/src/connection-controller.ts | 22 +++++++++++++++++-- extensions/whatsapp/src/inbound/lifecycle.ts | 5 +++++ ...-media-path-image-messages.test-support.ts | 6 +++-- .../src/monitor-inbox.test-harness.ts | 2 ++ 6 files changed, 46 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78a7a0be832..c000acdde55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- WhatsApp: close long-lived web sockets through Baileys `end(error)` before falling back to raw websocket close, so listener teardown runs Baileys cleanup instead of leaving zombie sockets. Fixes #52442. Thanks @essendigitalgroup-cyber. - Gateway/sessions: move hot transcript reads and mirror appends onto async bounded IO with serialized parent-linked writes, keeping large session histories from stalling Gateway requests and channel replies. Fixes #75656. Thanks @DerFlash. - macOS/Voice Wake: accept trigger-only phrases in the built-in Voice Wake test, matching the settings UI and runtime trigger-only path instead of requiring extra command text after the wake word. Fixes #64986. Thanks @zoiks65. - Cron/TTS: run cron announce payloads through the normal TTS directive transform before outbound delivery, so scheduled `[[tts]]` replies generate voice payloads instead of leaking raw tags. Fixes #52125. Thanks @kenchen3000. diff --git a/extensions/whatsapp/src/connection-controller.test.ts b/extensions/whatsapp/src/connection-controller.test.ts index 592eafeb09b..718d5aa767a 100644 --- a/extensions/whatsapp/src/connection-controller.test.ts +++ b/extensions/whatsapp/src/connection-controller.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "node:events"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js"; -import { WhatsAppConnectionController } from "./connection-controller.js"; +import { closeWaSocket, WhatsAppConnectionController } from "./connection-controller.js"; import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js"; import { createWaSocket, waitForWaConnection } from "./session.js"; @@ -40,6 +40,7 @@ function createSocketWithTransportEmitter() { const ws = new EventEmitter() as EventEmitter & { close: ReturnType }; ws.close = vi.fn(); return { + end: vi.fn(), ws, }; } @@ -74,6 +75,7 @@ describe("WhatsAppConnectionController", () => { it("closes the socket when open fails before listener creation", async () => { const sock = { + end: vi.fn(), ws: { close: vi.fn(), }, @@ -91,11 +93,21 @@ describe("WhatsAppConnectionController", () => { ).rejects.toThrow("handshake failed"); expect(createListener).not.toHaveBeenCalled(); - expect(sock.ws.close).toHaveBeenCalledOnce(); + expect(sock.end).toHaveBeenCalledOnce(); + expect(sock.end).toHaveBeenCalledWith(expect.any(Error)); + expect(sock.ws.close).not.toHaveBeenCalled(); expect(controller.socketRef.current).toBeNull(); expect(controller.getActiveListener()).toBeNull(); }); + it("falls back to raw websocket close when Baileys end is unavailable", () => { + const sock = { ws: { close: vi.fn() } }; + + closeWaSocket(sock); + + expect(sock.ws.close).toHaveBeenCalledOnce(); + }); + it("lets createWaSocket own the auth barrier before opening a socket", async () => { const callOrder: string[] = []; createWaSocketMock.mockImplementationOnce(async () => { diff --git a/extensions/whatsapp/src/connection-controller.ts b/extensions/whatsapp/src/connection-controller.ts index 27b5b284853..6fd0dc79ec6 100644 --- a/extensions/whatsapp/src/connection-controller.ts +++ b/extensions/whatsapp/src/connection-controller.ts @@ -131,8 +131,20 @@ function createLiveConnection(params: { }; } -export function closeWaSocket(sock: { ws?: { close?: () => void } } | null | undefined): void { +export function closeWaSocket( + sock: + | { + end?: (error: Error | undefined) => void; + ws?: { close?: () => void }; + } + | null + | undefined, +): void { try { + if (typeof sock?.end === "function") { + sock.end(new Error("OpenClaw WhatsApp socket close")); + return; + } sock?.ws?.close?.(); } catch { // ignore best-effort shutdown failures @@ -140,7 +152,13 @@ export function closeWaSocket(sock: { ws?: { close?: () => void } } | null | und } export function closeWaSocketSoon( - sock: { ws?: { close?: () => void } } | null | undefined, + sock: + | { + end?: (error: Error | undefined) => void; + ws?: { close?: () => void }; + } + | null + | undefined, delayMs = 500, ): void { setTimeout(() => { diff --git a/extensions/whatsapp/src/inbound/lifecycle.ts b/extensions/whatsapp/src/inbound/lifecycle.ts index c00bc267cb5..ef7b60bd95c 100644 --- a/extensions/whatsapp/src/inbound/lifecycle.ts +++ b/extensions/whatsapp/src/inbound/lifecycle.ts @@ -7,6 +7,7 @@ type OffCapableEmitter = { }; type ClosableSocket = { + end?: (error: Error | undefined) => void; ws?: { close?: () => void; }; @@ -30,5 +31,9 @@ export function attachEmitterListener( } export function closeInboundMonitorSocket(sock: ClosableSocket): void { + if (typeof sock.end === "function") { + sock.end(new Error("OpenClaw WhatsApp listener close")); + return; + } sock.ws?.close?.(); } diff --git a/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test-support.ts b/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test-support.ts index 2b803773595..6f559c46542 100644 --- a/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test-support.ts +++ b/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test-support.ts @@ -124,7 +124,7 @@ describe("web monitor inbox", () => { await listener.close(); }); - it("detaches inbound listeners and closes the socket on close()", async () => { + it("detaches inbound listeners and ends the socket on close()", async () => { const listener = await openMonitor(vi.fn()); const sock = getSock(); @@ -135,7 +135,9 @@ describe("web monitor inbox", () => { expect(sock.ev.listenerCount("messages.upsert")).toBe(0); expect(sock.ev.listenerCount("connection.update")).toBe(0); - expect(sock.ws.close).toHaveBeenCalledTimes(1); + expect(sock.end).toHaveBeenCalledTimes(1); + expect(sock.end).toHaveBeenCalledWith(expect.any(Error)); + expect(sock.ws.close).not.toHaveBeenCalled(); }); it("logs inbound bodies through the inbound child logger", async () => { diff --git a/extensions/whatsapp/src/monitor-inbox.test-harness.ts b/extensions/whatsapp/src/monitor-inbox.test-harness.ts index 5c8cc5a4a1a..453c88ae6dc 100644 --- a/extensions/whatsapp/src/monitor-inbox.test-harness.ts +++ b/extensions/whatsapp/src/monitor-inbox.test-harness.ts @@ -34,6 +34,7 @@ export const upsertPairingRequestMock = pairingUpsertPairingRequestMock; export type MockSock = { ev: EventEmitter; + end: AnyMockFn; ws: { close: AnyMockFn }; sendPresenceUpdate: AnyMockFn; sendMessage: AnyMockFn; @@ -107,6 +108,7 @@ function createMockSock(): MockSock { const ev = new EventEmitter(); return { ev, + end: vi.fn(), ws: { close: vi.fn() }, sendPresenceUpdate: createResolvedMock(), sendMessage: createResolvedMock(),