From eb158545fcfab963b4d6e075a7f46a04b491e756 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 28 Dec 2025 12:04:20 +0000 Subject: [PATCH] fix: force web reconnect on stalled close --- CHANGELOG.md | 1 + src/web/auto-reply.test.ts | 81 ++++++++++++++++++++++++++++++++++++++ src/web/auto-reply.ts | 5 +++ src/web/inbound.ts | 15 ++++++- 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdfab5110..5342795c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Heartbeat replies now drop any output containing `HEARTBEAT_OK`, preventing stray emoji/text from being delivered. - macOS menu now refreshes the control channel after the gateway starts and shows “Connecting to gateway…” while the gateway is coming up. - macOS local mode now waits for the gateway to be ready before configuring the control channel, avoiding false “no connection” flashes. +- WhatsApp watchdog now forces a reconnect even if the socket close event stalls (prevents silent inbox stalls). ## 2.0.0-beta3 — 2025-12-27 diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 8f6188adb..8edf2dcf7 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -311,6 +311,87 @@ describe("web auto-reply", () => { await run; }); + it("forces reconnect when watchdog closes without onClose", async () => { + vi.useFakeTimers(); + const sleep = vi.fn(async () => {}); + const closeResolvers: Array<(reason: unknown) => void> = []; + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = vi.fn(async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + let resolveClose: (reason: unknown) => void = () => {}; + const onClose = new Promise((res) => { + resolveClose = res; + closeResolvers.push(res); + }); + return { + close: vi.fn(), + onClose, + signalClose: (reason?: unknown) => resolveClose(reason), + }; + }); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const controller = new AbortController(); + const run = monitorWebProvider( + false, + listenerFactory, + true, + async () => ({ text: "ok" }), + runtime as never, + controller.signal, + { + heartbeatSeconds: 1, + reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 }, + sleep, + }, + ); + + await Promise.resolve(); + expect(listenerFactory).toHaveBeenCalledTimes(1); + + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const sendMedia = vi.fn(); + await capturedOnMessage?.({ + body: "hi", + from: "+1", + to: "+2", + id: "m1", + sendComposing, + reply, + sendMedia, + }); + + await vi.advanceTimersByTimeAsync(31 * 60 * 1000); + await Promise.resolve(); + + const waitForSecondCall = async () => { + const started = Date.now(); + while ( + listenerFactory.mock.calls.length < 2 && + Date.now() - started < 200 + ) { + await Promise.resolve(); + } + }; + await waitForSecondCall(); + expect(listenerFactory).toHaveBeenCalledTimes(2); + + controller.abort(); + closeResolvers[1]?.({ status: 499, isLoggedOut: false }); + await Promise.resolve(); + await run; + }); + it( "stops after hitting max reconnect attempts", { timeout: 20000 }, diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 4fef1142a..9fbd43caf 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1361,6 +1361,11 @@ export async function monitorWebProvider( void closeListener().catch((err) => { logVerbose(`Close listener failed: ${formatError(err)}`); }); // Trigger reconnect + listener.signalClose?.({ + status: 499, + isLoggedOut: false, + error: "watchdog-timeout", + }); } } }, WATCHDOG_CHECK_MS); diff --git a/src/web/inbound.ts b/src/web/inbound.ts index 53301f73b..aa96077b6 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -78,6 +78,12 @@ export async function monitorWebInbox(options: { const onClose = new Promise((resolve) => { onCloseResolve = resolve; }); + const resolveClose = (reason: WebListenerCloseReason) => { + if (!onCloseResolve) return; + const resolver = onCloseResolve; + onCloseResolve = null; + resolver(reason); + }; try { // Advertise that the gateway is online right after connecting. await sock.sendPresenceUpdate("available"); @@ -310,7 +316,7 @@ export async function monitorWebInbox(options: { try { if (update.connection === "close") { const status = getStatusCode(update.lastDisconnect?.error); - onCloseResolve?.({ + resolveClose({ status, isLoggedOut: status === DisconnectReason.loggedOut, error: update.lastDisconnect?.error, @@ -321,7 +327,7 @@ export async function monitorWebInbox(options: { { error: String(err) }, "connection.update handler error", ); - onCloseResolve?.({ + resolveClose({ status: undefined, isLoggedOut: false, error: err, @@ -359,6 +365,11 @@ export async function monitorWebInbox(options: { } }, onClose, + signalClose: (reason?: WebListenerCloseReason) => { + resolveClose( + reason ?? { status: undefined, isLoggedOut: false, error: "closed" }, + ); + }, /** * Send a message through this connection's socket. * Used by IPC to avoid creating new connections.