diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 8c66df9b5df6..cf731bb11dc8 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -26,6 +26,7 @@ import { setRuntimeConfigSourceSnapshotMock, startWebAutoReplyMonitor, } from "./auto-reply.test-harness.js"; +import { waitForWaConnection } from "./session.js"; type DrainSelectionEntry = { channel: string; @@ -225,6 +226,33 @@ describe("web auto-reply connection", () => { expectErrorContaining(runtime.error, "2/2 attempts"); }); + it("retries opening-phase connection wait timeouts through the reconnect policy", async () => { + vi.mocked(waitForWaConnection).mockRejectedValueOnce({ output: { statusCode: 408 } }); + const listenerFactory = vi.fn(async () => createMockWebListener()); + const sleep = vi.fn(async () => {}); + const { runtime, controller, run } = startWebAutoReplyMonitor({ + monitorWebChannelFn: monitorWebChannel as never, + listenerFactory, + sleep, + reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 2, factor: 1.1 }, + }); + + await vi.waitFor( + () => { + expect(listenerFactory).toHaveBeenCalledTimes(1); + }, + { timeout: 250, interval: 2 }, + ); + controller.abort(); + await run; + + expect(waitForWaConnection).toHaveBeenCalledTimes(2); + expect(listenerFactory).toHaveBeenCalledTimes(1); + expect(sleep).toHaveBeenCalled(); + expectErrorContaining(runtime.error, "status 408"); + expectErrorContaining(runtime.error, "Retry 1/2"); + }); + it("keeps post-open Baileys 428 on the reconnect path", async () => { const sleep = vi.fn(async () => {}); const scripted = createScriptedWebListenerFactory(); diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index 6bd1ead0653a..651ca30f141c 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -32,13 +32,7 @@ import { resolveReconnectPolicy, sleepWithAbort, } from "../reconnect.js"; -import { - formatError, - getStatusCode, - getWebAuthAgeMs, - logoutWeb, - readWebSelfId, -} from "../session.js"; +import { formatError, getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../session.js"; import { resolveWhatsAppSocketTiming } from "../socket-timing.js"; import { getRuntimeConfig, getRuntimeConfigSourceSnapshot } from "./config.runtime.js"; import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js"; @@ -407,45 +401,73 @@ export async function monitorWebChannel( }, }); } catch (error) { - if (getStatusCode(error) === 428) { - const retryDecision = controller.consumeReconnectAttempt(); - statusController.noteReconnectAttempts(retryDecision.reconnectAttempts); + const setupDecision = controller.resolveSetupErrorDecision(error); + if (setupDecision === "aborted") { + await controller.shutdown(); + break; + } + if (setupDecision) { + statusController.noteReconnectAttempts(setupDecision.reconnectAttempts); statusController.noteClose({ - statusCode: 428, + statusCode: setupDecision.normalized.statusCode, error: formatError(error), - reconnectAttempts: retryDecision.reconnectAttempts, - healthState: retryDecision.healthState, + reconnectAttempts: setupDecision.reconnectAttempts, + healthState: setupDecision.healthState, }); - if (retryDecision.action === "stop") { + if (setupDecision.action === "stop") { reconnectLogger.warn( { connectionId, - status: 428, - reconnectAttempts: retryDecision.reconnectAttempts, + status: setupDecision.normalized.statusLabel, + reconnectAttempts: setupDecision.reconnectAttempts, maxAttempts: reconnectPolicy.maxAttempts, }, - "web reconnect: 428 during opening; max attempts reached", - ); - runtime.error( - `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.`, + "web reconnect: setup status error; max attempts reached", ); + if (setupDecision.healthState === "logged-out") { + await clearTerminalWebAuthState({ + account, + runtime, + statusLabel: setupDecision.normalized.statusLabel, + healthState: setupDecision.healthState, + log: reconnectLogger, + }); + runtime.error( + `WhatsApp session logged out during setup. Run \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` to relink.`, + ); + } else if (setupDecision.healthState === "conflict") { + await clearTerminalWebAuthState({ + account, + runtime, + statusLabel: setupDecision.normalized.statusLabel, + healthState: setupDecision.healthState, + log: reconnectLogger, + }); + runtime.error( + `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.`, + ); + } else { + runtime.error( + `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.`, + ); + } await controller.shutdown(); break; } reconnectLogger.info( { connectionId, - status: 428, - reconnectAttempts: retryDecision.reconnectAttempts, - delayMs: retryDecision.delayMs, + status: setupDecision.normalized.statusLabel, + reconnectAttempts: setupDecision.reconnectAttempts, + delayMs: setupDecision.delayMs, }, - "web reconnect: 428 during opening; retrying", + "web reconnect: setup status error; retrying", ); runtime.error( - `WhatsApp Web connection closed during setup (status 428). Retry ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(retryDecision.delayMs ?? 0)}.`, + `WhatsApp Web connection closed during setup (status ${setupDecision.normalized.statusLabel}). Retry ${setupDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(setupDecision.delayMs ?? 0)}.`, ); try { - await controller.waitBeforeRetry(retryDecision.delayMs ?? 0); + await controller.waitBeforeRetry(setupDecision.delayMs ?? 0); } catch { break; } diff --git a/extensions/whatsapp/src/connection-controller.test.ts b/extensions/whatsapp/src/connection-controller.test.ts index 93460ce3f58f..874be5be2730 100644 --- a/extensions/whatsapp/src/connection-controller.test.ts +++ b/extensions/whatsapp/src/connection-controller.test.ts @@ -9,6 +9,7 @@ import { } from "./connection-controller.js"; import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js"; import { createWaSocket, waitForWaConnection } from "./session.js"; +import { DEFAULT_WHATSAPP_SOCKET_TIMING } from "./socket-timing.js"; vi.mock("./session.js", async () => { const actual = await vi.importActual("./session.js"); @@ -130,6 +131,9 @@ describe("WhatsAppConnectionController", () => { }); expect(callOrder).toEqual(["create", "wait-for-connection"]); + expect(waitForWaConnectionMock).toHaveBeenCalledWith(expect.anything(), { + timeoutMs: DEFAULT_WHATSAPP_SOCKET_TIMING.connectTimeoutMs, + }); }); it("restarts login once on status 408 and preserves replacement socket options", async () => { @@ -180,6 +184,8 @@ describe("WhatsAppConnectionController", () => { }); expect(onQr).toHaveBeenCalledWith("qr-after-timeout"); expect(onSocketReplaced).toHaveBeenCalledWith(replacementSock); + expect(waitForConnection).toHaveBeenNthCalledWith(1, initialSock, { timeout: "none" }); + expect(waitForConnection).toHaveBeenNthCalledWith(2, replacementSock, { timeout: "none" }); }); it("still honors the post-pairing 515 restart after a status 408 recovery", async () => { @@ -213,6 +219,11 @@ describe("WhatsAppConnectionController", () => { }); expect(createSocket).toHaveBeenCalledTimes(2); expect(waitForConnection).toHaveBeenCalledTimes(3); + expect(waitForConnection).toHaveBeenNthCalledWith(1, initialSock, { timeout: "none" }); + expect(waitForConnection).toHaveBeenNthCalledWith(2, afterTimeoutSock, { timeout: "none" }); + expect(waitForConnection).toHaveBeenNthCalledWith(3, afterPairingRestartSock, { + timeout: "none", + }); expect(initialSock.end).toHaveBeenCalledOnce(); expect(afterTimeoutSock.end).toHaveBeenCalledOnce(); }); diff --git a/extensions/whatsapp/src/connection-controller.ts b/extensions/whatsapp/src/connection-controller.ts index 882700cb9737..6d56963f6e59 100644 --- a/extensions/whatsapp/src/connection-controller.ts +++ b/extensions/whatsapp/src/connection-controller.ts @@ -1,4 +1,4 @@ -import { DisconnectReason, type WASocket } from "baileys"; +import type { WASocket } from "baileys"; import { info } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { @@ -14,11 +14,14 @@ import { logoutWeb, waitForWaConnection, } from "./session.js"; -import type { WhatsAppSocketTimingOptions } from "./socket-timing.js"; +import { + DEFAULT_WHATSAPP_SOCKET_TIMING, + type WhatsAppSocketTimingOptions, +} from "./socket-timing.js"; -const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401; +const LOGGED_OUT_STATUS = 401; const POST_PAIRING_RESTART_STATUS = 515; -const TIMED_OUT_STATUS = DisconnectReason?.timedOut ?? 408; +const TIMED_OUT_STATUS = 408; const WHATSAPP_LOGIN_RESTART_MESSAGE = "WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"; const WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE = @@ -228,7 +231,7 @@ export async function waitForWhatsAppLoginResult(params: { while (true) { try { - await wait(currentSock); + await wait(currentSock, { timeout: "none" }); return { outcome: "connected", restarted: postPairingRestarted || timeoutRestarted, @@ -306,7 +309,7 @@ export class WhatsAppConnectionController { private readonly abortSignal?: AbortSignal; private readonly sleep: (ms: number, signal?: AbortSignal) => Promise; private readonly isNonRetryableStatus: (statusCode: unknown) => boolean; - private readonly socketTiming: WhatsAppSocketTimingOptions; + private readonly socketTiming: Required; private readonly abortPromise?: Promise<"aborted">; private readonly disconnectRetryController = new AbortController(); @@ -342,7 +345,10 @@ export class WhatsAppConnectionController { this.abortSignal = params.abortSignal; this.sleep = params.sleep ?? ((ms: number, signal?: AbortSignal) => sleepWithAbort(ms, signal)); this.isNonRetryableStatus = params.isNonRetryableStatus ?? (() => false); - this.socketTiming = params.socketTiming ?? {}; + this.socketTiming = { + ...DEFAULT_WHATSAPP_SOCKET_TIMING, + ...params.socketTiming, + }; this.socketRef = { current: null }; this.abortPromise = params.abortSignal && @@ -445,7 +451,7 @@ export class WhatsAppConnectionController { authDir: this.authDir, ...this.socketTiming, }); - await waitForWaConnection(sock); + await waitForWaConnection(sock, { timeoutMs: this.socketTiming.connectTimeoutMs }); this.socketRef.current = sock; const placeholderListener = {} as ManagedWhatsAppListener; @@ -565,6 +571,19 @@ export class WhatsAppConnectionController { }; } + resolveSetupErrorDecision(error: unknown): WhatsAppConnectionCloseDecision | "aborted" | null { + const statusCode = getStatusCode(error); + if (typeof statusCode !== "number") { + return null; + } + + return this.resolveCloseDecision({ + status: statusCode, + isLoggedOut: statusCode === LOGGED_OUT_STATUS, + error, + }); + } + consumeReconnectAttempt(): WhatsAppReconnectAttemptDecision { this.reconnectAttempts += 1; if ( diff --git a/extensions/whatsapp/src/inbound.media.test.ts b/extensions/whatsapp/src/inbound.media.test.ts index f73744485fa4..26fb0d6476a0 100644 --- a/extensions/whatsapp/src/inbound.media.test.ts +++ b/extensions/whatsapp/src/inbound.media.test.ts @@ -221,6 +221,7 @@ vi.mock("./session.js", async () => { let monitorWebInbox: typeof import("./inbound.js").monitorWebInbox; let resetWebInboundDedupe: typeof import("./inbound.js").resetWebInboundDedupe; let createWaSocket: typeof import("./session.js").createWaSocket; +let waitForWaConnection: typeof import("./session.js").waitForWaConnection; async function waitForMessage(onMessage: ReturnType) { await vi.waitFor(() => expect(onMessage).toHaveBeenCalledTimes(1), { @@ -262,7 +263,7 @@ describe("web inbound media saves with extension", () => { beforeAll(async () => { await fs.rm(HOME, { recursive: true, force: true }); ({ monitorWebInbox, resetWebInboundDedupe } = await import("./inbound.js")); - ({ createWaSocket } = await import("./session.js")); + ({ createWaSocket, waitForWaConnection } = await import("./session.js")); }); afterAll(async () => { @@ -274,6 +275,30 @@ describe("web inbound media saves with extension", () => { } }); + it("closes the socket when connection wait fails before inbox attach", async () => { + const error = new Error("connection timeout"); + vi.mocked(waitForWaConnection).mockRejectedValueOnce(error); + + await expect( + monitorWebInbox({ + cfg: { + channels: { whatsapp: { allowFrom: ["*"] } }, + messages: { messagePrefix: undefined, responsePrefix: undefined }, + web: { whatsapp: { connectTimeoutMs: 12_345 } }, + } as never, + verbose: false, + onMessage: vi.fn(), + accountId: "default", + authDir: path.join(HOME, "wa-auth"), + }), + ).rejects.toThrow("connection timeout"); + + expect(vi.mocked(waitForWaConnection)).toHaveBeenCalledWith(currentMockSocket, { + timeoutMs: 12_345, + }); + expect(currentMockSocket?.ws.close).toHaveBeenCalledOnce(); + }); + it("stores image extension and keeps document filename", async () => { const onMessage = vi.fn(); const listener = await monitorWebInbox({ diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index c662bea7cdc8..b4706cf9ca6f 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -1294,11 +1294,17 @@ export async function attachWebInboxToSocket( } export async function monitorWebInbox(options: MonitorWebInboxOptions) { + const socketTiming = resolveWhatsAppSocketTiming(options.cfg); const sock = await createWaSocket(false, options.verbose, { authDir: options.authDir, - ...resolveWhatsAppSocketTiming(options.cfg), + ...socketTiming, }); - await waitForWaConnection(sock); + try { + await waitForWaConnection(sock, { timeoutMs: socketTiming.connectTimeoutMs }); + } catch (err) { + closeInboundMonitorSocket(sock); + throw err; + } return attachWebInboxToSocket({ ...options, sock, diff --git a/extensions/whatsapp/src/qa-driver.runtime.test.ts b/extensions/whatsapp/src/qa-driver.runtime.test.ts index 0c21260c8143..e16a6a5e0d64 100644 --- a/extensions/whatsapp/src/qa-driver.runtime.test.ts +++ b/extensions/whatsapp/src/qa-driver.runtime.test.ts @@ -87,8 +87,7 @@ describe("startWhatsAppQaDriverSession", () => { await session.close(); }); - it("clears the connection timeout after a successful connection", async () => { - vi.useFakeTimers(); + it("passes the connection timeout to the shared connection waiter", async () => { const sock = createMockSocket(); mocks.createWaSocket.mockResolvedValue(sock); mocks.waitForWaConnection.mockResolvedValue(undefined); @@ -98,30 +97,26 @@ describe("startWhatsAppQaDriverSession", () => { connectionTimeoutMs: 45_000, }); - expect(vi.getTimerCount()).toBe(0); + expect(mocks.waitForWaConnection).toHaveBeenCalledWith(sock, { timeoutMs: 45_000 }); await session.close(); }); it("closes the socket and removes listeners when connection setup times out", async () => { - vi.useFakeTimers(); const sock = createMockSocket(); + const timeoutError = new Error("timed out waiting for WhatsApp QA driver session"); mocks.createWaSocket.mockResolvedValue(sock); - mocks.waitForWaConnection.mockReturnValue(new Promise(() => {})); + mocks.waitForWaConnection.mockRejectedValue(timeoutError); - const started = startWhatsAppQaDriverSession({ - authDir: "/tmp/openclaw-whatsapp-auth", - connectionTimeoutMs: 10, - }); - const rejection = started.catch((error: unknown) => error); + await expect( + startWhatsAppQaDriverSession({ + authDir: "/tmp/openclaw-whatsapp-auth", + connectionTimeoutMs: 10, + }), + ).rejects.toThrow("timed out waiting for WhatsApp QA driver session"); - await vi.advanceTimersByTimeAsync(10); - - const error = await rejection; - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("timed out waiting for WhatsApp QA driver session"); + expect(mocks.waitForWaConnection).toHaveBeenCalledWith(sock, { timeoutMs: 10 }); expect(sock.ev.listenerCount("messages.upsert")).toBe(0); expect(sock.end).toHaveBeenCalledOnce(); - expect(vi.getTimerCount()).toBe(0); }); }); diff --git a/extensions/whatsapp/src/qa-driver.runtime.ts b/extensions/whatsapp/src/qa-driver.runtime.ts index b068feabefab..305bbc62d0c5 100644 --- a/extensions/whatsapp/src/qa-driver.runtime.ts +++ b/extensions/whatsapp/src/qa-driver.runtime.ts @@ -126,27 +126,13 @@ export async function startWhatsAppQaDriverSession(params: { }; sock.ev.on("messages.upsert", onMessagesUpsert); - let connectionTimeout: NodeJS.Timeout | undefined; try { - await Promise.race([ - waitForWaConnection(sock), - new Promise((_, reject) => { - connectionTimeout = setTimeout( - () => reject(new Error("timed out waiting for WhatsApp QA driver session")), - params.connectionTimeoutMs ?? 45_000, - ); - connectionTimeout.unref?.(); - }), - ]); + await waitForWaConnection(sock, { timeoutMs: params.connectionTimeoutMs ?? 45_000 }); } catch (error) { closeSessionResources( error instanceof Error ? error : new Error("failed starting WhatsApp QA driver session"), ); throw error; - } finally { - if (connectionTimeout) { - clearTimeout(connectionTimeout); - } } const sendApi = createWebSendApi({ diff --git a/extensions/whatsapp/src/session.test.ts b/extensions/whatsapp/src/session.test.ts index de67e56dadb7..7cc29b7a1435 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -508,6 +508,16 @@ describe("web session", () => { }); it("waits for connection open", async () => { + const ev = new EventEmitter(); + const promise = waitForWaConnection( + { ev } as unknown as ReturnType, + { timeout: "none" }, + ); + ev.emit("connection.update", { connection: "open" }); + await expect(promise).resolves.toBeUndefined(); + }); + + it("keeps one-argument callers on the old no-timeout wait policy", async () => { const ev = new EventEmitter(); const promise = waitForWaConnection({ ev } as unknown as ReturnType< typeof baileys.makeWASocket @@ -518,9 +528,10 @@ describe("web session", () => { it("rejects when connection closes", async () => { const ev = new EventEmitter(); - const promise = waitForWaConnection({ ev } as unknown as ReturnType< - typeof baileys.makeWASocket - >); + const promise = waitForWaConnection( + { ev } as unknown as ReturnType, + { timeout: "none" }, + ); ev.emit("connection.update", { connection: "close", lastDisconnect: new Error("bye"), @@ -528,6 +539,35 @@ describe("web session", () => { await expect(promise).rejects.toBeInstanceOf(Error); }); + it("rejects after timeout with no connection event", async () => { + vi.useFakeTimers(); + const ev = new EventEmitter(); + const promise = waitForWaConnection( + { ev } as unknown as ReturnType, + { timeoutMs: 100 }, + ); + vi.advanceTimersByTime(100); + const error = await promise.catch((err: unknown) => err); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("timed out after 100ms"); + expect(error).toMatchObject({ output: { statusCode: 408 } }); + expect(ev.listenerCount("connection.update")).toBe(0); + vi.useRealTimers(); + }); + + it("clears timeout when connection opens before timeout", async () => { + vi.useFakeTimers(); + const ev = new EventEmitter(); + const promise = waitForWaConnection( + { ev } as unknown as ReturnType, + { timeoutMs: 5000 }, + ); + ev.emit("connection.update", { connection: "open" }); + await expect(promise).resolves.toBeUndefined(); + expect(ev.listenerCount("connection.update")).toBe(0); + vi.useRealTimers(); + }); + it("logWebSelfId prints cached E.164 when creds exist", () => { const authDir = createTempAuthDir("openclaw-wa-log-self"); fsSync.writeFileSync( diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index d60ea11fd4ba..98b6aaa5b653 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -27,7 +27,6 @@ import { import { renderQrTerminal } from "./qr-terminal.js"; import { getStatusCode } from "./session-errors.js"; import { - DisconnectReason, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, makeWASocket, @@ -63,7 +62,7 @@ export { } from "./creds-persistence.js"; export type { CredsQueueWaitResult } from "./creds-persistence.js"; -const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401; +const LOGGED_OUT_STATUS = 401; const WHATSAPP_WEBSOCKET_PROXY_TARGET = "https://mmg.whatsapp.net/"; const CREDS_FLUSH_TIMEOUT_MESSAGE = "Queued WhatsApp creds save did not finish before auth bootstrap; skipping repair and continuing with primary creds."; @@ -311,21 +310,41 @@ function normalizeEnvProxyValue(value: string | undefined): string | null | unde return trimmed.length > 0 ? trimmed : null; } -export async function waitForWaConnection(sock: ReturnType) { +export type WhatsAppConnectionWaitOptions = + | { + timeout: "none"; + } + | { + timeoutMs: number; + }; + +export async function waitForWaConnection( + sock: ReturnType, + options: WhatsAppConnectionWaitOptions = { timeout: "none" }, +) { return new Promise((resolve, reject) => { type OffCapable = { off?: (event: string, listener: (...args: unknown[]) => void) => void; }; const evWithOff = sock.ev as unknown as OffCapable; + let timer: ReturnType | undefined; + + const cleanup = () => { + evWithOff.off?.("connection.update", handler); + if (timer) { + clearTimeout(timer); + timer = undefined; + } + }; const handler = (...args: unknown[]) => { const update = (args[0] ?? {}) as Partial; if (update.connection === "open") { - evWithOff.off?.("connection.update", handler); + cleanup(); resolve(); } if (update.connection === "close") { - evWithOff.off?.("connection.update", handler); + cleanup(); reject( toLintErrorObject( update.lastDisconnect ?? new Error("Connection closed"), @@ -336,6 +355,15 @@ export async function waitForWaConnection(sock: ReturnType) }; sock.ev.on("connection.update", handler); + + if ("timeoutMs" in options) { + const timeoutMs = options.timeoutMs; + timer = setTimeout(() => { + cleanup(); + reject(createConnectionTimeoutError(timeoutMs)); + }, timeoutMs); + timer.unref?.(); + } }); } @@ -356,3 +384,13 @@ function toLintErrorObject(value: unknown, fallbackMessage: string): Error { } return error; } + +function createConnectionTimeoutError(timeoutMs: number): Error { + const error = new Error(`WhatsApp connection timed out after ${timeoutMs}ms`); + Object.assign(error, { + output: { + statusCode: 408, + }, + }); + return error; +} diff --git a/src/plugins/runtime/runtime-web-channel-plugin.test.ts b/src/plugins/runtime/runtime-web-channel-plugin.test.ts index c5f94f272e9a..aea43e282d10 100644 --- a/src/plugins/runtime/runtime-web-channel-plugin.test.ts +++ b/src/plugins/runtime/runtime-web-channel-plugin.test.ts @@ -61,4 +61,24 @@ describe("runtime web channel plugin", () => { "web channel plugin runtime is missing export 'resolveDefaultWebAuthDir'", ); }); + + it("forwards the explicit connection wait policy to the heavy runtime", async () => { + const waitForWaConnection = vi.fn().mockResolvedValue(undefined); + const sock = { id: "socket" }; + + vi.doMock("./runtime-plugin-boundary.js", () => ({ + loadPluginBoundaryModule: () => ({ waitForWaConnection }), + resolvePluginRuntimeModulePath: () => "/tmp/runtime-api.js", + resolvePluginRuntimeRecordByEntryBaseNames: () => ({ + origin: "bundled", + source: "test", + }), + })); + + const { waitForWebChannelConnection } = await import("./runtime-web-channel-plugin.js"); + + await waitForWebChannelConnection(sock, { timeoutMs: 12_345 }); + + expect(waitForWaConnection).toHaveBeenCalledWith(sock, { timeoutMs: 12_345 }); + }); }); diff --git a/src/plugins/runtime/runtime-web-channel-plugin.ts b/src/plugins/runtime/runtime-web-channel-plugin.ts index a134b2d3171c..d44356a55881 100644 --- a/src/plugins/runtime/runtime-web-channel-plugin.ts +++ b/src/plugins/runtime/runtime-web-channel-plugin.ts @@ -26,6 +26,14 @@ type WebChannelPluginRecord = { source: string; }; +type WebChannelConnectionWaitOptions = + | { + timeout: "none"; + } + | { + timeoutMs: number; + }; + type WebChannelLightRuntimeModule = { getActiveWebListener: (accountId?: string | null) => unknown; getWebAuthAgeMs: (authDir?: string) => number | null; @@ -101,7 +109,7 @@ type WebChannelHeavyRuntimeModule = { monitorWebChannel: (...args: unknown[]) => Promise; monitorWebInbox: (...args: unknown[]) => Promise; startWebLoginWithQr: (...args: unknown[]) => Promise; - waitForWaConnection: (sock: unknown) => Promise; + waitForWaConnection: (sock: unknown, options: WebChannelConnectionWaitOptions) => Promise; waitForWebLogin: (...args: unknown[]) => Promise; extractMediaPlaceholder: (...args: unknown[]) => unknown; extractText: (...args: unknown[]) => unknown;